Add staff application feature with API integration and frontend form implementation

This commit is contained in:
2025-09-24 22:26:17 +02:00
parent 2a0f38aa28
commit f886609a0e
13 changed files with 992 additions and 28 deletions
+8
View File
@@ -126,6 +126,14 @@ export const routes: Routes = [
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
@@ -17,6 +17,14 @@
</p>
</a>
</div>
<div class="columnParagraph">
<a [routerLink]="['/forms/staff-application']">
<h2>Staff Application</h2>
<p>
Interested in becoming a moderator on our server? Apply here.
</p>
</a>
</div>
</div>
</section>
</section>
@@ -0,0 +1,309 @@
<div>
<app-header [current_page]="'staff-application'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Staff Application</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection staff-application-container">
<div class="form-container">
<div class="pages">
<!-- Welcome Page -->
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
<h1>Moderator Application</h1>
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
<p>Please take your time to fill out this application thoroughly.</p>
<button mat-raised-button (click)="nextPage()">
Get Started
</button>
</section>
}
<!-- Confirmation Page -->
@if (currentPageIndex === 1) {
<section class="formPage">
<div class="description">
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
please continue</p>
<br>
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant process.
We will review your application carefully and get back to you if we think you're a good fit.</p>
<p style="font-style: italic;">Applications that seem to have been made with
little to no effort will be automatically rejected.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
<form [formGroup]="form">
<!-- Basic Information Page -->
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<h2>Basic Information</h2>
<!-- Email -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span>
</ng-container>
</div>
}
<!-- Age -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Age</mat-label>
<input matInput
formControlName="age"
placeholder="Age">
@if (form.controls.age.invalid && form.controls.age.touched) {
<mat-error>
@if (form.controls.age.errors?.['required']) {
Age is required
} @else if (form.controls.age.errors?.['min']) {
You must be at least 13 years old
} @else if (form.controls.age.errors?.['pattern']) {
Please enter a valid number
}
</mat-error>
}
</mat-form-field>
<!-- Discord Username -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Discord Username</mat-label>
<input matInput
formControlName="discordUsername"
placeholder="Discord Username">
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
<mat-error>
Discord username is required
</mat-error>
}
</mat-form-field>
<!-- PC Requirements -->
<div class="checkbox-field">
<mat-checkbox formControlName="meetsRequirements">
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and able to talk in voice chat)
</mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error">
You must meet the PC requirements to apply
</mat-error>
}
</div>
<!-- Pronouns (Optional) -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Pronouns (Optional)</mat-label>
<input matInput
formControlName="pronouns"
placeholder="Pronouns">
</mat-form-field>
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
Next
</button>
</section>
}
<!-- Experience Page -->
@if (currentPageIndex === 3) {
<section class="formPage">
<div class="description">
<h2>Experience & Availability</h2>
<!-- Join Date -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>When did you join our server? (Estimate)</mat-label>
<input matInput [matDatepicker]="picker" formControlName="joinDate">
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
<mat-error>
Join date is required
</mat-error>
}
</mat-form-field>
<!-- Weekly Playtime -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Average expected playtime in a week (hours)</mat-label>
<input matInput
formControlName="weeklyPlaytime"
placeholder="Hours per week">
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
<mat-error>
@if (form.controls.weeklyPlaytime.errors?.['required']) {
Weekly playtime is required
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
Weekly playtime must be at least 1 hour
}
</mat-error>
}
</mat-form-field>
<!-- Available Days -->
<div class="field-container">
<label class="field-label">Available days for moderating:</label>
<div class="days-container">
@for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)" (click)="toggleDay(day)">
{{ day }}
</div>
}
</div>
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
<mat-error>
Please select at least one day
</mat-error>
}
</div>
<!-- Available Times -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput
formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error>
Available times are required
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="nextPage()"
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
Next
</button>
</section>
}
<!-- Qualifications Page -->
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Qualifications & Expectations</h2>
<!-- Previous Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput
formControlName="previousExperience"
placeholder="Describe your previous experience"
rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error>
@if (form.controls.previousExperience.errors?.['required']) {
Previous experience is required
} @else if (form.controls.previousExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Plugin Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput
formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins"
rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error>
@if (form.controls.pluginExperience.errors?.['required']) {
Plugin experience is required
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Moderator Expectations -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput
formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do"
rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) {
Moderator expectations are required
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Additional Information -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label>
<textarea matInput
formControlName="additionalInfo"
placeholder="Any additional information you'd like to share"
rows="4"></textarea>
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()"
[disabled]="form.controls.previousExperience.invalid || form.controls.pluginExperience.invalid || form.controls.moderatorExpectations.invalid">
Submit Application
</button>
</section>
}
</form>
</div>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
}
</div>
</section>
</main>
</div>
@@ -0,0 +1,164 @@
:host {
display: block;
}
.staff-application-container {
display: flex;
flex-direction: column;
min-height: 80vh;
}
main {
flex: 1;
display: flex;
flex-direction: column;
}
.form-container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
.formPage {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
height: 100%;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.navigation-buttons {
display: flex;
gap: 16px;
margin-top: 20px;
}
.form-navigation {
display: flex;
justify-content: center;
gap: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: auto;
margin-bottom: auto;
&.active {
background-color: #fff;
}
}
.nav-button {
color: #1f9bde;
}
.pages {
margin-top: auto;
margin-bottom: auto;
}
.description {
max-width: 75ch;
text-align: left;
}
.valid-email {
display: flex;
align-items: center;
color: #4CAF50;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
}
.valid-email span {
color: #4CAF50;
font-weight: 500;
}
.checkbox-field {
margin: 16px 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.checkbox-error {
color: #f44336;
font-size: 12px;
margin-top: 4px;
}
.field-container {
margin: 16px 0;
width: 100%;
}
.field-label {
font-size: 16px;
margin-bottom: 8px;
display: block;
color: rgba(255, 255, 255, 0.7);
}
.days-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.day-chip {
padding: 8px 12px;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.3s ease;
&.selected {
background-color: #1f9bde;
color: white;
}
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
mat-form-field {
margin-bottom: 16px;
}
@@ -0,0 +1,348 @@
import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ApplicationsService, EmailEntry, MailService, StaffApplication} from '@api';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {AuthService} from '@services/auth.service';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {MatDialog} from '@angular/material/dialog';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {Router} from '@angular/router';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatDatepickerModule} from '@angular/material/datepicker';
import {MatNativeDateModule} from '@angular/material/core';
import {MatChipsModule} from '@angular/material/chips';
@Component({
selector: 'app-staff-application',
imports: [
HeaderComponent,
NgOptimizedImage,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
ReactiveFormsModule,
MatCheckboxModule,
MatDatepickerModule,
MatNativeDateModule,
MatChipsModule
],
templateUrl: './staff-application.component.html',
styleUrl: './staff-application.component.scss'
})
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
private mailService = inject(MailService);
public authService = inject(AuthService);
public staffApplicationService = inject(ApplicationsService)
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected form: FormGroup<StaffApplicationForm>;
private emails = signal<EmailEntry[]>([]);
protected verifiedEmails = computed(() => this.emails()
.filter(email => email.verified)
.map(email => email.email.toLowerCase()));
protected emailIsValid = signal<boolean>(false);
protected dialog = inject(MatDialog);
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
protected selectedDays: string[] = [];
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) {
const staffApplication: StaffApplicationForm = {
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email, Validators.maxLength(320)]
}),
age: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(13), Validators.pattern('^[0-9]*$')]
}),
discordUsername: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(32)]
}),
meetsRequirements: new FormControl(false, {
nonNullable: true,
validators: [Validators.requiredTrue]
}),
pronouns: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(32)]
}),
joinDate: new FormControl('', {
nonNullable: true,
validators: [Validators.required]
}),
weeklyPlaytime: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(1)]
}),
availableDays: new FormControl([], {
nonNullable: true,
validators: [Validators.required]
}),
availableTimes: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(1000)]
}),
previousExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
pluginExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
moderatorExpectations: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
additionalInfo: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(4000)]
})
}
this.form = new FormGroup(staffApplication);
this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails);
});
this.form.valueChanges.subscribe(() => {
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
this.emailIsValid.set(true);
} else {
this.emailIsValid.set(false);
}
});
computed(() => {
if (this.verifiedEmails().length > 0) {
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
this.emailIsValid.set(true);
}
});
}
ngOnInit() {
const uuid = this.authService.getUuid();
if (uuid === null) {
alert('Error retrieving token, please relog on the website and try again')
throw new Error('JWT subject is null, are you logged in?');
}
}
ngAfterViewInit() {
this.setupResizeObserver();
this.updateContainerHeight();
this.boundHandleResize = this.handleResize.bind(this);
window.addEventListener('resize', this.boundHandleResize);
setTimeout(() => this.updateContainerHeight(), 0);
}
ngOnDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.boundHandleResize) {
window.removeEventListener('resize', this.boundHandleResize);
}
}
private handleResize() {
this.updateContainerHeight();
}
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.updateContainerHeight();
});
const headerElement = document.querySelector('app-header');
if (headerElement) {
this.resizeObserver.observe(headerElement);
}
const footerElement = document.querySelector('footer');
if (footerElement) {
this.resizeObserver.observe(footerElement);
}
}
private updateContainerHeight() {
const headerElement = document.querySelector('app-header');
const footerElement = document.querySelector('footer');
const container = this.elementRef.nativeElement.querySelector('.staff-application-container');
if (headerElement && footerElement && container) {
const headerHeight = headerElement.getBoundingClientRect().height;
const footerHeight = footerElement.getBoundingClientRect().height;
const calculatedHeight = `calc(100vh - ${headerHeight}px - ${footerHeight}px)`;
this.renderer.setStyle(container, 'min-height', calculatedHeight);
}
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
// Mark all fields as touched to show validation errors
Object.keys(this.form.controls).forEach(field => {
const control = this.form.get(field);
control?.markAsTouched();
});
}
}
private router = inject(Router)
private sendForm() {
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
//TODO route to mail page
// Navigate to the sent page
this.router.navigate(['/forms/sent'], {
state: {message: 'Your staff application has been submitted successfully. We will review your application and get back to you soon.'}
}).then();
})
}
public currentPageIndex: number = 0;
public totalPages: number[] = [0, 1, 2, 3, 4];
public goToPage(pageIndex: number): void {
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
this.currentPageIndex = pageIndex;
}
}
public previousPage() {
this.goToPage(this.currentPageIndex - 1);
}
public nextPage() {
this.goToPage(this.currentPageIndex + 1);
}
public isFirstPage(): boolean {
return this.currentPageIndex === 0;
}
public isLastPage(): boolean {
return this.currentPageIndex === this.totalPages.length - 1;
}
protected validateMailOrNextPage() {
if (this.emailIsValid()) {
this.nextPage();
return;
}
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
data: {email: this.form.getRawValue().email},
});
dialogRef.afterClosed().subscribe(result => {
if (result === true) {
this.emailIsValid.set(true);
this.nextPage();
}
});
}
toggleDay(day: string) {
const availableDaysControl = this.form.get('availableDays');
const currentDays = [...(availableDaysControl?.value || [])];
if (currentDays.includes(day)) {
const index = currentDays.indexOf(day);
currentDays.splice(index, 1);
} else {
currentDays.push(day);
}
availableDaysControl?.setValue(currentDays);
}
private mapToStaffApplication(formData: any): StaffApplication {
let joinDateString: string;
if (formData.joinDate instanceof Date) {
joinDateString = formData.joinDate.toISOString();
} else if (typeof formData.joinDate === 'string' && formData.joinDate.trim() !== '') {
const parsedDate = new Date(formData.joinDate);
if (isNaN(parsedDate.getTime())) {
throw new Error('Invalid date string');
}
joinDateString = parsedDate.toISOString();
} else {
throw new Error('Invalid date string');
}
return {
email: formData.email,
age: Number(formData.age),
discordUsername: formData.discordUsername,
meetsRequirements: formData.meetsRequirements,
pronouns: formData.pronouns || '',
joinDate: joinDateString,
weeklyPlaytime: Number(formData.weeklyPlaytime),
availableDays: formData.availableDays,
availableTimes: formData.availableTimes,
previousExperience: formData.previousExperience,
pluginExperience: formData.pluginExperience,
moderatorExpectations: formData.moderatorExpectations,
additionalInfo: formData.additionalInfo || ''
};
}
}
interface StaffApplicationForm {
email: FormControl<string>;
age: FormControl<string>;
discordUsername: FormControl<string>;
meetsRequirements: FormControl<boolean>;
pronouns: FormControl<string>;
joinDate: FormControl<string>;
weeklyPlaytime: FormControl<string>;
availableDays: FormControl<string[]>;
availableTimes: FormControl<string>;
previousExperience: FormControl<string>;
pluginExperience: FormControl<string>;
moderatorExpectations: FormControl<string>;
additionalInfo: FormControl<string>;
}