website refactor

This commit is contained in:
2026-01-14 02:02:24 +01:00
parent 8d7c709e0c
commit 4522d41aef
291 changed files with 12763 additions and 9309 deletions

View File

@@ -0,0 +1,197 @@
/**
* @file SearchParamBuilder.ts
* Type-safe builder for constructing search parameter strings
* Pure function, no side effects
*/
import { SearchParamValidators } from './SearchParamValidators';
export class SearchParamBuilder {
private params: URLSearchParams;
constructor() {
this.params = new URLSearchParams();
}
// Auth params
returnTo(value: string | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateReturnTo(value);
if (!validation.isValid) {
throw new Error(`Invalid returnTo: ${validation.errors.join(', ')}`);
}
this.params.set('returnTo', value);
}
return this;
}
token(value: string | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateToken(value);
if (!validation.isValid) {
throw new Error(`Invalid token: ${validation.errors.join(', ')}`);
}
this.params.set('token', value);
}
return this;
}
email(value: string | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateEmail(value);
if (!validation.isValid) {
throw new Error(`Invalid email: ${validation.errors.join(', ')}`);
}
this.params.set('email', value);
}
return this;
}
error(value: string | null): this {
if (value !== null) {
this.params.set('error', value);
}
return this;
}
message(value: string | null): this {
if (value !== null) {
this.params.set('message', value);
}
return this;
}
// Sponsor params
campaignType(value: string | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateCampaignType(value);
if (!validation.isValid) {
throw new Error(`Invalid campaign type: ${validation.errors.join(', ')}`);
}
this.params.set('type', value);
}
return this;
}
campaignId(value: string | null): this {
if (value !== null) {
this.params.set('campaignId', value);
}
return this;
}
// Pagination params
page(value: number | null): this {
if (value !== null) {
const validation = SearchParamValidators.validatePage(value.toString());
if (!validation.isValid) {
throw new Error(`Invalid page: ${validation.errors.join(', ')}`);
}
this.params.set('page', value.toString());
}
return this;
}
limit(value: number | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateLimit(value.toString());
if (!validation.isValid) {
throw new Error(`Invalid limit: ${validation.errors.join(', ')}`);
}
this.params.set('limit', value.toString());
}
return this;
}
offset(value: number | null): this {
if (value !== null) {
this.params.set('offset', value.toString());
}
return this;
}
// Sorting params
sortBy(value: string | null): this {
if (value !== null) {
this.params.set('sortBy', value);
}
return this;
}
order(value: string | null): this {
if (value !== null) {
const validation = SearchParamValidators.validateOrder(value);
if (!validation.isValid) {
throw new Error(`Invalid order: ${validation.errors.join(', ')}`);
}
this.params.set('order', value);
}
return this;
}
// Filtering params
status(value: string | null): this {
if (value !== null) {
this.params.set('status', value);
}
return this;
}
role(value: string | null): this {
if (value !== null) {
this.params.set('role', value);
}
return this;
}
tier(value: string | null): this {
if (value !== null) {
this.params.set('tier', value);
}
return this;
}
// Generic setter
set(key: string, value: string | null): this {
if (value !== null) {
this.params.set(key, value);
}
return this;
}
// Build the query string
build(): string {
const queryString = this.params.toString();
return queryString ? `?${queryString}` : '';
}
// Static factory methods for common scenarios
static auth(returnTo?: string | null, token?: string | null, email?: string | null): string {
const builder = new SearchParamBuilder();
if (returnTo !== undefined) builder.returnTo(returnTo);
if (token !== undefined) builder.token(token);
if (email !== undefined) builder.email(email);
return builder.build();
}
static pagination(page?: number | null, limit?: number | null): string {
const builder = new SearchParamBuilder();
if (page !== undefined) builder.page(page);
if (limit !== undefined) builder.limit(limit);
return builder.build();
}
static sorting(sortBy?: string | null, order?: string | null): string {
const builder = new SearchParamBuilder();
if (sortBy !== undefined) builder.sortBy(sortBy);
if (order !== undefined) builder.order(order);
return builder.build();
}
static sponsorCampaign(type?: string | null, campaignId?: string | null): string {
const builder = new SearchParamBuilder();
if (type !== undefined) builder.campaignType(type);
if (campaignId !== undefined) builder.campaignId(campaignId);
return builder.build();
}
}

View File

@@ -0,0 +1,208 @@
/**
* @file SearchParamParser.ts
* Type-safe parser for search parameters from URL
* Returns Result type for clean error handling
*/
import { Result } from '@/lib/contracts/Result';
import { SearchParamValidators } from './SearchParamValidators';
export interface ParsedAuthParams {
returnTo?: string | null;
token?: string | null;
email?: string | null;
error?: string | null;
message?: string | null;
}
export interface ParsedSponsorParams {
type?: string | null;
campaignId?: string | null;
}
export interface ParsedPaginationParams {
page?: number;
limit?: number;
offset?: number;
}
export interface ParsedSortingParams {
sortBy?: string | null;
order?: 'asc' | 'desc';
}
export interface ParsedFilterParams {
status?: string | null;
role?: string | null;
tier?: string | null;
}
export class SearchParamParser {
// Parse auth parameters
static parseAuth(params: URLSearchParams): Result<ParsedAuthParams, string> {
const errors: string[] = [];
const returnTo = params.get('returnTo');
if (returnTo !== null) {
const validation = SearchParamValidators.validateReturnTo(returnTo);
if (!validation.isValid) {
errors.push(...validation.errors);
}
}
const token = params.get('token');
if (token !== null) {
const validation = SearchParamValidators.validateToken(token);
if (!validation.isValid) {
errors.push(...validation.errors);
}
}
const email = params.get('email');
if (email !== null) {
const validation = SearchParamValidators.validateEmail(email);
if (!validation.isValid) {
errors.push(...validation.errors);
}
}
if (errors.length > 0) {
return Result.err(errors.join(', '));
}
return Result.ok({
returnTo: params.get('returnTo'),
token: params.get('token'),
email: params.get('email'),
error: params.get('error'),
message: params.get('message'),
});
}
// Parse sponsor parameters
static parseSponsor(params: URLSearchParams): Result<ParsedSponsorParams, string> {
const errors: string[] = [];
const type = params.get('type');
if (type !== null) {
const validation = SearchParamValidators.validateCampaignType(type);
if (!validation.isValid) {
errors.push(...validation.errors);
}
}
if (errors.length > 0) {
return Result.err(errors.join(', '));
}
return Result.ok({
type: params.get('type'),
campaignId: params.get('campaignId'),
});
}
// Parse pagination parameters
static parsePagination(params: URLSearchParams): Result<ParsedPaginationParams, string> {
const result: ParsedPaginationParams = {};
const errors: string[] = [];
const page = params.get('page');
if (page !== null) {
const validation = SearchParamValidators.validatePage(page);
if (!validation.isValid) {
errors.push(...validation.errors);
} else {
result.page = parseInt(page);
}
}
const limit = params.get('limit');
if (limit !== null) {
const validation = SearchParamValidators.validateLimit(limit);
if (!validation.isValid) {
errors.push(...validation.errors);
} else {
result.limit = parseInt(limit);
}
}
const offset = params.get('offset');
if (offset !== null) {
const num = parseInt(offset);
if (!isNaN(num)) {
result.offset = num;
}
}
if (errors.length > 0) {
return Result.err(errors.join(', '));
}
return Result.ok(result);
}
// Parse sorting parameters
static parseSorting(params: URLSearchParams): Result<ParsedSortingParams, string> {
const errors: string[] = [];
const order = params.get('order');
if (order !== null) {
const validation = SearchParamValidators.validateOrder(order);
if (!validation.isValid) {
errors.push(...validation.errors);
}
}
if (errors.length > 0) {
return Result.err(errors.join(', '));
}
return Result.ok({
sortBy: params.get('sortBy'),
order: (params.get('order') as 'asc' | 'desc') || undefined,
});
}
// Parse filter parameters
static parseFilters(params: URLSearchParams): Result<ParsedFilterParams, string> {
return Result.ok({
status: params.get('status'),
role: params.get('role'),
tier: params.get('tier'),
});
}
// Parse all parameters at once
static parseAll(params: URLSearchParams): Result<
{
auth: ParsedAuthParams;
sponsor: ParsedSponsorParams;
pagination: ParsedPaginationParams;
sorting: ParsedSortingParams;
filters: ParsedFilterParams;
},
string
> {
const authResult = this.parseAuth(params);
if (authResult.isErr()) return Result.err(authResult.getError());
const sponsorResult = this.parseSponsor(params);
if (sponsorResult.isErr()) return Result.err(sponsorResult.getError());
const paginationResult = this.parsePagination(params);
if (paginationResult.isErr()) return Result.err(paginationResult.getError());
const sortingResult = this.parseSorting(params);
if (sortingResult.isErr()) return Result.err(sortingResult.getError());
const filtersResult = this.parseFilters(params);
return Result.ok({
auth: authResult.unwrap(),
sponsor: sponsorResult.unwrap(),
pagination: paginationResult.unwrap(),
sorting: sortingResult.unwrap(),
filters: filtersResult.unwrap(),
});
}
}

View File

@@ -0,0 +1,48 @@
/**
* @file SearchParamTypes.ts
* Pure type definitions for search parameters
* No logic, just types
*/
export interface SearchParamSpec {
name: string;
description?: string;
defaultValue?: string | null;
}
export type SearchParamMap = Record<string, SearchParamSpec>;
// Auth page search params
export interface AuthSearchParams {
returnTo: SearchParamSpec;
token: SearchParamSpec;
email: SearchParamSpec;
error: SearchParamSpec;
message: SearchParamSpec;
}
// Sponsor campaign search params
export interface SponsorCampaignSearchParams {
type: SearchParamSpec;
campaignId: SearchParamSpec;
}
// Pagination search params
export interface PaginationSearchParams {
page: SearchParamSpec;
limit: SearchParamSpec;
offset: SearchParamSpec;
}
// Sorting search params
export interface SortingSearchParams {
sortBy: SearchParamSpec;
order: SearchParamSpec;
}
// Generic filter params
export interface FilterSearchParams {
status: SearchParamSpec;
role: SearchParamSpec;
tier: SearchParamSpec;
}

View File

@@ -0,0 +1,90 @@
/**
* @file SearchParamValidators.ts
* Pure validation logic for search parameters
* No side effects, no dependencies
*/
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
export class SearchParamValidators {
// Auth validators
static validateReturnTo(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
if (!value.startsWith('/')) {
return { isValid: false, errors: ['returnTo must start with /'] };
}
if (value.includes('://') || value.includes('//')) {
return { isValid: false, errors: ['returnTo must be a relative path'] };
}
return { isValid: true, errors: [] };
}
static validateToken(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
if (value.length === 0) {
return { isValid: false, errors: ['token cannot be empty'] };
}
return { isValid: true, errors: [] };
}
static validateEmail(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
if (!value.includes('@')) {
return { isValid: false, errors: ['email must be valid'] };
}
return { isValid: true, errors: [] };
}
// Sponsor validators
static validateCampaignType(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
const validTypes = ['leagues', 'teams', 'drivers', 'races', 'platform'];
if (!validTypes.includes(value)) {
return { isValid: false, errors: [`type must be one of: ${validTypes.join(', ')}`] };
}
return { isValid: true, errors: [] };
}
// Pagination validators
static validatePage(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
const num = parseInt(value);
if (isNaN(num) || num < 1) {
return { isValid: false, errors: ['page must be a positive integer'] };
}
return { isValid: true, errors: [] };
}
static validateLimit(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
const num = parseInt(value);
if (isNaN(num) || num < 1) {
return { isValid: false, errors: ['limit must be a positive integer'] };
}
return { isValid: true, errors: [] };
}
// Sorting validators
static validateOrder(value: string | null): ValidationResult {
if (value === null) return { isValid: true, errors: [] };
if (!['asc', 'desc'].includes(value)) {
return { isValid: false, errors: ['order must be asc or desc'] };
}
return { isValid: true, errors: [] };
}
// Generic validators
static validateRequired(value: string | null, fieldName: string): ValidationResult {
if (value === null || value.length === 0) {
return { isValid: false, errors: [`${fieldName} is required`] };
}
return { isValid: true, errors: [] };
}
static validateOptional(value: string | null): ValidationResult {
return { isValid: true, errors: [] };
}
}