chore: establish typed SvelteKit frontend foundation
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Same-origin by default. Vite proxies /api to http://127.0.0.1:8080 locally.
|
||||||
|
PUBLIC_API_BASE_URL=/api/v1
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,7 +6,8 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
*.log
|
*.log
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
|
||||||
|
"printWidth": 110,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
3905
package-lock.json
generated
Normal file
3905
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "zarinpal-analytics-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"generate:api": "openapi-typescript ../zarinpal-analytics-backend/docs/openapi.yaml -o src/lib/api/schema.d.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.37.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
|
"@testing-library/svelte": "^5.2.8",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"openapi-typescript": "^7.9.1",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"prettier-plugin-svelte": "3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "0.6.14",
|
||||||
|
"svelte": "^5.38.7",
|
||||||
|
"svelte-check": "^4.3.1",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/app.css
Normal file
163
src/app.css
Normal 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
5
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare global {
|
||||||
|
namespace App {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
21
src/app.html
Normal file
21
src/app.html
Normal 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
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));
|
||||||
|
}
|
||||||
7
src/routes/+layout.svelte
Normal file
7
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
2
src/routes/+layout.ts
Normal file
2
src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = true;
|
||||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ fallback: 'index.html' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
setupFiles: ['./vitest-setup.ts'],
|
||||||
|
pool: 'threads',
|
||||||
|
maxWorkers: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
1
vitest-setup.ts
Normal file
1
vitest-setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Reference in New Issue
Block a user