feat(frontend): add react spa with wishlist flows and public profile

This commit is contained in:
Anton
2026-04-23 16:05:27 +03:00
parent 5f6a551b6c
commit 00f01611ed
44 changed files with 2166 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
export interface ApiErrorShape {
error: string;
message: string;
details?: unknown;
}
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly details?: unknown;
constructor(status: number, body: ApiErrorShape) {
super(body.message);
this.status = status;
this.code = body.error;
this.details = body.details;
}
}
interface RequestOptions<TBody = unknown> {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: TBody;
signal?: AbortSignal;
formData?: FormData;
}
async function request<TResponse, TBody = unknown>(
path: string,
options: RequestOptions<TBody> = {},
): Promise<TResponse> {
const headers: Record<string, string> = {};
let body: BodyInit | undefined;
if (options.formData) {
body = options.formData;
} else if (options.body !== undefined) {
headers['content-type'] = 'application/json';
body = JSON.stringify(options.body);
}
const response = await fetch(path, {
method: options.method ?? 'GET',
headers,
body,
credentials: 'include',
signal: options.signal,
});
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
const errBody: ApiErrorShape =
payload && typeof payload === 'object' && 'error' in payload
? (payload as ApiErrorShape)
: { error: 'HTTP', message: response.statusText || 'Request failed' };
throw new ApiError(response.status, errBody);
}
return payload as TResponse;
}
export const api = {
get: <T>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
post: <T, B = unknown>(path: string, body?: B) => request<T, B>(path, { method: 'POST', body }),
patch: <T, B = unknown>(path: string, body?: B) =>
request<T, B>(path, { method: 'PATCH', body }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', formData }),
};

View File

@@ -0,0 +1,6 @@
import clsx, { type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,25 @@
export function formatPrice(price: string | null | undefined, currency: string): string | null {
if (!price) return null;
const n = Number(price);
if (Number.isNaN(n)) return `${price} ${currency}`;
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(n);
} catch {
return `${n.toLocaleString()} ${currency}`;
}
}
export function formatDate(iso: string | Date): string {
const d = typeof iso === 'string' ? new Date(iso) : iso;
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}
export function daysLeftUntil(iso: string | Date, retentionDays: number): number {
const d = typeof iso === 'string' ? new Date(iso) : iso;
const expires = d.getTime() + retentionDays * 24 * 60 * 60 * 1000;
return Math.max(0, Math.ceil((expires - Date.now()) / (24 * 60 * 60 * 1000)));
}

View File

@@ -0,0 +1,4 @@
declare const __FRONTEND_VERSION__: string;
export const FRONTEND_VERSION: string =
typeof __FRONTEND_VERSION__ !== 'undefined' ? __FRONTEND_VERSION__ : '0.0.0';