chore: establish typed SvelteKit frontend foundation
This commit is contained in:
100
src/lib/api/client.ts
Normal file
100
src/lib/api/client.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { components } from './schema';
|
||||
|
||||
export type AuthSession = components['schemas']['AuthSession'];
|
||||
export type ReportingPeriod = components['schemas']['ReportingPeriod'];
|
||||
export type AnalyticsRun = components['schemas']['AnalyticsRun'];
|
||||
export type SessionDetailPage = components['schemas']['SessionDetailPage'];
|
||||
export type DetailView = components['schemas']['DetailView'];
|
||||
export type FailureStage = components['schemas']['FailureStage'];
|
||||
export type Problem = components['schemas']['Problem'];
|
||||
|
||||
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public problem?: Problem,
|
||||
public retryAfter?: number,
|
||||
) {
|
||||
super(problem?.detail ?? 'در ارتباط با سرویس مشکلی پیش آمد.');
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const type = response.headers.get('content-type');
|
||||
const problem = type?.includes('application/problem+json')
|
||||
? ((await response.json()) as Problem)
|
||||
: undefined;
|
||||
throw new ApiError(response.status, problem, Number(response.headers.get('retry-after')) || undefined);
|
||||
}
|
||||
|
||||
return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login(username: string, password: string) {
|
||||
return request<AuthSession>('/auth/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
},
|
||||
session() {
|
||||
return request<AuthSession>('/auth/session');
|
||||
},
|
||||
logout(csrfToken: string) {
|
||||
return request<void>('/auth/session', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
},
|
||||
periods() {
|
||||
return request<components['schemas']['PeriodList']>('/analytics/periods');
|
||||
},
|
||||
startRun(periodId: string, csrfToken: string) {
|
||||
return request<AnalyticsRun>('/analytics/runs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ periodId }),
|
||||
});
|
||||
},
|
||||
run(runId: string) {
|
||||
return request<AnalyticsRun>(`/analytics/runs/${encodeURIComponent(runId)}`);
|
||||
},
|
||||
retry(runId: string, sectionId: string, csrfToken: string) {
|
||||
return request<AnalyticsRun>(
|
||||
`/analytics/runs/${encodeURIComponent(runId)}/sections/${encodeURIComponent(sectionId)}/retry-attempts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
details(periodId: string, view: DetailView, cursor?: string, failureStage?: FailureStage) {
|
||||
const query = new URLSearchParams({ periodId, view, limit: '25' });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
if (failureStage) query.set('failureStage', failureStage);
|
||||
return request<SessionDetailPage>(`/analytics/session-details?${query}`);
|
||||
},
|
||||
exportUrl(periodId: string, view: DetailView, failureStage?: FailureStage) {
|
||||
const query = new URLSearchParams({ periodId, view });
|
||||
if (failureStage) query.set('failureStage', failureStage);
|
||||
return `${apiBase}/analytics/session-details.csv?${query}`;
|
||||
},
|
||||
};
|
||||
1355
src/lib/api/schema.d.ts
vendored
Normal file
1355
src/lib/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
23
src/lib/format.test.ts
Normal file
23
src/lib/format.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatDate, formatMoney, formatPercent, formatValue } from './format';
|
||||
|
||||
describe('merchant-facing formatting', () => {
|
||||
it('renders Rial values with Persian digits and an explicit unit', () => {
|
||||
expect(formatMoney(1_250_000)).toContain('۱٬۲۵۰٬۰۰۰');
|
||||
expect(formatMoney(1_250_000)).toContain('ریال');
|
||||
});
|
||||
|
||||
it('keeps unavailable decimal values distinct from zero', () => {
|
||||
expect(formatPercent(null)).toBe('ناموجود');
|
||||
expect(formatPercent('0')).toContain('۰');
|
||||
});
|
||||
|
||||
it('formats counts with Persian digits', () => {
|
||||
expect(formatValue(420)).toBe('۴۲۰');
|
||||
});
|
||||
|
||||
it('formats instants in the Jalali calendar and Tehran timezone', () => {
|
||||
expect(formatDate('2026-08-20T09:30:00Z')).toMatch(/۱۴۰۵/);
|
||||
});
|
||||
});
|
||||
27
src/lib/format.ts
Normal file
27
src/lib/format.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const number = new Intl.NumberFormat('fa-IR');
|
||||
const decimal = new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 2 });
|
||||
const date = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'Asia/Tehran',
|
||||
});
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
return number.format(value);
|
||||
}
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return `${number.format(value)} ریال`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: string | null): string {
|
||||
return value === null ? 'ناموجود' : `${decimal.format(Number(value))}٪`;
|
||||
}
|
||||
|
||||
export function formatMilliseconds(value: number | null): string {
|
||||
return value === null ? 'ناموجود' : `${number.format(value)} میلیثانیه`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
return date.format(new Date(value));
|
||||
}
|
||||
Reference in New Issue
Block a user