chore: establish typed SvelteKit frontend foundation

This commit is contained in:
2026-08-20 19:47:25 +03:30
parent e1e4ed8467
commit c719a11ad7
18 changed files with 5705 additions and 1 deletions

163
src/app.css Normal file
View File

@@ -0,0 +1,163 @@
@import 'tailwindcss';
@font-face {
font-family: 'IRANYekanX';
src: url('/fonts/IRANYekanXVFaNumVF.woff2') format('woff2-variations');
font-weight: 100 1000;
font-display: swap;
}
:root {
color-scheme: light;
--canvas: #f1f1f3;
--surface: #ffffff;
--surface-muted: #f7f7f8;
--text: #19191a;
--text-secondary: #3d3d42;
--muted: #6b6b73;
--border: #e3e3e7;
--brand: #0a33ff;
--brand-hover: #0828cc;
--on-brand: #fff;
--focus: #0a33ff;
--success: #248061;
--warning: #8a5300;
--danger: #c93637;
}
:root[data-theme='dark'] {
color-scheme: dark;
--canvas: #0f0f10;
--surface: #19191a;
--surface-muted: #28282a;
--text: #fff;
--text-secondary: #d1d1d6;
--muted: #a7a7af;
--border: #3a3a3d;
--brand: #90aef4;
--brand-hover: #b4c8f7;
--on-brand: #0a0a0a;
--focus: #90aef4;
--success: #6fd6ae;
--warning: #f3be5b;
--danger: #ff8a8b;
}
* {
box-sizing: border-box;
}
html {
background: var(--canvas);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--canvas);
color: var(--text);
font-family: 'IRANYekanX', Tahoma, sans-serif;
font-size: 15px;
line-height: 1.8;
}
button,
input,
select {
font: inherit;
}
button,
a,
input,
select {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
::selection {
background: #ffd400;
color: #19191a;
}
.surface {
background: var(--surface);
border: 1px solid var(--border);
}
.muted {
color: var(--muted);
}
.ltr {
direction: ltr;
unicode-bidi: isolate;
}
.button-primary {
min-height: 44px;
border: 0;
border-radius: 10px;
padding: 0 20px;
background: var(--brand);
color: var(--on-brand);
font-weight: 700;
cursor: pointer;
transition:
background-color 160ms ease,
transform 160ms ease;
}
.button-primary:hover {
background: var(--brand-hover);
}
.button-primary:active {
transform: translateY(1px);
}
.button-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.button-secondary {
min-height: 44px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0 16px;
background: var(--surface);
color: var(--text);
font-weight: 600;
cursor: pointer;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
}
.skeleton {
overflow: hidden;
position: relative;
background: var(--surface-muted);
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--text) 7%, transparent),
transparent
);
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
to {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

5
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare global {
namespace App {}
}
export {};

21
src/app.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f1f1f3" />
<script>
(() => {
try {
const saved = localStorage.getItem('zpa-theme');
const dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (_) {}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

100
src/lib/api/client.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

23
src/lib/format.test.ts Normal file
View 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
View 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));
}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

2
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;