Add ticket validation feature for employees
Introduces ticket validation and result components for employee access control, including UI for scanning and manual code entry. Updates routing, guards, and navigation for mobile support and improves ticket status handling. Also adds ngx-scanner-qrcode dependency and minor UI fixes.
This commit is contained in:
@@ -27,7 +27,7 @@ import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import {MatSelectModule} from '@angular/material/select';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
|
||||
import { HeaderComponent } from './header/header.component';
|
||||
@@ -79,7 +79,8 @@ import { PricelistComponent } from './pricelist/pricelist.component';
|
||||
import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component';
|
||||
import { PdfTicketComponent } from './pdf-ticket/pdf-ticket.component';
|
||||
import { TestComponent } from './test/test.component';
|
||||
|
||||
import { TicketValidationComponent } from './ticket-validation/ticket-validation.component';
|
||||
import { TicketValidationResultComponent } from './ticket-validation-result/ticket-validation-result.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@@ -133,6 +134,8 @@ import { TestComponent } from './test/test.component';
|
||||
PricelistComponent,
|
||||
TheaterLayoutDesignerComponent,
|
||||
TestComponent,
|
||||
TicketValidationComponent,
|
||||
TicketValidationResultComponent,
|
||||
],
|
||||
imports: [
|
||||
AppRoutingModule,
|
||||
@@ -167,7 +170,7 @@ import { TestComponent } from './test/test.component';
|
||||
MatTableModule,
|
||||
MatSelectModule,
|
||||
MatSortModule,
|
||||
PdfTicketComponent
|
||||
PdfTicketComponent,
|
||||
],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
@@ -178,4 +181,5 @@ import { TestComponent } from './test/test.component';
|
||||
],
|
||||
bootstrap: [App]
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const routes: Routes = [
|
||||
path: 'employee/validation/ticket',
|
||||
component: TicketValidationComponent,
|
||||
canActivate: [AuthGuard],
|
||||
data: { roles: ['employee'] },
|
||||
data: { roles: ['employee'], allowMobile: true },
|
||||
},
|
||||
{
|
||||
path: 'employee/validation/ticket/:ticketId',
|
||||
|
||||
@@ -33,7 +33,7 @@ export class AuthGuard implements CanActivate {
|
||||
const dialogRef = this.dialog.open(LoginDialog, {
|
||||
disableClose: true,
|
||||
backdropClass: 'backdropBackground',
|
||||
data: { user },
|
||||
data: { user, allowMobile: true },
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.afterClosed());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthService, User, UserRole } from './../auth.service';
|
||||
import { AuthService, UserRole } from './../auth.service';
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
@@ -13,9 +13,9 @@ export class NavbarComponent {
|
||||
{ label: 'Preise', path: '/prices', auth: null },
|
||||
{ label: 'Bezahlen', path: '/checkout/order', auth: null },
|
||||
{ label: 'Einlasskontrolle', path: '/employee/validation/ticket', auth: ['employee'] },
|
||||
{ label: 'Film importieren', path: '/admin/movie-importer', auth: ['admin']},
|
||||
{ label: 'Statistiken', path: '/admin/statistics', auth: ['admin'] },
|
||||
{ label: 'Saal-Designer', path: '/admin/designer', auth: ['admin'] },
|
||||
{ label: 'Film-Importer', path: '/admin/movie-importer', auth: ['admin']},
|
||||
];
|
||||
|
||||
private auth = inject(AuthService);
|
||||
|
||||
@@ -88,14 +88,14 @@ export class PayForOrderComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
const order = orders[0];
|
||||
if (order.booked) {
|
||||
this.formControl.setErrors({ alreadyBooked: true });
|
||||
return;
|
||||
}
|
||||
if (order.cancelled) {
|
||||
this.formControl.setErrors({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (order.booked) {
|
||||
this.formControl.setErrors({ alreadyBooked: true });
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/checkout/order', order.code]);
|
||||
}),
|
||||
catchError(err => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h1 class="text-xl font-bold">Vielen Dank für Ihren Einkauf!</h1>
|
||||
<p class="text-center">{{ infoText }}</p>
|
||||
|
||||
<app-ticket-list [tickets]="tickets()" class="w-8/10 my-4"></app-ticket-list>
|
||||
<app-ticket-list [tickets]="tickets()" class="w-fit my-4"></app-ticket-list>
|
||||
|
||||
<button mat-button type="button" [disabled]="isGenerating" matButton="filled" class="success-button w-80 mt-4" (click)="downloadTickets()">{{ getButtonText() }}</button>
|
||||
<button routerLink="/schedule" type="button" mat-button matButton="outlined" color="accent" class="success-button w-80 mt-1">Zur Programmauswahl</button>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<button routerLink="/checkout/order/{{ order().code }}" type="button" mat-button matButton="filled" color="accent" class="success-button mt-2 w-80">{{ buttonText }}</button>
|
||||
<button routerLink="/schedule" type="button" mat-button matButton="outlined" class="success-button mb-4 w-80 mt-1">Zurück zur Programmauswahl</button>
|
||||
<div [routerLink]="['/checkout/order', order().code]" [queryParams]="{ action: 'cancel' }" class="text-green-500 cursor-pointer w-fit mt-2">
|
||||
<div [routerLink]="['/checkout/order', order().code]" [queryParams]="{ action: 'cancel' }" class="text-green-500 cursor-pointer w-fit mt-2">
|
||||
Reservierung stornieren
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@
|
||||
<div class="flex flex-col flex-1 text-sm leading-tight ml-1">
|
||||
<p>
|
||||
{{ ticket().seat.row.category.name }}
|
||||
@if (ticket().seat.row.category.name.length > 10) {
|
||||
<br>
|
||||
}
|
||||
@else {
|
||||
•
|
||||
} Reihe
|
||||
• Reihe
|
||||
<span class="font-mono"><strong>{{ convertIntoRowName(ticket().seat.row.position) }}</strong></span>
|
||||
Platz
|
||||
<span class="font-mono"><strong>{{ ticket().seat.position }}</strong></span>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
@switch (result()) {
|
||||
|
||||
@case ('nothing') {
|
||||
<div class="w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2 text-center">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">qr_code_scanner</mat-icon>
|
||||
<h1 class="text-xl font-bold">Bitte Ticket scannen<br>oder Ticketcode eingeben</h1>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('invalid') {
|
||||
<div class="bg-red-200 text-red-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2 text-center">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">cancel</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Ticket ungültig!</h1>
|
||||
<p class="text-center">Unter der angegebenen Ticketnummer konnte keine gültige Eintrittskarte gefunden werden.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('unpaid') {
|
||||
@if (performance()) {
|
||||
<app-performance-info [performance]="performance()!" class="w-full"></app-performance-info>
|
||||
<div class="h-3"></div>
|
||||
}
|
||||
<div class="bg-orange-200 text-orange-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">cancel</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Ticket nicht bezahlt!</h1>
|
||||
<p class="text-center">Die Bestellung wurde noch nicht bezahlt und der Sitzplatz befindet sich noch im Status 'reserviert'.</p>
|
||||
@if (ticket()) {
|
||||
<app-ticket-small [ticket]="ticket()!" class="w-fit my-5"></app-ticket-small>
|
||||
}
|
||||
@if (order()) {
|
||||
<div [routerLink]="['/checkout/order', order()?.code]" class="cursor-pointer w-fit my-2">
|
||||
Ticket jetzt bezahlen
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('expired') {
|
||||
@if (performance()) {
|
||||
<app-performance-info [performance]="performance()!" class="w-full"></app-performance-info>
|
||||
<div class="h-3"></div>
|
||||
}
|
||||
<div class="bg-red-200 text-red-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">cancel</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Vorstellung beendet!</h1>
|
||||
<p class="text-center">Die Filmvorführung hat bereits stattgefunden.</p>
|
||||
@if (ticket()) {
|
||||
<app-ticket-small [ticket]="ticket()!" class="w-fit my-5"></app-ticket-small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('valid') {
|
||||
@if (performance()) {
|
||||
<app-performance-info [performance]="performance()!" class="w-full"></app-performance-info>
|
||||
<div class="h-3"></div>
|
||||
}
|
||||
<div class="bg-green-200 text-green-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">check_circle</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Ticket gültig!</h1>
|
||||
@if (ticket()) {
|
||||
<app-ticket-small [ticket]="ticket()!" class="w-fit my-5"></app-ticket-small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('early') {
|
||||
@if (performance()) {
|
||||
<app-performance-info [performance]="performance()!" class="w-full"></app-performance-info>
|
||||
<div class="h-3"></div>
|
||||
}
|
||||
<div class="bg-purple-200 text-purple-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">schedule</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Noch kein Einlass!</h1>
|
||||
<p class="text-center">Die Vorstellung beginnt in mehr als zwei Stunden.</p>
|
||||
@if (ticket()) {
|
||||
<app-ticket-small [ticket]="ticket()!" class="w-fit my-5"></app-ticket-small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('cancelled') {
|
||||
@if (performance()) {
|
||||
<app-performance-info [performance]="performance()!" class="w-full"></app-performance-info>
|
||||
<div class="h-3"></div>
|
||||
}
|
||||
<div class="bg-red-200 text-red-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-icons-outlined" style="font-size: 100px; width: 100px; height: 100px;">cancel</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Ticket storniert!</h1>
|
||||
@if (order()) {
|
||||
<p class="text-center">
|
||||
Die Bestellung wurde am {{ cancelledDate }} storniert.
|
||||
</p>
|
||||
}
|
||||
@if (ticket()) {
|
||||
<app-ticket-small [ticket]="ticket()!" class="w-fit my-5"></app-ticket-small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('error') {
|
||||
<div class="bg-red-200 text-red-600 rounded-md shadow-sm w-full h-full p-6 py-8 items-center justify-center flex flex-col space-y-2">
|
||||
<mat-icon class="mb-4 material-symbols-outlined" style="font-size: 100px; width: 100px; height: 100px;">error</mat-icon>
|
||||
<h1 class="text-2xl font-bold">Überprüfung fehlgeschlagen!</h1>
|
||||
<p class="text-center">Bei der Validierung des Tickets ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@default {
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<mat-progress-spinner
|
||||
mode="indeterminate"
|
||||
diameter="75"
|
||||
></mat-progress-spinner>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Bestellung, Eintrittskarte, Vorstellung } from '@infinimotion/model-frontend';
|
||||
|
||||
export type ValidationResult = 'nothing' | 'invalid' | 'unpaid' | 'expired' | 'valid' | 'early' | 'cancelled' | 'loading' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ticket-validation-result',
|
||||
standalone: false,
|
||||
templateUrl: './ticket-validation-result.component.html',
|
||||
styleUrl: './ticket-validation-result.component.css',
|
||||
})
|
||||
export class TicketValidationResultComponent {
|
||||
result = input.required<ValidationResult>();
|
||||
performance = input<Vorstellung>();
|
||||
ticket = input<Eintrittskarte>();
|
||||
order = input<Bestellung>();
|
||||
|
||||
get cancelledDate(): string {
|
||||
if (!this.order()?.cancelled) return '';
|
||||
return new Date(this.order()?.cancelled!).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.middle {
|
||||
position: relative;
|
||||
top: 45%;
|
||||
-webkit-transform: translateY(-50%);
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
61
src/app/ticket-validation/ticket-validation.component.html
Normal file
61
src/app/ticket-validation/ticket-validation.component.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<app-menu-header label="Einlasskontrolle: Ticketstatus prüfen" icon="security"></app-menu-header>
|
||||
|
||||
<div class="flex middle">
|
||||
<div class="flex-1 flex justify-center my-auto p-20 pl-50">
|
||||
<div class="w-full max-w-md">
|
||||
<form class="ticket-search-form w-full" (ngSubmit)="DoSubmit()">
|
||||
<div class="flex items-center space-x-4">
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label>Ticketnummer eingeben</mat-label>
|
||||
<input class="w-full" type="text"
|
||||
matInput
|
||||
[formControl]="formControl"
|
||||
(input)="onInput($event)"
|
||||
[mask]="'TXXXXXXX'"
|
||||
[patterns]="ticketPattern"
|
||||
placeholder="TXXXXXXX"
|
||||
maxlength="8"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- <mat-error>
|
||||
@if (formControl.hasError('invalid')) {
|
||||
Ungültiger Ticketcode
|
||||
}
|
||||
@else if (formControl.hasError('required')) {
|
||||
Bitte geben Sie den Ticketcode eingeben
|
||||
}
|
||||
@else if (formControl.hasError('severalTickets')) {
|
||||
Mehrere Tickets gefunden - bitte kontaktieren Sie den Support
|
||||
}
|
||||
@else if (formControl.hasError('serverError')) {
|
||||
Fehler beim Laden des Tickets
|
||||
}
|
||||
</mat-error> -->
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- @if (formControl.valid || !formControl.touched) {
|
||||
<div class="h-6"></div>
|
||||
} -->
|
||||
|
||||
<button
|
||||
mat-button
|
||||
class="w-full mt-3"
|
||||
matButton="filled"
|
||||
color="accent"
|
||||
[disabled]="(loadingService.loading$ | async) || (formControl.invalid && !formControl.hasError('serverError'))"
|
||||
type="submit"
|
||||
>
|
||||
Ticket prüfen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-center items-center p-20 pr-50">
|
||||
<div class="w-full max-w-md">
|
||||
<app-ticket-validation-result class="w-full h-100" [result]="result" [performance]="performance" [ticket]="ticket" [order]="order"></app-ticket-validation-result>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
147
src/app/ticket-validation/ticket-validation.component.ts
Normal file
147
src/app/ticket-validation/ticket-validation.component.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { HttpService } from '../http.service';
|
||||
import { LoadingService } from '../loading.service';
|
||||
import { catchError, map, Observable, of, take } from 'rxjs';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ValidationResult } from '../ticket-validation-result/ticket-validation-result.component';
|
||||
import { Bestellung, Eintrittskarte, Vorstellung } from '@infinimotion/model-frontend';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ticket-validation',
|
||||
standalone: false,
|
||||
templateUrl: './ticket-validation.component.html',
|
||||
styleUrl: './ticket-validation.component.css',
|
||||
})
|
||||
export class TicketValidationComponent implements OnInit {
|
||||
private httpService = inject(HttpService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
public loadingService = inject(LoadingService);
|
||||
|
||||
queryError?: string;
|
||||
|
||||
result: ValidationResult = 'nothing';
|
||||
performance?: Vorstellung;
|
||||
ticket?: Eintrittskarte;
|
||||
order?: Bestellung;
|
||||
|
||||
public ticketPattern = {
|
||||
'X': { pattern: /[A-Za-z0-9]/ },
|
||||
'T': { pattern: /[Tt]/ },
|
||||
};
|
||||
|
||||
formControl = new FormControl('', {
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.minLength(8),
|
||||
Validators.maxLength(8)
|
||||
]
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
const error = this.route.snapshot.queryParamMap.get('error');
|
||||
const code = this.route.snapshot.queryParamMap.get('code');
|
||||
|
||||
if (code) {
|
||||
this.formControl.setValue(code);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setTimeout(() => {
|
||||
this.formControl.clearValidators();
|
||||
this.formControl.setErrors({ [error]: true });
|
||||
this.formControl.markAsTouched();
|
||||
});
|
||||
|
||||
this.formControl.valueChanges.pipe(
|
||||
take(1),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {
|
||||
this.formControl.setValidators([
|
||||
Validators.required,
|
||||
Validators.minLength(8),
|
||||
Validators.maxLength(8)
|
||||
]);
|
||||
this.formControl.updateValueAndValidity();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onInput(event: Event) {
|
||||
this.queryError = undefined;
|
||||
const input = event.target as HTMLInputElement;
|
||||
const filtered = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
this.formControl.setValue(filtered, { emitEvent: false });
|
||||
}
|
||||
|
||||
DoSubmit() {
|
||||
this.formControl.markAsTouched();
|
||||
if (this.formControl.invalid) return;
|
||||
|
||||
const code = this.formControl.value?.trim();
|
||||
if (!code || code.length !== 8) return;
|
||||
|
||||
this.result = 'loading';
|
||||
this.loadingService.show();
|
||||
const ticketFilter = [`eq;code;string;${code}`];
|
||||
|
||||
this.httpService.getTicketsByFilter(ticketFilter).pipe(
|
||||
map(tickets => {
|
||||
this.loadingService.hide();
|
||||
|
||||
if (tickets.length === 0) {
|
||||
this.result = 'invalid';
|
||||
this.formControl.setErrors({ invalid: true });
|
||||
return
|
||||
}
|
||||
if (tickets.length > 1) {
|
||||
throw new Error("Für den Code existieren mehere Tickets");
|
||||
// this.formControl.setErrors({ severalTickets: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.ticket = tickets[0];
|
||||
this.order = this.ticket.order;
|
||||
this.performance = this.ticket.show;
|
||||
const now = new Date;
|
||||
|
||||
if (this.ticket.order.cancelled) {
|
||||
this.result = 'cancelled';
|
||||
return;
|
||||
}
|
||||
|
||||
const showStart = new Date(this.ticket.show.start)
|
||||
const showEnd = new Date(showStart.getTime() + this.ticket.show.movie.duration * 60 * 1000);
|
||||
if (showEnd < now) {
|
||||
this.result = 'expired';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ticket.order.booked === null) {
|
||||
this.result = 'unpaid';
|
||||
return;
|
||||
}
|
||||
|
||||
const twoHoursInMs = 2 * 60 * 60 * 1000;
|
||||
const twoHoursBeforeShow = new Date(showStart.getTime() - twoHoursInMs);
|
||||
if (now < twoHoursBeforeShow) {
|
||||
this.result = 'early';
|
||||
return;
|
||||
}
|
||||
|
||||
this.result = 'valid';
|
||||
}),
|
||||
catchError(err => {
|
||||
this.result = 'error';
|
||||
this.loadingService.hide();
|
||||
this.loadingService.showError(err);
|
||||
// this.formControl.setErrors({ serverError: true });
|
||||
console.log(err);
|
||||
return of(null);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user