feat: complete AI analytics experience

This commit is contained in:
2026-08-22 15:47:26 +08:00
parent 2bea50a291
commit 43f65a8259
35 changed files with 3497 additions and 574 deletions

View File

@@ -2,3 +2,5 @@
PUBLIC_API_BASE_URL=/api/v1
# Keep disabled until the backend growth-report capability and budgets are configured.
PUBLIC_AI_GROWTH_ENABLED=false
# Independent rollout switch for the main growth-coach experience.
PUBLIC_AI_COACH_ENABLED=false

View File

@@ -131,7 +131,8 @@ The world is recognizably ZarinPal through its wordmark, action blue, small yell
The approved Persian-first baseline remains authoritative where the shipped artifact confirms it: the licensed font and license file stay tracked; light and dark themes remain semantic variants; values use Persian digits and Rial; merchant dates use the Jalali calendar and Asia/Tehran; RTL, bidi isolation, WCAG 2.2 AA, keyboard operation, reduced motion, safe peer comparisons, and traceable metrics remain binding. The artifact now resolves provisional visual guidance into the concrete tokens and component rules recorded here.
| Version | Date | Decision |
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.12.0 | 2026-08-22 | Replaced the Transactions table with a period-scoped operational-health dashboard: four status KPIs, an absolute-count distribution chart, a 100% composition trend, keyboard inspection, and visible semantic data-table alternatives. Status colors are fixed to blue Paid/awaiting verification, emerald Verified/final, rose Failed, and amber Reversed. |
| 0.11.1 | 2026-08-21 | Hardened narrow-screen shell, calendar, and chart behavior: the navbar uses the compact authentication-screen brand mark while the opened mobile drawer carries the full typographic wordmark and the only terminal selector, exact-period fields cannot overlap, payment charts keep their readable width with horizontal scrolling, and the growth teaser gains an action-blue emphasis with a restrained reduced-motion-safe pulse. |
| 0.11.0 | 2026-08-21 | Added the feature-gated AI growth analyst: a cache-first 90-day forecast view with count/amount modes, P10–P90 uncertainty band, keyboard and tabular alternatives, evidence-linked Persian campaign guidance, preserved-content refresh states, and restrained semantic opportunity/risk styling. |
| 0.10.0 | 2026-08-20 | Reframed retry guidance around the share of failed payment paths retried, including an explicit zero state; made payment-flow bars proportional to counts; and removed single-day hover styling from whole-week selection. |
@@ -288,7 +289,7 @@ The signature KPI card pairs a muted label and optional formula help with a sema
### Reporting Scope Filter
Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Clicking a period type first opens the exact-period picker; the URL and backend request change only after a concrete period is chosen. The compact RTL popover provides a four-digit Jalali year input, month navigation plus calendar grid for day/week, whole-week hover and selection, and named month/season choices. Transaction status and a two-thumb RTL amount range sit to the physical right of the calendar control after a divider. Below 768px, the full set moves into a bottom sheet. Backend-offered complete periods are the selectable authority.
Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Clicking a period type first opens the exact-period picker; the URL and backend request change only after a concrete period is chosen. The compact RTL popover provides a four-digit Jalali year input, month navigation plus calendar grid for day/week, whole-week hover and selection, and named month/season choices. The Transactions analytics page intentionally exposes only terminal and period scope; legacy status and amount query parameters do not affect it. Below 768px, period selection moves into a bottom sheet. Backend-offered complete periods are the selectable authority.
### Loading State
@@ -302,9 +303,9 @@ The signature chart is a blue line with outlined points, a low-opacity blue area
Retry engagement is the share of failed payment paths that customers tried again, using final failures plus recovered failed paths as the opportunity denominator. When opportunities exist and none are retried, the merchant sees `۰٪`, not an unavailable state. The adaptive guidance depends only on this percentage. Flow bars use the same total-session scale, so equal counts always render at equal widths.
### Transaction Table
### Transaction Status Analytics
The table stays inside a bordered 8px surface, receives its compact filters from the shared page bar, uses aligned 14px rows and 12px identifiers/status, and exposes semantic detail links with a pointer and neutral row hover. Verified badges are green; Paid remains neutral gray. Narrow viewports may scroll the table horizontally after surrounding filters and summaries have reflowed.
The Transactions page contains no rows or drill-down links. Its primary chart uses four vertical absolute-count bars in the fixed order Paid, Verified, Failed, Reversed. Its secondary chart plots one line with focusable dots per status across the backend-provided time buckets. Paid means «پرداخت‌شده، در انتظار تأیید» and is blue; Verified means «تأیید نهایی» and is emerald; Failed is rose; Reversed is amber. These colors are stable in both themes and always appear with explicit status text. Every mark is keyboard-focusable, exposes count and percentage, honors reduced motion, and is backed by a visible expandable semantic table. Chart cards always show the selected-period badge, unit or denominator explanation, and explicit loading, error, and empty states.
### Fee Share and Comparative Progress

View File

@@ -16,13 +16,14 @@ export type CreateGrowthReportRequest =
operations['createGrowthReport']['requestBody']['content']['application/json'];
export type AIInsightModule = 'overview' | 'payments' | 'fees' | 'peers' | 'transactions' | 'growth';
export type AIInsightSection = components['schemas']['AIInsightSection'];
export type AIInsightNarrative = { headline: string; body: string; evidenceIds: string[] };
export type AIInsight = {
insightId: string;
module: AIInsightModule;
revision: number;
status: 'READY' | 'PARTIAL';
scope: { terminalId?: string | null };
scope: { terminalId?: string | null; periodId?: string | null };
asOfDate: string;
dataThroughDate: string;
forecastStartDate: string;
@@ -57,16 +58,31 @@ export type AIInsight = {
overlapConfounded: boolean;
}>;
refreshState: { state: string; jobId?: string; eventsUrl?: string; nextRefreshAt?: string };
sectionRefreshState: Partial<
Record<AIInsightSection, { state: string; jobId?: string; eventsUrl?: string; nextRefreshAt?: string }>
>;
generatedAt: string;
};
export type AIInsightJob = {
jobId: string;
snapshotId: string;
state: string;
module: AIInsightModule;
sectionId: AIInsightSection;
eventsUrl: string;
createdAt: string;
updatedAt: string;
};
export type GrowthCoachProfileInput = components['schemas']['GrowthCoachProfileInput'];
export type GrowthCoachProfile = components['schemas']['GrowthCoachProfile'];
export type GrowthCoachDashboard = components['schemas']['GrowthCoachDashboard'];
export type GrowthCoachPlan = components['schemas']['GrowthCoachPlan'];
export type GrowthCoachRecommendation = components['schemas']['GrowthCoachRecommendation'];
export type GoldenStep = components['schemas']['GoldenStep'];
export type GrowthCoachJob = components['schemas']['GrowthCoachJob'];
export type MonthlyGoal = components['schemas']['MonthlyGoal'];
export type GoalSubtask = components['schemas']['GoalSubtask'];
export type GoalDeadlineProposal = components['schemas']['GoalDeadlineProposal'];
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
@@ -192,20 +208,125 @@ export const api = {
growthReport(reportId: string) {
return request<GrowthReport>(`/analytics/growth-reports/${encodeURIComponent(reportId)}`);
},
latestAIInsight(module: AIInsightModule, terminalId?: string) {
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
return request<AIInsight>(`/analytics/ai-insights/${module}${query}`);
latestAIInsight(module: AIInsightModule, terminalId?: string, periodId?: string) {
const query = new URLSearchParams();
if (terminalId) query.set('terminalId', terminalId);
if (periodId) query.set('periodId', periodId);
const suffix = query.size ? `?${query}` : '';
return request<AIInsight>(`/analytics/ai-insights/${module}${suffix}`);
},
refreshAIInsight(module: AIInsightModule, csrfToken: string, terminalId?: string) {
refreshAIInsight(module: AIInsightModule, csrfToken: string, terminalId?: string, periodId?: string) {
return request<AIInsightJob>(`/analytics/ai-insights/${module}/refreshes`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(terminalId ? { terminalId } : {}),
body: JSON.stringify({ ...(terminalId ? { terminalId } : {}), ...(periodId ? { periodId } : {}) }),
});
},
refreshAIInsightSection(
module: AIInsightModule,
section: AIInsightSection,
csrfToken: string,
terminalId?: string,
periodId?: string,
) {
return request<AIInsightJob>(
`/analytics/ai-insights/${module}/sections/${encodeURIComponent(section)}/refreshes`,
{
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ ...(terminalId ? { terminalId } : {}), ...(periodId ? { periodId } : {}) }),
},
);
},
aiInsightJob(jobId: string) {
return request<AIInsightJob>(`/analytics/ai-insight-refreshes/${encodeURIComponent(jobId)}`);
},
growthCoach(terminalId?: string) {
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
return request<GrowthCoachDashboard>(`/growth-coach${query}`);
},
monthlyGoals(terminalId?: string) {
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
return request<{ goals: MonthlyGoal[] }>(`/growth-coach/goals${query}`);
},
transitionGoalSubtask(
goalId: string,
subtaskId: string,
transition: 'COMPLETE' | 'REOPEN',
csrfToken: string,
) {
return request<GoalSubtask>(
`/growth-coach/goals/${encodeURIComponent(goalId)}/subtasks/${encodeURIComponent(subtaskId)}/transitions`,
{
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ transition }),
},
);
},
decideGoalDeadlineProposal(proposalId: string, decision: 'ACCEPT' | 'REJECT', csrfToken: string) {
return request<GoalDeadlineProposal>(
`/growth-coach/deadline-proposals/${encodeURIComponent(proposalId)}/decisions`,
{
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ decision }),
},
);
},
saveGrowthCoachProfile(terminalId: string, input: GrowthCoachProfileInput, csrfToken: string) {
return request<components['schemas']['GrowthCoachProfileResult']>(
`/growth-coach/terminals/${encodeURIComponent(terminalId)}/profile`,
{
method: 'PUT',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify(input),
},
);
},
refreshGrowthCoach(terminalId: string, cadence: 'WEEKLY' | 'MONTHLY', csrfToken: string) {
return request<GrowthCoachJob>(`/growth-coach/terminals/${encodeURIComponent(terminalId)}/refreshes`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ cadence }),
});
},
decideGrowthCoachRecommendation(
recommendationId: string,
decision: 'ACCEPTED' | 'REJECTED',
csrfToken: string,
reason = '',
note = '',
) {
return request<{ decision: string; action?: GoldenStep }>(
`/growth-coach/recommendations/${encodeURIComponent(recommendationId)}/decisions`,
{
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ decision, reason, note }),
},
);
},
transitionGoldenStep(actionId: string, transition: 'START' | 'COMPLETE' | 'ABANDON', csrfToken: string) {
return request<GoldenStep>(`/growth-coach/actions/${encodeURIComponent(actionId)}/transitions`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ transition }),
});
},
decideGrowthCoachDifficulty(proposalId: string, decision: 'ACCEPTED' | 'REJECTED', csrfToken: string) {
return request<components['schemas']['GrowthCoachDifficultyProposal']>(
`/growth-coach/difficulty-proposals/${encodeURIComponent(proposalId)}/decisions`,
{
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ decision, reason: '', note: '' }),
},
);
},
growthCoachJob(jobId: string) {
return request<GrowthCoachJob>(`/growth-coach/refreshes/${encodeURIComponent(jobId)}`);
},
exportUrl(
periodId: string,
view: DetailView,

View File

@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from './client';
afterEach(() => vi.unstubAllGlobals());
describe('growth coach API client', () => {
it('loads a terminal-scoped workspace without a merchant identifier', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
scope: 'TERMINAL',
generationState: 'IDLE',
plans: [],
actions: [],
terminals: [],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
await api.growthCoach('terminal-1');
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/growth-coach?terminalId=terminal-1');
});
it('sends CSRF and idempotency headers for recommendation decisions', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ decision: 'REJECTED' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'coach-idempotency' });
await api.decideGrowthCoachRecommendation('rec-1', 'REJECTED', 'csrf', 'خیلی سخت');
const init = fetchMock.mock.calls[0][1];
expect(init.headers['X-CSRF-Token']).toBe('csrf');
expect(init.headers['Idempotency-Key']).toBe('coach-idempotency');
expect(JSON.parse(init.body)).toEqual({ decision: 'REJECTED', reason: 'خیلی سخت', note: '' });
});
it('requests a refresh for only the selected coach cadence', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
jobId: 'coach-job-1',
state: 'QUEUED',
eventsUrl: '/api/v1/growth-coach/refreshes/coach-job-1/events',
createdAt: '2026-08-21T00:00:00Z',
updatedAt: '2026-08-21T00:00:00Z',
}),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'coach-refresh-idempotency' });
await api.refreshGrowthCoach('terminal-1', 'WEEKLY', 'csrf');
const init = fetchMock.mock.calls[0][1];
expect(init.method).toBe('POST');
expect(init.headers['X-CSRF-Token']).toBe('csrf');
expect(init.headers['Idempotency-Key']).toBe('coach-refresh-idempotency');
expect(JSON.parse(init.body)).toEqual({ cadence: 'WEEKLY' });
});
it('loads monthly goals and transitions only the selected subtask', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ goals: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'subtask-idempotency' });
await api.monthlyGoals('terminal-1');
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/growth-coach/goals?terminalId=terminal-1');
await api.transitionGoalSubtask('goal-1', 'subtask-2', 'COMPLETE', 'csrf');
expect(fetchMock.mock.calls[1][0]).toBe(
'/api/v1/growth-coach/goals/goal-1/subtasks/subtask-2/transitions',
);
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ transition: 'COMPLETE' });
expect(fetchMock.mock.calls[1][1].headers['Idempotency-Key']).toBe('subtask-idempotency');
});
it('records an explicit deadline proposal decision', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'ACCEPTED' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'deadline-idempotency' });
await api.decideGoalDeadlineProposal('proposal-1', 'ACCEPT', 'csrf');
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/growth-coach/deadline-proposals/proposal-1/decisions');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ decision: 'ACCEPT' });
});
});

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from './client';
afterEach(() => vi.unstubAllGlobals());
describe('AI insight section refresh client', () => {
it('targets one section and sends mutation protections', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ jobId: 'job-1', state: 'QUEUED' }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'section-idempotency' });
await api.refreshAIInsightSection('transactions', 'future-90-days', 'csrf', 'T83', 'period-7');
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/v1/analytics/ai-insights/transactions/sections/future-90-days/refreshes',
);
expect(fetchMock.mock.calls[0][1].headers['X-CSRF-Token']).toBe('csrf');
expect(fetchMock.mock.calls[0][1].headers['Idempotency-Key']).toBe('section-idempotency');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
terminalId: 'T83',
periodId: 'period-7',
});
});
it('scopes latest transaction insight to terminal and period', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
vi.stubGlobal('fetch', fetchMock);
await api.latestAIInsight('transactions', 'T83', 'period-7');
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/v1/analytics/ai-insights/transactions?terminalId=T83&periodId=period-7',
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,13 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { ApiError, api, type AIInsight, type AIInsightModule } from '$lib/api/client';
import {
ApiError,
api,
type AIInsight,
type AIInsightModule,
type AIInsightSection,
} from '$lib/api/client';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
@@ -12,11 +18,14 @@
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { formatDate } from '$lib/format';
let { module, terminalId }: { module: AIInsightModule; terminalId?: string } = $props();
let { module, terminalId, periodId }: { module: AIInsightModule; terminalId?: string; periodId?: string } =
$props();
const dashboard = useDashboardContext();
let insight = $state<AIInsight | null>(null);
let loading = $state(true);
let refreshing = $state(false);
let refreshingSections = $state<Partial<Record<AIInsightSection, boolean>>>({});
let sectionErrors = $state<Partial<Record<AIInsightSection, string>>>({});
let error = $state('');
let sequence = 0;
@@ -31,12 +40,15 @@
)[insight?.confidence ?? 'INSUFFICIENT_DATA'],
);
async function load() {
async function load(
requestedModule: AIInsightModule,
requestedTerminalId?: string,
requestedPeriodId?: string,
) {
const current = ++sequence;
loading = !insight;
error = '';
try {
const next = await api.latestAIInsight(module, terminalId);
const next = await api.latestAIInsight(requestedModule, requestedTerminalId, requestedPeriodId);
if (current === sequence) insight = next;
} catch (cause) {
if (current !== sequence) return;
@@ -54,12 +66,12 @@
refreshing = true;
error = '';
try {
const job = await api.refreshAIInsight(module, dashboard.session.csrfToken, terminalId);
const job = await api.refreshAIInsight(module, dashboard.session.csrfToken, terminalId, periodId);
for (let attempt = 0; attempt < 45; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const current = await api.aiInsightJob(job.jobId);
if (current.state === 'COMPLETED') {
await load();
await load(module, terminalId, periodId);
return;
}
if (current.state === 'FAILED') throw new Error('job failed');
@@ -75,11 +87,50 @@
}
}
async function refreshOne(section: AIInsightSection) {
if (!dashboard.session || refreshingSections[section]) return;
refreshingSections[section] = true;
sectionErrors[section] = '';
try {
const job = await api.refreshAIInsightSection(
module,
section,
dashboard.session.csrfToken,
terminalId,
periodId,
);
for (let attempt = 0; attempt < 45; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const current = await api.aiInsightJob(job.jobId);
if (current.state === 'COMPLETED') {
await load(module, terminalId, periodId);
return;
}
if (current.state === 'FAILED') throw new Error('job failed');
}
sectionErrors[section] = 'تازه‌سازی ادامه دارد؛ متن معتبر قبلی حفظ شده است.';
} catch (cause) {
sectionErrors[section] =
cause instanceof ApiError && cause.status === 429
? 'سهم این بخش فعلاً تمام شده است؛ متن قبلی حفظ شد.'
: 'این بخش تازه نشد؛ متن قبلی بدون تغییر باقی ماند.';
} finally {
refreshingSections[section] = false;
}
}
function sectionBusy(section: AIInsightSection) {
const state = insight?.sectionRefreshState?.[section]?.state;
return Boolean(refreshingSections[section]) || ['QUEUED', 'REFRESHING', 'RUNNING'].includes(state ?? '');
}
$effect(() => {
module;
terminalId;
const requestedModule = module;
const requestedTerminalId = terminalId;
const requestedPeriodId = periodId;
insight = null;
void load();
loading = true;
void load(requestedModule, requestedTerminalId, requestedPeriodId);
});
function eventTone(tone: string) {
@@ -137,16 +188,44 @@
</CardHeader>
<CardContent class="space-y-5 p-5">
<div class="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
{#each [insight.copy.past, insight.copy.latestObserved, insight.copy.future7Days, insight.copy.future30Days] as section}
{#each [{ id: 'past' as const, copy: insight.copy.past }, { id: 'latest-observed' as const, copy: insight.copy.latestObserved }, { id: 'future-7-days' as const, copy: insight.copy.future7Days }, { id: 'future-30-days' as const, copy: insight.copy.future30Days }] as section}
<article class="bg-muted/35 rounded-md border p-4">
<h3 class="text-sm font-bold">{section.headline}</h3>
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.body}</p>
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-bold">{section.copy.headline}</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی همین بخش"
title="تازه‌سازی همین بخش"
onclick={() => refreshOne(section.id)}
disabled={sectionBusy(section.id)}
><RefreshCcwIcon
class={`size-4 ${sectionBusy(section.id) ? 'animate-spin' : ''}`}
/></Button
>
</div>
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.copy.body}</p>
{#if sectionErrors[section.id]}<p class="text-destructive mt-2 text-xs" role="status">
{sectionErrors[section.id]}
</p>{/if}
</article>
{/each}
</div>
{#if insight.copy.future90Days}
<article class="border-primary/15 bg-primary/[.025] rounded-md border p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-bold">{insight.copy.future90Days.headline}</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی چشم‌انداز ۹۰ روزه"
onclick={() => refreshOne('future-90-days')}
disabled={sectionBusy('future-90-days')}
><RefreshCcwIcon
class={`size-4 ${sectionBusy('future-90-days') ? 'animate-spin' : ''}`}
/></Button
>
</div>
<p class="text-muted-foreground mt-2 text-sm leading-7">{insight.copy.future90Days.body}</p>
</article>
{/if}
@@ -170,16 +249,45 @@
/></summary
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
{#each insight.copy.findings as finding}<div>
<section>
<div class="mb-3 flex items-center justify-between">
<h3 class="font-bold">یافته‌ها</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی یافته‌ها"
onclick={() => refreshOne('findings')}
disabled={sectionBusy('findings')}
><RefreshCcwIcon
class={`size-4 ${sectionBusy('findings') ? 'animate-spin' : ''}`}
/></Button
>
</div>
{#each insight.copy.findings as finding}<div class="mb-3">
<p class="text-sm font-bold">{finding.title}</p>
<p class="text-muted-foreground mt-1 text-sm leading-6">{finding.summary}</p>
<code class="text-muted-foreground mt-1 block text-[11px]">{finding.evidenceId}</code>
</div>{/each}{#each insight.copy.options as option}<div
</div>{/each}
</section>
<section>
<div class="mb-3 flex items-center justify-between">
<h3 class="font-bold">گزینه‌ها</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی گزینه‌ها"
onclick={() => refreshOne('options')}
disabled={sectionBusy('options')}
><RefreshCcwIcon class={`size-4 ${sectionBusy('options') ? 'animate-spin' : ''}`} /></Button
>
</div>
{#each insight.copy.options as option}<div
class="border-primary/15 bg-primary/[.025] rounded-md border p-3"
>
<p class="text-sm font-bold">{option.title}</p>
<p class="text-muted-foreground mt-1 text-sm leading-6">{option.description}</p>
</div>{/each}
</section>
</div>
</details>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -0,0 +1,87 @@
import { render, screen, waitFor } from '@testing-library/svelte';
import { tick } from 'svelte';
import { describe, expect, it, vi } from 'vitest';
import type { AIInsight } from '$lib/api/client';
import AIInsightPanel from './AIInsightPanel.svelte';
const { latestAIInsight } = vi.hoisted(() => ({
latestAIInsight: vi.fn(),
}));
vi.mock('$lib/api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('$lib/api/client')>();
return { ...actual, api: { ...actual.api, latestAIInsight } };
});
vi.mock('$lib/dashboard-context.svelte', () => ({
useDashboardContext: () => ({ session: null }),
}));
const insight: AIInsight = {
insightId: 'insight_test',
module: 'overview',
revision: 1,
status: 'READY',
scope: {},
asOfDate: '2026-08-22',
dataThroughDate: '2026-08-21',
forecastStartDate: '2026-08-22',
freshness: 'FRESH',
dataLagDays: 1,
confidence: 'HIGH',
copy: {
past: { headline: 'روند گذشته', body: 'فروش در این بازه رشد کرده است.', evidenceIds: ['paid'] },
latestObserved: {
headline: 'وضعیت فعلی',
body: 'آخرین روز ثبت‌شده باثبات بوده است.',
evidenceIds: ['paid'],
},
future7Days: {
headline: 'هفت روز آینده',
body: 'روند کوتاه‌مدت باثبات پیش‌بینی می‌شود.',
evidenceIds: ['paid'],
},
future30Days: {
headline: 'سی روز آینده',
body: 'برای ماه آینده تغییر بزرگی دیده نمی‌شود.',
evidenceIds: ['paid'],
},
findings: [],
options: [],
},
evidence: {},
calendarContext: [],
refreshState: { state: 'IDLE' },
sectionRefreshState: {},
generatedAt: '2026-08-22T00:00:00Z',
};
describe('AIInsightPanel', () => {
it('leaves the skeleton after loading an insight and does not fetch it again', async () => {
latestAIInsight.mockReset();
latestAIInsight.mockResolvedValueOnce(insight).mockImplementation(() => new Promise(() => {}));
render(AIInsightPanel, { module: 'overview' });
await screen.findByText('فروش در این بازه رشد کرده است.');
await tick();
await waitFor(() => expect(screen.queryByLabelText('در حال دریافت تحلیل هوشمند')).toBeNull());
expect(latestAIInsight).toHaveBeenCalledTimes(1);
});
it('loads a transaction insight with the resolved period scope', async () => {
latestAIInsight.mockReset();
latestAIInsight.mockResolvedValueOnce({
...insight,
module: 'transactions',
scope: { terminalId: 'T83', periodId: 'period-7' },
});
render(AIInsightPanel, { module: 'transactions', terminalId: 'T83', periodId: 'period-7' });
await screen.findByText('فروش در این بازه رشد کرده است.');
expect(latestAIInsight).toHaveBeenCalledWith('transactions', 'T83', 'period-7');
});
});

View File

@@ -14,12 +14,12 @@ describe('AmountRangeFilter', () => {
expect(thumbs).toHaveLength(2);
await fireEvent.input(thumbs[0], { target: { value: '100000' } });
await fireEvent.input(thumbs[1], { target: { value: '9999900000' } });
await fireEvent.input(thumbs[1], { target: { value: '900000000' } });
expect(screen.getByText('از ۱۰۰٬۰۰۰ ریال')).toBeTruthy();
expect(screen.getByText('تا ۹٬۹۹۹٬۹۰۰٬۰۰۰ ریال')).toBeTruthy();
expect(screen.getByText('تا ۹۰۰٬۰۰۰٬۰۰۰ ریال')).toBeTruthy();
await fireEvent.click(screen.getByRole('button', { name: 'اعمال بازه' }));
expect(onchange).toHaveBeenCalledWith(100_000, 9_999_900_000);
expect(onchange).toHaveBeenCalledWith(100_000, 900_000_000);
});
});

View File

@@ -31,7 +31,7 @@
{ href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon },
{ href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon },
{ href: '/peers', label: 'هم‌گروه‌ها', icon: UsersRoundIcon },
...(growthFeatureEnabled ? [{ href: '/growth', label: 'تحلیل‌گر رشد', icon: TrendingUpIcon }] : []),
...(growthFeatureEnabled ? [{ href: '/growth', label: 'مربی رشد', icon: TrendingUpIcon }] : []),
];
function navUrl(path: string) {
@@ -106,7 +106,7 @@
<Sidebar.MenuButton
isActive={isActive(item.href)}
tooltipContent={item.label}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px] group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-[21px]! group-data-[collapsible=icon]:[&>span]:hidden"
class={`data-[active=true]:text-primary h-11 justify-start px-3 text-[13px] group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-[21px]! ${item.href === '/growth' ? 'border-primary/20 bg-primary/5 hover:border-primary/35 border' : ''}`}
>
{#snippet child({ props })}
<a
@@ -114,8 +114,25 @@
href={navUrl(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
{#if item.href === '/growth'}
<span
class="bg-primary/10 relative grid size-7 shrink-0 place-items-center rounded-md"
>
<item.icon class="text-primary size-[18px]" strokeWidth={2} />
<span class="bg-primary absolute -top-0.5 -left-0.5 size-1.5 rounded-full"></span>
</span>
{:else}
<item.icon class="size-[19px]" strokeWidth={1.8} />
<span class="group-data-[collapsible=icon]:hidden">{item.label}</span>
{/if}
<span class="group-data-[collapsible=icon]:hidden">
{item.label}
{#if item.href === '/growth'}
<span
class="border-primary/20 text-primary mr-1.5 rounded-sm border px-1 py-0.5 text-[9px] font-black"
>AI</span
>
{/if}
</span>
</a>
{/snippet}
</Sidebar.MenuButton>

View File

@@ -2,11 +2,9 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import * as Sheet from '$lib/components/ui/sheet';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import {
@@ -15,13 +13,11 @@
setDashboardQuery,
type DashboardRange,
} from '$lib/dashboard-filters';
import AmountRangeFilter from './AmountRangeFilter.svelte';
import PeriodPicker from './PeriodPicker.svelte';
const dashboard = useDashboardContext();
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
const isTransactionsPage = $derived(page.url.pathname === '/transactions');
const currentPeriod = $derived(dashboard.periodFor(filters.range, filters.period));
let pickerRange = $state<DashboardRange>('month');
let desktopPickerOpen = $state(false);
@@ -95,76 +91,7 @@
</div>
{/snippet}
{#snippet transactionFilters()}
<div class="flex flex-wrap items-center gap-2">
<Select.Root
type="single"
value={filters.status}
onValueChange={(value) => update({ status: value })}
items={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'Verified', label: 'تأییدشده' },
{ value: 'Paid', label: 'پرداخت‌شده' },
{ value: 'Failed', label: 'ناموفق' },
{ value: 'Reversed', label: 'برگشت‌خورده' },
]}
>
<Select.Trigger
id="transaction-status-filter"
aria-label="وضعیت تراکنش"
class="min-h-11 cursor-pointer rounded-md px-3 text-xs font-medium md:min-h-9"
>
<span data-slot="select-value">
{filters.status === 'Verified'
? 'تأییدشده'
: filters.status === 'Paid'
? 'پرداخت‌شده'
: filters.status === 'Failed'
? 'ناموفق'
: filters.status === 'Reversed'
? 'برگشت‌خورده'
: 'همه وضعیت‌ها'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-40 rounded-lg p-1 text-right">
<Select.Group>
<Select.Item value="all" label="همه وضعیت‌ها" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Verified" label="تأییدشده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Paid" label="پرداخت‌شده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Failed" label="ناموفق" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Reversed" label="برگشت‌خورده" class="min-h-11 cursor-pointer text-right" />
</Select.Group>
</Select.Content>
</Select.Root>
<AmountRangeFilter
minAmount={filters.minAmount}
maxAmount={filters.maxAmount}
onchange={(minAmount, maxAmount) =>
update({
minAmount: minAmount === null ? null : String(minAmount),
maxAmount: maxAmount === null ? null : String(maxAmount),
})}
/>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
variant="ghost"
size="sm"
class="min-h-11 cursor-pointer rounded-md px-2.5 text-xs md:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{/snippet}
<div class="bg-card hidden min-h-14 items-center justify-end gap-3 rounded-lg border p-2.5 shadow-xs md:flex">
{#if isTransactionsPage}
{@render transactionFilters()}
<span class="border-border h-8 border-s" aria-hidden="true"></span>
{/if}
{@render periodControls(false)}
</div>
@@ -188,17 +115,9 @@
>
<Sheet.Header class="text-right">
<Sheet.Title>فیلتر گزارش</Sheet.Title>
<Sheet.Description>بازه دقیق و فیلترهای این صفحه را انتخاب کنید.</Sheet.Description>
<Sheet.Description>بازه دقیق گزارش را انتخاب کنید.</Sheet.Description>
</Sheet.Header>
{#if isTransactionsPage}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">فیلتر تراکنش‌ها</h2>
{@render transactionFilters()}
</section>
<div class="border-border border-t"></div>
{/if}
<section class="grid gap-3">
<h2 class="text-sm font-semibold">بازه زمانی</h2>
{@render periodControls(true)}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Dialog } from 'bits-ui';
import type { GoldenStep, GrowthCoachRecommendation } from '$lib/api/client';
import { Button } from '$lib/components/ui/button';
import { formatDateOnly } from '$lib/format';
import GrowthAIText from './GrowthAIText.svelte';
let {
open = $bindable(false),
action,
recommendation,
terminalId,
}: {
open?: boolean;
action: GoldenStep | null;
recommendation: GrowthCoachRecommendation | null;
terminalId: string;
} = $props();
function metricValue(value: number | null | undefined) {
return value == null ? '—' : new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 1 }).format(value);
}
function effortLabel(effort: GrowthCoachRecommendation['effort'] | undefined) {
if (effort === 'LOW') return 'کم';
if (effort === 'HIGH') return 'زیاد';
return 'متوسط';
}
function statusLabel(status: GoldenStep['status'] | undefined) {
if (status === 'NOT_STARTED') return 'شروع نشده';
if (status === 'IN_PROGRESS') return 'در حال انجام';
if (status === 'COMPLETED') return 'تکمیل شده';
if (status === 'MISSED') return 'مهلت گذشته';
return 'کنار گذاشته شده';
}
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="bg-background/75 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 max-h-[calc(100vh-2rem)] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-xl border p-5 shadow-xl sm:p-6"
>
<div class="flex items-start justify-between gap-4 border-b pb-4">
<div>
<Dialog.Title class="text-lg font-black">جزئیات پیشنهاد پذیرفته‌شده</Dialog.Title>
<Dialog.Description class="text-muted-foreground mt-1 text-sm">
متن کامل پیشنهاد و وضعیت فعلی قدم زرین
</Dialog.Description>
</div>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="ghost" size="icon" aria-label="بستن جزئیات" {...props}><XIcon /></Button>
{/snippet}
</Dialog.Close>
</div>
{#if action}
<div class="mt-5 space-y-5">
<div>
<div class="mb-2 flex flex-wrap gap-2 text-xs">
<span class="bg-primary/10 text-primary rounded-full px-2.5 py-1 font-bold">
{action.actionType === 'METRIC' ? 'سنجش خودکار' : 'انجام دستی'}
</span>
<span class="bg-muted rounded-full px-2.5 py-1">وضعیت: {statusLabel(action.status)}</span>
{#if recommendation}<span class="bg-muted rounded-full px-2.5 py-1">
تلاش {effortLabel(recommendation.effort)}
</span>{/if}
</div>
<h3 class="text-base leading-7 font-black"><GrowthAIText text={action.title} {terminalId} /></h3>
<p class="text-muted-foreground mt-1 text-xs">مهلت: {formatDateOnly(action.deadline)}</p>
</div>
{#if recommendation}
<div class="space-y-2">
<h4 class="text-sm font-black">چرا این پیشنهاد ارائه شد؟</h4>
<p class="text-muted-foreground text-sm leading-7">
<GrowthAIText text={recommendation.rationale} {terminalId} />
</p>
</div>
<div class="bg-muted/60 rounded-lg p-4 text-sm leading-7">
<strong>اثر مورد انتظار:</strong>
<GrowthAIText text={recommendation.expectedImpact} {terminalId} />
</div>
{/if}
{#if action.actionType === 'METRIC'}
<div class="grid grid-cols-2 gap-2 text-center text-xs sm:grid-cols-3">
<div class="rounded-lg border p-3">
خط پایه<strong class="mt-1 block text-base">{metricValue(action.baseline)}</strong>
</div>
<div class="rounded-lg border p-3">
مقدار فعلی<strong class="mt-1 block text-base">{metricValue(action.currentValue)}</strong>
</div>
<div class="col-span-2 rounded-lg border p-3 sm:col-span-1">
هدف قطعی<strong class="mt-1 block text-base">{metricValue(action.target)}</strong>
</div>
</div>
{/if}
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import type { GoldenStep, GrowthCoachRecommendation } from '$lib/api/client';
import GoldenStepDetailsDialog from './GoldenStepDetailsDialog.svelte';
const action: GoldenStep = {
actionId: 'step-1',
recommendationId: 'rec-1',
terminalId: 'T83',
actionType: 'METRIC',
title: 'بهبود پرداخت',
status: 'IN_PROGRESS',
metricId: 'success_rate',
metricDirection: 'INCREASE',
baseline: 77.6,
target: 82.6,
currentValue: 79,
sampleSize: 120,
deadline: '2026-08-21',
updatedAt: '2026-08-20T10:00:00Z',
};
const recommendation: GrowthCoachRecommendation = {
recommendationId: 'rec-1',
planId: 'plan-1',
rank: 1,
status: 'ACCEPTED',
actionType: 'METRIC',
title: 'بهبود پرداخت',
rationale: 'شواهد payment-health افت را نشان می‌دهد.',
expectedImpact: 'خریدهای موفق بیشتر می‌شود.',
effort: 'MEDIUM',
evidenceIds: ['payment-health'],
metricId: 'success_rate',
metricDirection: 'INCREASE',
actionLevel: 'SMALL',
baseline: 77.6,
target: 82.6,
sampleSize: 120,
deadline: '2026-08-21',
subtasks: [],
eventIds: [],
};
describe('GoldenStepDetailsDialog', () => {
it('shows the accepted suggestion details with localized dashboard links', () => {
render(GoldenStepDetailsDialog, { open: true, action, recommendation, terminalId: 'T83' });
expect(screen.getByRole('dialog')).not.toBeNull();
expect(screen.getByRole('heading', { name: 'جزئیات پیشنهاد پذیرفته‌شده' })).not.toBeNull();
expect(screen.getByText('خریدهای موفق بیشتر می‌شود.')).not.toBeNull();
const evidenceLink = screen.getByRole('link', { name: 'نرخ موفقیت پرداخت' });
expect(evidenceLink.getAttribute('href')).toBe('/payments?terminal=T83');
expect(screen.queryByText(/payment-health/)).toBeNull();
});
});

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { localizeGrowthAIText } from '$lib/growth-ai-text';
let { text, terminalId }: { text: string; terminalId: string } = $props();
const parts = $derived(localizeGrowthAIText(text, terminalId));
</script>
{#each parts as part}{#if part.href}<a
href={part.href}
class="text-primary decoration-primary/35 hover:decoration-primary focus-visible:ring-ring rounded-sm font-bold underline underline-offset-4 outline-none focus-visible:ring-2"
>{part.text}</a
>{:else}{part.text}{/if}{/each}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { components } from '$lib/api/schema';
import { formatDateOnly } from '$lib/format';
type UpcomingEvent = components['schemas']['GrowthUpcomingEvent'];
type EventRecommendation = components['schemas']['GrowthEventRecommendation'];
let {
events = [],
recommendations = [],
now = new Date(),
}: {
events?: UpcomingEvent[];
recommendations?: EventRecommendation[];
now?: Date;
} = $props();
const visibleEvents = $derived.by(() => {
const byEvidenceId = new Map(events.map((event) => [event.evidenceId, event]));
const ranked = recommendations
.map((recommendation) => ({
...byEvidenceId.get(recommendation.evidenceId),
relevanceReason: recommendation.relevanceReason,
}))
.filter(
(event): event is UpcomingEvent & { relevanceReason: string } =>
Boolean(event.evidenceId) && new Date(event.date) >= now,
);
if (ranked.length) return ranked.slice(0, 3);
return events
.filter((event) => new Date(event.date) >= now)
.toSorted((a, b) => Date.parse(a.date) - Date.parse(b.date))
.slice(0, 3)
.map((event) => ({
...event,
relevanceReason: 'این مناسبت در تقویم بررسی‌شده به‌عنوان رویدادی مرتبط با کسب‌وکار ثبت شده است.',
}));
});
</script>
{#if visibleEvents.length}
<div class="grid gap-3 sm:grid-cols-3" role="list" aria-label="رویدادهای پیش رو">
{#each visibleEvents as event (event.evidenceId)}
<article class="bg-muted/35 rounded-lg border p-4 text-right" role="listitem">
<p class="text-muted-foreground text-xs">رویداد پیش رو</p>
<h3 class="mt-2 text-sm font-bold">{event.eventTitleFa}</h3>
<p class="text-muted-foreground mt-2 text-xs">{formatDateOnly(event.date)}</p>
<p class="text-muted-foreground mt-3 text-xs leading-5">{event.relevanceReason}</p>
</article>
{/each}
</div>
{:else}
<div class="py-10 text-center" role="status">
<p class="font-bold">رویداد مرتبطی در تقویم بررسی‌شده پیدا نشد</p>
<p class="text-muted-foreground mt-1 text-sm">با به‌روزرسانی تقویم، این بخش دوباره بررسی می‌شود.</p>
</div>
{/if}

View File

@@ -0,0 +1,97 @@
import { cleanup, render, screen } from '@testing-library/svelte';
import { afterEach, describe, expect, it } from 'vitest';
import GrowthEventImpactChart from './GrowthEventImpactChart.svelte';
const events = [
{
evidenceId: 'event-1',
eventId: 'event-1',
eventTitleFa: 'نوروز',
date: '2027-03-21T00:00:00Z',
eventType: 'SEASONAL',
officialHoliday: false,
merchantCategories: [],
behavioralChannels: [],
},
{
evidenceId: 'event-2',
eventId: 'event-2',
eventTitleFa: 'شب یلدا',
date: '2026-12-21T00:00:00Z',
eventType: 'CULTURAL',
officialHoliday: false,
merchantCategories: [],
behavioralChannels: [],
},
{
evidenceId: 'event-3',
eventId: 'event-3',
eventTitleFa: 'بلک‌فرایدی',
date: '2026-11-27T00:00:00Z',
eventType: 'COMMERCIAL',
officialHoliday: false,
merchantCategories: [],
behavioralChannels: [],
},
{
evidenceId: 'event-4',
eventId: 'event-4',
eventTitleFa: 'سایبرماندی',
date: '2026-11-30T00:00:00Z',
eventType: 'COMMERCIAL',
officialHoliday: false,
merchantCategories: [],
behavioralChannels: [],
},
];
afterEach(cleanup);
describe('GrowthEventImpactChart', () => {
it('uses AI rank order and shows each grounded relevance reason', () => {
render(GrowthEventImpactChart, {
props: {
events,
recommendations: [
{ evidenceId: 'event-1', relevanceReason: 'زمان مناسبی برای آماده‌سازی پیشنهادهای فروش است.' },
{ evidenceId: 'event-3', relevanceReason: 'این رویداد با فروش آنلاین کسب‌وکار مرتبط است.' },
{ evidenceId: 'event-2', relevanceReason: 'این مناسبت برای برنامه‌ریزی پیام‌رسانی مناسب است.' },
],
now: new Date('2026-08-21T00:00:00Z'),
},
});
const cards = screen.getAllByRole('listitem');
expect(cards).toHaveLength(3);
expect(cards[0]).toHaveTextContent('نوروز');
expect(cards[0]).toHaveTextContent('زمان مناسبی برای آماده‌سازی پیشنهادهای فروش است.');
expect(cards[1]).toHaveTextContent('بلک‌فرایدی');
});
it('falls back to the first three upcoming reviewed events when AI is unavailable', () => {
render(GrowthEventImpactChart, { props: { events, now: new Date('2026-08-21T00:00:00Z') } });
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(screen.getByText('بلک‌فرایدی')).not.toBeNull();
expect(
screen.getAllByText('این مناسبت در تقویم بررسی‌شده به‌عنوان رویدادی مرتبط با کسب‌وکار ثبت شده است.'),
).toHaveLength(3);
expect(screen.queryByText('نوروز')).toBeNull();
});
it('does not render events that are already in the past', () => {
render(GrowthEventImpactChart, {
props: {
events: [
{ ...events[0], evidenceId: 'past', eventTitleFa: 'رویداد گذشته', date: '2026-08-20T00:00:00Z' },
events[2],
],
now: new Date('2026-08-21T00:00:00Z'),
},
});
expect(screen.queryByText('رویداد گذشته')).toBeNull();
expect(screen.getByText('بلک‌فرایدی')).not.toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
<SparklesIcon class="size-5" />
</span>
<div>
<h2 class="text-primary font-bold">تحلیل‌گر رشد</h2>
<h2 class="text-primary font-bold">مربی رشد</h2>
<p class="text-muted-foreground mt-1 text-sm leading-7">
فرصت‌های پیش رو و دوره‌های کم‌رونق احتمالی را با تکیه بر داده‌های خودتان ببینید.
</p>

View File

@@ -10,6 +10,6 @@ describe('GrowthTeaser', () => {
const link = screen.getByRole('link', { name: /دیدن تحلیل ۹۰ روزه/ });
expect(link.getAttribute('href')).toBe('/growth?terminal=terminal-1');
expect(link.className).toContain('bg-primary');
expect(screen.getByText('تحلیل‌گر رشد')).not.toBeNull();
expect(screen.getByText('مربی رشد')).not.toBeNull();
});
});

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
let {
value = $bindable([]),
suggestions = [],
label,
placeholder = 'بنویسید و Enter بزنید',
}: {
value?: string[];
suggestions?: string[];
label: string;
placeholder?: string;
} = $props();
let draft = $state('');
function add(raw: string) {
const tag = raw.trim();
if (!tag || value.includes(tag) || value.length >= 10) return;
value = [...value, tag];
draft = '';
}
function remove(tag: string) {
value = value.filter((item) => item !== tag);
}
function handleKeydown(event: KeyboardEvent) {
if (['Enter', ',', '،', ' ', 'Tab'].includes(event.key) && draft.trim()) {
event.preventDefault();
add(draft);
} else if (event.key === 'Backspace' && !draft && value.length) {
value = value.slice(0, -1);
}
}
</script>
<div class="space-y-3">
<div
class="border-input bg-background focus-within:ring-ring flex min-h-12 flex-wrap items-center gap-2 rounded-lg border p-2 focus-within:ring-2"
>
{#each value as tag (tag)}
<span
class="bg-primary/10 text-primary inline-flex min-h-8 items-center gap-1 rounded-md px-2.5 text-sm font-bold"
>
{tag}
<button
type="button"
class="hover:bg-primary/10 rounded p-0.5"
aria-label={`حذف ${tag}`}
onclick={() => remove(tag)}
>
<XIcon class="size-3.5" />
</button>
</span>
{/each}
<input
bind:value={draft}
aria-label={label}
class="min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none"
{placeholder}
onkeydown={handleKeydown}
/>
</div>
<p class="text-muted-foreground text-xs">با فاصله، Enter یا ویرگول جدا کنید.</p>
<div class="flex flex-wrap items-center gap-2" aria-label="پیشنهادهای پرکاربرد">
<span class="text-muted-foreground text-xs font-semibold">پرکاربرد:</span>
{#each suggestions as suggestion}
<button
type="button"
class="border-input hover:border-primary/40 hover:bg-primary/5 inline-flex min-h-8 items-center gap-1 rounded-full border px-2.5 text-xs transition-colors"
aria-label={`افزودن ${suggestion}`}
disabled={value.includes(suggestion)}
onclick={() => add(suggestion)}
>
<PlusIcon class="size-3.5" />
{suggestion}
</button>
{/each}
</div>
</div>

View File

@@ -0,0 +1,30 @@
import { fireEvent, render } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import TagInput from './TagInput.svelte';
describe('TagInput', () => {
it.each(['Enter', ',', '،', ' '])('commits a tag with %s', async (separator) => {
const { container } = render(TagInput, {
value: [],
suggestions: [],
label: 'حوزه‌های فعالیت',
});
const input = container.querySelector('input') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'فروشگاه' } });
await fireEvent.keyDown(input, { key: separator });
expect(container.textContent).toContain('فروشگاه');
});
it('adds a commonly used suggestion and avoids duplicates', async () => {
const { container } = render(TagInput, {
value: [],
suggestions: ['فروش آنلاین'],
label: 'حوزه‌های فعالیت',
});
const suggestion = container.querySelector('[aria-label="افزودن فروش آنلاین"]') as HTMLButtonElement;
await fireEvent.click(suggestion);
await fireEvent.click(suggestion);
expect(container.querySelectorAll('span')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import type { components } from '$lib/api/schema';
import { formatPercent, formatValue } from '$lib/format';
import { statusLabels, statusOrder, type TransactionStatus } from '$lib/transaction-analytics';
type Distribution = components['schemas']['TransactionStatusDistribution'];
let { data }: { data: Distribution[] } = $props();
let active = $state<TransactionStatus | null>(null);
const ordered = $derived(
statusOrder.map(
(status) =>
data.find((entry) => entry.status === status) ?? {
status,
count: 0,
sharePercent: data.length ? '0' : null,
},
),
);
const maxCount = $derived(Math.max(1, ...ordered.map((entry) => entry.count)));
const statusClasses: Record<TransactionStatus, string> = {
Paid: 'bg-blue-600 dark:bg-blue-500',
Verified: 'bg-emerald-600 dark:bg-emerald-500',
Failed: 'bg-rose-600 dark:bg-rose-500',
Reversed: 'bg-amber-600 dark:bg-amber-500',
};
function barHeight(count: number) {
return count === 0 ? 2 : Math.max(8, (count / maxCount) * 100);
}
</script>
<div class="space-y-5">
<!-- svelte-ignore a11y_no_noninteractive_tabindex (the horizontally scrollable chart must be keyboard reachable) -->
<div
class="overflow-x-auto overscroll-x-contain pb-1"
role="region"
aria-label="نمودار ستونی تعداد تراکنش‌ها بر اساس وضعیت"
tabindex="0"
>
<div class="grid min-h-72 min-w-[640px] grid-cols-4 gap-4 border-b px-3 pt-10 sm:gap-7">
{#each ordered as entry}
<div class="grid min-w-0 grid-rows-[1fr_auto] gap-3 text-center">
<div class="relative flex items-end justify-center">
{#if active === entry.status}
<div
class="absolute bottom-[calc(var(--bar-height)+.65rem)] left-1/2 z-10 w-44 -translate-x-1/2 rounded-md bg-zinc-950 p-2.5 text-xs leading-5 text-white shadow-xl"
style={`--bar-height:${barHeight(entry.count)}%`}
role="tooltip"
>
<strong class="block">{statusLabels[entry.status]}</strong>
<span>{formatValue(entry.count)} تراکنش · {formatPercent(entry.sharePercent)}</span>
</div>
{/if}
<button
type="button"
class={`focus-visible:ring-ring w-full max-w-24 cursor-default rounded-t-md transition-[height,filter] duration-500 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-offset-2 motion-reduce:transition-none ${statusClasses[entry.status]}`}
style={`height:${barHeight(entry.count)}%`}
aria-label={`${statusLabels[entry.status]}، ${formatValue(entry.count)} تراکنش، ${formatPercent(entry.sharePercent)} از کل`}
onmouseenter={() => (active = entry.status)}
onmouseleave={() => (active = null)}
onfocus={() => (active = entry.status)}
onblur={() => (active = null)}
></button>
</div>
<div class="min-h-16">
<strong class="block tabular-nums">{formatValue(entry.count)}</strong>
<span class="text-muted-foreground mt-1 block text-[11px] leading-5">
{statusLabels[entry.status]}
</span>
</div>
</div>
{/each}
</div>
</div>
<details class="border-border rounded-md border">
<summary
class="focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2"
>
جدول داده‌های نمودار وضعیت
</summary>
<div class="overflow-x-auto border-t">
<table class="w-full min-w-96 text-sm">
<caption class="sr-only">تعداد و سهم تراکنش‌ها به تفکیک وضعیت</caption>
<thead class="bg-muted/50">
<tr
><th class="p-3 text-right">وضعیت</th><th class="p-3 text-right">تعداد</th><th
class="p-3 text-right">سهم از کل</th
></tr
>
</thead>
<tbody>
{#each ordered as entry}
<tr class="border-t">
<th class="p-3 text-right font-medium">{statusLabels[entry.status]}</th>
<td class="p-3 tabular-nums">{formatValue(entry.count)}</td>
<td class="p-3 tabular-nums">{formatPercent(entry.sharePercent)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
</div>

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import TransactionStatusChart from './TransactionStatusChart.svelte';
describe('TransactionStatusChart', () => {
it('renders canonical labels, exact accessible values, and a data-table alternative', () => {
render(TransactionStatusChart, {
data: [
{ status: 'Verified', count: 60, sharePercent: '60' },
{ status: 'Failed', count: 20, sharePercent: '20' },
{ status: 'Paid', count: 10, sharePercent: '10' },
{ status: 'Reversed', count: 10, sharePercent: '10' },
],
});
expect(screen.getByRole('button', { name: /پرداخت‌شده، در انتظار تأیید، ۱۰ تراکنش، ۱۰٪/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /تأیید نهایی، ۶۰ تراکنش، ۶۰٪/ })).toBeTruthy();
expect(screen.getByText('جدول داده‌های نمودار وضعیت')).toBeTruthy();
expect(screen.getByRole('table')).toBeTruthy();
});
});

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import type { components } from '$lib/api/schema';
import { formatPercent, formatValue } from '$lib/format';
import { statusLabels, statusOrder, type TransactionStatus } from '$lib/transaction-analytics';
type Point = components['schemas']['TransactionStatusTrendPoint'];
type PeriodType = components['schemas']['PeriodType'];
let { data, periodType }: { data: Point[]; periodType: PeriodType } = $props();
let active = $state<{ bucket: number; status: TransactionStatus } | null>(null);
const width = 760;
const height = 300;
const pad = { top: 24, right: 24, bottom: 54, left: 62 };
const plotWidth = width - pad.left - pad.right;
const plotHeight = height - pad.top - pad.bottom;
const hour = new Intl.NumberFormat('fa-IR', { minimumIntegerDigits: 2, maximumFractionDigits: 0, useGrouping: false });
const date = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { month: 'short', day: 'numeric' });
const statusColors: Record<TransactionStatus, string> = {
Paid: '#2563eb',
Verified: '#059669',
Failed: '#e11d48',
Reversed: '#d97706',
};
function countFor(point: Point, status: TransactionStatus) {
if (status === 'Paid') return point.paidSessions;
if (status === 'Verified') return point.verifiedSessions;
if (status === 'Failed') return point.failedSessions;
return point.reversedSessions;
}
const maxCount = $derived(Math.max(1, ...data.flatMap((point) => statusOrder.map((status) => countFor(point, status)))));
const series = $derived(statusOrder.map((status) => ({
status,
points: data.map((point, bucket) => ({
bucket,
point,
count: countFor(point, status),
x: pad.left + (bucket * plotWidth) / Math.max(1, data.length - 1),
y: pad.top + plotHeight - (countFor(point, status) / maxCount) * plotHeight,
})),
})));
const xTickIndexes = $derived.by(() => {
const tickCount = Math.min(6, data.length);
if (tickCount <= 1) return data.length ? [0] : [];
return [...new Set(Array.from({ length: tickCount }, (_, index) => Math.round((index * (data.length - 1)) / (tickCount - 1))))];
});
const activePoint = $derived(active === null ? null : series.find(({ status }) => status === active.status)?.points[active.bucket] ?? null);
function bucketLabel(point: Point) {
if (periodType === 'DAY' && point.localHour !== undefined) return `${hour.format(point.localHour)}:۰۰`;
return date.format(new Date(`${point.localDate}T12:00:00Z`));
}
function share(point: Point, count: number) {
return point.totalSessions > 0 ? ((count * 100) / point.totalSessions).toFixed(2) : '0';
}
function linePath(points: (typeof series)[number]['points']) {
return points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.y}`).join(' ');
}
function tooltipStyle(point: NonNullable<typeof activePoint>) {
const horizontal = (point.x / width) * 100;
const vertical = (point.y / height) * 100;
const below = vertical < 35;
return `left:clamp(6rem,${horizontal}%,calc(100% - 6rem));top:${below ? vertical + 5 : vertical - 4}%;transform:translate(-50%,${below ? '0' : '-100%'});`;
}
</script>
<div class="space-y-5">
<div class="flex flex-wrap gap-x-5 gap-y-2 text-xs" aria-label="راهنمای رنگ وضعیت‌ها">
{#each statusOrder as status}
<span class="inline-flex items-center gap-2"><span class="size-2.5 rounded-full" style={`background:${statusColors[status]}`} aria-hidden="true"></span>{statusLabels[status]}</span>
{/each}
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (the horizontally scrollable chart must be keyboard reachable) -->
<div class="w-full overflow-x-auto overscroll-x-contain" dir="ltr" role="region" aria-label="نمودار خطی روند وضعیت تراکنش‌ها، قابل پیمایش افقی" tabindex="0">
<div class="relative min-h-[310px] min-w-[760px] md:min-w-0">
{#if data.length === 0}
<div class="text-muted-foreground grid h-[280px] place-items-center text-sm" role="status">داده‌ای برای این بازه ثبت نشده است.</div>
{:else}
<svg viewBox={`0 0 ${width} ${height}`} class="h-auto min-h-[300px] w-full overflow-visible" role="img" aria-label="روند زمانی تعداد تراکنش‌ها در چهار وضعیت">
{#each [0, 1, 2, 3, 4] as row}
{@const y = pad.top + (row * plotHeight) / 4}
<line x1={pad.left} x2={width - pad.right} y1={y} y2={y} stroke="var(--border)" stroke-dasharray="4 6" />
<text x={pad.left - 10} y={y + 4} fill="var(--muted-foreground)" font-size="10" text-anchor="end">{formatValue(Math.round(maxCount * (1 - row / 4)))}</text>
{/each}
<line x1={pad.left} x2={pad.left} y1={pad.top} y2={height - pad.bottom} stroke="var(--border)" />
<line x1={pad.left} x2={width - pad.right} y1={height - pad.bottom} y2={height - pad.bottom} stroke="var(--border)" />
{#each xTickIndexes as tickIndex}
{@const tick = series[0]?.points[tickIndex]}
{#if tick}<text x={tick.x} y={height - pad.bottom + 20} fill="var(--muted-foreground)" font-size="9" text-anchor="middle" direction="rtl">{bucketLabel(tick.point)}</text>{/if}
{/each}
<text x={(pad.left + width - pad.right) / 2} y={height - 5} fill="var(--muted-foreground)" font-size="10" text-anchor="middle" direction="rtl">{periodType === 'DAY' ? 'ساعت' : 'زمان'}</text>
<text x={-((pad.top + height - pad.bottom) / 2)} y="13" fill="var(--muted-foreground)" font-size="10" text-anchor="middle" transform="rotate(-90)" direction="rtl">تعداد تراکنش</text>
{#each series as statusSeries}
<path d={linePath(statusSeries.points)} fill="none" stroke={statusColors[statusSeries.status]} stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="status-line" />
{#each statusSeries.points as point}
<circle cx={point.x} cy={point.y} r={active?.bucket === point.bucket && active.status === statusSeries.status ? 5.5 : 3.25} fill="var(--card)" stroke={statusColors[statusSeries.status]} stroke-width="2.5" tabindex="0" role="button" aria-label={`${bucketLabel(point.point)}، ${statusLabels[statusSeries.status]}، ${formatValue(point.count)} تراکنش، ${formatPercent(share(point.point, point.count))}`} onmouseenter={() => (active = { bucket: point.bucket, status: statusSeries.status })} onmouseleave={() => (active = null)} onfocus={() => (active = { bucket: point.bucket, status: statusSeries.status })} onblur={() => (active = null)} />
{/each}
{/each}
</svg>
{#if active !== null && activePoint}
<div class="pointer-events-none absolute z-20 w-48 transition-[left,top,transform] duration-150 motion-reduce:transition-none" style={tooltipStyle(activePoint)} dir="rtl" role="tooltip">
<div class="rounded-md bg-zinc-950 p-3 text-right text-xs leading-5 text-white shadow-xl"><strong class="block">{bucketLabel(activePoint.point)}</strong><span class="block">{statusLabels[active.status]}</span><span class="block text-white/75">{formatValue(activePoint.count)} تراکنش · {formatPercent(share(activePoint.point, activePoint.count))}</span></div>
</div>
{/if}
{/if}
</div>
</div>
<details class="border-border rounded-md border">
<summary class="focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2">جدول داده‌های روند وضعیت</summary>
<div class="overflow-x-auto border-t">
<table class="w-full min-w-[760px] text-sm">
<caption class="sr-only">تعداد تراکنش‌ها به تفکیک زمان و وضعیت</caption>
<thead class="bg-muted/50"><tr><th class="p-3 text-right">زمان</th>{#each statusOrder as status}<th class="p-3 text-right">{statusLabels[status]}</th>{/each}<th class="p-3 text-right">کل</th></tr></thead>
<tbody>
{#each data as point}
<tr class="border-t"><th class="p-3 text-right font-medium">{bucketLabel(point)}</th>{#each statusOrder as status}{@const count = countFor(point, status)}<td class="p-3 tabular-nums">{formatValue(count)} <span class="text-muted-foreground">({formatPercent(share(point, count))})</span></td>{/each}<td class="p-3 font-semibold tabular-nums">{formatValue(point.totalSessions)}</td></tr>
{/each}
</tbody>
</table>
</div>
</details>
</div>
<style>
.status-line { stroke-dasharray: 1800; stroke-dashoffset: 1800; animation: draw-status-line 800ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
@keyframes draw-status-line { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .status-line { animation: none; stroke-dasharray: none; stroke-dashoffset: 0; } }
</style>

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import TransactionStatusTrendChart from './TransactionStatusTrendChart.svelte';
describe('TransactionStatusTrendChart', () => {
it('exposes count and percentage for every status segment and a semantic table', () => {
render(TransactionStatusTrendChart, {
periodType: 'DAY',
data: [
{
localDate: '2026-08-22',
localHour: 8,
paidSessions: 1,
verifiedSessions: 6,
failedSessions: 2,
reversedSessions: 1,
totalSessions: 10,
},
],
});
expect(screen.getByRole('button', { name: /۰۸:۰۰، تأیید نهایی، ۶ تراکنش، ۶۰٪/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /۰۸:۰۰، ناموفق، ۲ تراکنش، ۲۰٪/ })).toBeTruthy();
expect(screen.getByText('جدول داده‌های روند وضعیت')).toBeTruthy();
expect(screen.getByRole('table')).toBeTruthy();
});
it('keeps zero-filled buckets keyboard inspectable', () => {
render(TransactionStatusTrendChart, {
periodType: 'WEEK',
data: [
{
localDate: '2026-08-22',
paidSessions: 0,
verifiedSessions: 0,
failedSessions: 0,
reversedSessions: 0,
totalSessions: 0,
},
],
});
expect(screen.getAllByRole('button', { name: /۰ تراکنش، ۰٪/ })).toHaveLength(4);
});
});

View File

@@ -1,201 +1,181 @@
<script lang="ts">
import { page } from '$app/state';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ChartColumnBigIcon from '@lucide/svelte/icons/chart-column-big';
import CircleCheckBigIcon from '@lucide/svelte/icons/circle-check-big';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import ShieldAlertIcon from '@lucide/svelte/icons/shield-alert';
import { api, type SessionDetailPage, type SessionStatus } from '$lib/api/client';
import type { AnalyticsRun } from '$lib/api/client';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Table from '$lib/components/ui/table';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatDate, formatMoney, formatValue } from '$lib/format';
import { formatPercent, formatValue } from '$lib/format';
import KpiCard from './KpiCard.svelte';
import PageHeader from './PageHeader.svelte';
import AIInsightPanel from './AIInsightPanel.svelte';
import TransactionStatusChart from './TransactionStatusChart.svelte';
import TransactionStatusTrendChart from './TransactionStatusTrendChart.svelte';
const dashboard = useDashboardContext();
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
let result = $state<SessionDetailPage | null>(null);
let run = $state<AnalyticsRun | null>(null);
let loading = $state(true);
let error = $state('');
let cursor = $state<string | undefined>();
let sequence = 0;
let loadSequence = 0;
const statusLabels: Record<SessionStatus, string> = {
Paid: 'پرداخت‌شده',
Verified: 'تأییدشده',
Failed: 'ناموفق',
Reversed: 'برگشت‌خورده',
};
function badgeVariant(status: SessionStatus) {
return status === 'Paid' ? 'secondary' : status === 'Failed' ? 'destructive' : 'outline';
}
function badgeClass(status: SessionStatus) {
if (status === 'Verified') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300';
}
if (status === 'Reversed') {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300';
}
return '';
}
async function fetchRows(reset = false) {
const current = ++sequence;
if (reset) cursor = undefined;
async function loadRun() {
const sequence = ++loadSequence;
loading = true;
error = '';
const period = dashboard.periodFor(filters.range, filters.period);
if (!period) return;
try {
const next = await api.details(period.periodId, 'ALL', {
cursor,
terminalId: filters.terminal === 'all' ? undefined : filters.terminal,
status: filters.status === 'all' ? undefined : filters.status,
minAmountRial: filters.minAmount ?? undefined,
maxAmountRial: filters.maxAmount ?? undefined,
limit: 25,
});
if (current === sequence) result = next;
const next = await dashboard.runFor(filters.range, filters.terminal, filters.period);
if (sequence === loadSequence) run = next;
} catch {
if (current === sequence) error = 'فهرست تراکنش‌ها دریافت نشد.';
if (sequence === loadSequence) error = 'تحلیل تراکنش‌های این بازه آماده نشد. لطفاً دوباره تلاش کنید.';
} finally {
if (current === sequence) loading = false;
if (sequence === loadSequence) loading = false;
}
}
function detailUrl(sessionId: string) {
return `/transactions/${encodeURIComponent(sessionId)}${page.url.search}`;
}
async function nextPage() {
if (!result?.pageInfo.nextCursor) return;
cursor = result.pageInfo.nextCursor;
await fetchRows();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$effect(() => {
filters.range;
filters.period;
filters.terminal;
filters.status;
filters.minAmount;
filters.maxAmount;
void fetchRows(true);
filters.period;
void loadRun();
});
</script>
<svelte:head><title>تراکنش‌ها | تحلیل زرین‌پال</title></svelte:head>
<PageHeader
title="تراکنش‌ها"
description="جزئیات و تاریخچه پرداخت‌ها با فیلترهای قابل اشتراک"
description="سلامت عملیاتی پرداخت‌ها و ترکیب وضعیت‌ها در بازه انتخاب‌شده"
icon={ArrowUpDownIcon}
/>
<AIInsightPanel
module="transactions"
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
/>
<Card class="rounded-lg shadow-xs">
<CardContent class="p-0">
{#if loading}
<div class="overflow-hidden" role="status" aria-label="در حال دریافت تراکنش‌ها">
<div class="grid grid-cols-7 gap-3 border-b px-4 py-3">
{#each Array(7) as _}<Skeleton class="h-4 rounded-sm" />{/each}
<div
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
role="status"
aria-label="در حال محاسبه تحلیل تراکنش‌ها"
>
{#each Array(4) as _}<Skeleton class="h-40 rounded-lg" />{/each}
</div>
{#each Array(8) as _, index}
<div class="grid min-h-14 grid-cols-7 items-center gap-3 border-b px-4 py-3 last:border-0">
<Skeleton class="h-3.5 w-20 rounded-sm" />
<Skeleton class="h-3.5 w-12 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-8 rounded-sm" />
<Skeleton class="h-7 w-20 rounded-md" />
<Skeleton class="size-8 justify-self-end rounded-md" />
</div>
{/each}
<div class="mt-4 grid gap-4">
<Skeleton class="h-[28rem] rounded-lg" /><Skeleton class="h-[30rem] rounded-lg" />
</div>
{:else if error}
<div class="grid min-h-72 place-items-center text-sm">
<div class="space-y-4 text-center">
<Card class="border-destructive/30 rounded-lg">
<CardContent class="space-y-4 p-8 text-center">
<p role="alert">{error}</p>
<Button variant="outline" onclick={() => fetchRows(true)}><RotateCcwIcon /> تلاش دوباره</Button>
</div>
</div>
{:else if result && result.items.length}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header
><Table.Row
><Table.Head class="text-right">شناسه پرداخت</Table.Head><Table.Head class="text-right"
>درگاه</Table.Head
><Table.Head class="text-right">زمان</Table.Head><Table.Head class="text-right">مبلغ</Table.Head
><Table.Head class="text-right">تلاش‌ها</Table.Head><Table.Head class="text-right"
>وضعیت</Table.Head
><Table.Head><span class="sr-only">جزئیات</span></Table.Head></Table.Row
></Table.Header
>
<Table.Body>
{#each result.items as item}
<Table.Row class="hover:bg-muted/60 transition-colors">
<Table.Cell class="text-right font-mono text-xs" dir="ltr"
><a
class="hover:text-primary focus-visible:ring-ring rounded-sm underline-offset-4 hover:underline focus-visible:ring-2"
href={detailUrl(item.sessionId)}>{item.sessionId}</a
></Table.Cell
>
<Table.Cell class="text-right">{item.terminalId}</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">{formatDate(item.createdAt)}</Table.Cell>
<Table.Cell class="text-right font-semibold whitespace-nowrap"
>{formatMoney(item.amountRial)}</Table.Cell
>
<Table.Cell class="text-right">{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell class="text-right"
><Badge
variant={badgeVariant(item.sessionStatus)}
class={`rounded-md ${badgeClass(item.sessionStatus)}`}
>{statusLabels[item.sessionStatus]}</Badge
></Table.Cell
>
<Table.Cell
><Button
variant="ghost"
size="icon"
class="size-11"
href={detailUrl(item.sessionId)}
aria-label={`دیدن جزئیات تراکنش ${item.sessionId}`}
><ArrowLeftIcon class="text-muted-foreground size-4" /></Button
></Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="text-muted-foreground flex items-center justify-between border-t p-4 text-xs">
<span>{formatValue(result.items.length)} مورد در این صفحه</span><Button
variant="outline"
size="sm"
disabled={!result.pageInfo.hasNextPage}
onclick={nextPage}>صفحه بعد <ArrowLeftIcon /></Button
>
</div>
{:else}
<div class="grid min-h-72 place-items-center px-4 text-center">
<div>
<ArrowUpDownIcon class="text-muted-foreground mx-auto mb-4 size-9" />
<h2 class="font-bold">تراکنشی با این فیلتر پیدا نشد</h2>
<p class="text-muted-foreground mt-2 text-sm">بازه یا فیلتر مبلغ و وضعیت را تغییر دهید.</p>
</div>
</div>
{/if}
<Button variant="outline" onclick={loadRun}><RefreshCcwIcon /> تلاش دوباره</Button>
</CardContent>
</Card>
{:else if run}
{@const transactions = run.sections.transactions}
{#if transactions.state === 'READY'}
<div class="kpi-grid grid gap-3 sm:gap-4">
<KpiCard
label="کل تراکنش‌ها"
value={formatValue(transactions.data.totalSessions.value)}
note="همه تراکنش‌های ثبت‌شده در بازه انتخاب‌شده"
icon={ListChecksIcon}
/>
<KpiCard
label="نرخ موفقیت بانکی"
value={formatPercent(transactions.data.bankSuccessRate.value)}
note="سهم پرداخت‌شده‌ها و تأییدهای نهایی از کل تراکنش‌ها"
icon={CircleCheckBigIcon}
tone="good"
formula="(پرداخت‌شده + تأیید نهایی) ÷ کل تراکنش‌ها"
/>
<KpiCard
label="نرخ تکمیل تأیید"
value={formatPercent(transactions.data.verificationCompletionRate.value)}
note="سهم تأیید نهایی از همه پرداخت‌های موفق بانکی"
icon={GaugeIcon}
formula="تأیید نهایی ÷ (پرداخت‌شده + تأیید نهایی)"
/>
<KpiCard
label="نرخ ناموفق"
value={formatPercent(transactions.data.failureRate.value)}
note="سهم تراکنش‌های ناموفق از کل تراکنش‌ها"
icon={ShieldAlertIcon}
tone="warning"
formula="ناموفق ÷ کل تراکنش‌ها"
/>
</div>
<Card class="mt-4 overflow-hidden rounded-lg shadow-xs">
<CardHeader class="border-b pb-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2 text-base"
><ChartColumnBigIcon class="text-primary size-5" /> توزیع وضعیت تراکنش‌ها</CardTitle
>
<p class="text-muted-foreground mt-2 max-w-2xl text-xs leading-6">
ارتفاع هر ستون، تعداد مطلق تراکنش‌ها را نشان می‌دهد. واحد: تراکنش؛ سهم هر وضعیت از کل بازه
محاسبه شده است.
</p>
</div>
<Badge variant="secondary" class="rounded-md">{run.period.jalaliLabelFa}</Badge>
</div>
</CardHeader>
<CardContent class="p-4 sm:p-6">
{#if transactions.data.totalSessions.value === 0}<p
class="text-muted-foreground mb-4 rounded-md border border-dashed p-5 text-center text-sm"
role="status"
>
در این بازه تراکنشی ثبت نشده است؛ ستون‌ها با مقدار صفر نمایش داده می‌شوند.
</p>{/if}
<TransactionStatusChart data={transactions.data.statusDistribution} />
</CardContent>
</Card>
<Card class="mt-4 overflow-hidden rounded-lg shadow-xs">
<CardHeader class="border-b pb-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2 text-base"
><ChartColumnBigIcon class="text-primary size-5" /> روند ترکیب وضعیت‌ها</CardTitle
>
<p class="text-muted-foreground mt-2 max-w-2xl text-xs leading-6">
هر خط، تغییر تعداد یک وضعیت را در طول زمان نشان می‌دهد؛ جزئیات تعداد و سهم هر نقطه با
تمرکز صفحه‌کلید یا اشاره‌گر در دسترس است.
</p>
</div>
<Badge variant="secondary" class="rounded-md">{run.period.jalaliLabelFa}</Badge>
</div>
</CardHeader>
<CardContent class="p-4 sm:p-6"
><TransactionStatusTrendChart
data={transactions.data.statusTrend}
periodType={run.period.periodType}
/></CardContent
>
</Card>
{:else if transactions.state === 'FAILED'}
<Card class="rounded-lg"
><CardContent class="p-8 text-center text-sm" role="alert">بخش تحلیل تراکنش‌ها آماده نشد.</CardContent
></Card
>
{:else}
<Card class="rounded-lg"
><CardContent class="text-muted-foreground p-8 text-center text-sm" role="status"
>تحلیل وضعیت تراکنش‌ها در حال آماده‌سازی است.</CardContent
></Card
>
{/if}
{/if}
<style>
.kpi-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
</style>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatMoney, formatPercent, formatValue } from './format';
import { formatDate, formatDateOnly, formatMoney, formatPercent, formatValue } from './format';
describe('merchant-facing formatting', () => {
it('renders Rial values with Persian digits and an explicit unit', () => {
@@ -20,4 +20,11 @@ describe('merchant-facing formatting', () => {
it('formats instants in the Jalali calendar and Tehran timezone', () => {
expect(formatDate('2026-08-20T09:30:00Z')).toMatch(/۱۴۰۵/);
});
it('formats deadlines as a Jalali date without a clock value', () => {
const result = formatDateOnly('2026-08-20T09:30:00Z');
expect(result).toMatch(/۱۴۰۵/);
expect(result).not.toMatch(/\d{1,2}:\d{2}|[۰-۹]{1,2}:[۰-۹]{2}/);
});
});

View File

@@ -5,6 +5,10 @@ const date = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeStyle: 'short',
timeZone: 'Asia/Tehran',
});
const dateOnly = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
dateStyle: 'medium',
timeZone: 'Asia/Tehran',
});
export function formatValue(value: number): string {
return number.format(value);
@@ -25,3 +29,7 @@ export function formatMilliseconds(value: number | null): string {
export function formatDate(value: string): string {
return date.format(new Date(value));
}
export function formatDateOnly(value: string): string {
return dateOnly.format(new Date(value));
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { localizeGrowthAIText } from './growth-ai-text';
describe('growth AI text presentation', () => {
it('replaces internal evidence identifiers with dashboard language and links', () => {
const parts = localizeGrowthAIText(
'شواهد payment-health، payment-volume و operational-efficiency بررسی شدند.',
'T83',
);
expect(parts.map((part) => part.text).join('')).toBe(
'شواهد نرخ موفقیت پرداخت، تعداد و مبلغ پرداخت موفق و سرعت تأیید پرداخت بررسی شدند.',
);
expect(parts.filter((part) => part.href)).toEqual([
{ text: 'نرخ موفقیت پرداخت', href: '/payments?terminal=T83' },
{ text: 'تعداد و مبلغ پرداخت موفق', href: '/dashboard?terminal=T83' },
{ text: 'سرعت تأیید پرداخت', href: '/payments?terminal=T83' },
]);
});
it('localizes internal metric identifiers without changing ordinary text', () => {
const parts = localizeGrowthAIText('success_rate و fee_amount را بهتر کنید.', 'T83');
expect(parts.map((part) => part.text).join('')).toBe(
'نرخ موفقیت پرداخت و کارمزد پرداخت‌شده را بهتر کنید.',
);
expect(parts.filter((part) => part.href).map((part) => part.href)).toEqual([
'/payments?terminal=T83',
'/fees?terminal=T83',
]);
});
it('links Persian dashboard terms emitted by new generations', () => {
const parts = localizeGrowthAIText('نرخ موفقیت پرداخت را بهتر کنید.', 'T83');
expect(parts.filter((part) => part.href)).toEqual([
{ text: 'نرخ موفقیت پرداخت', href: '/payments?terminal=T83' },
]);
});
});

41
src/lib/growth-ai-text.ts Normal file
View File

@@ -0,0 +1,41 @@
export type GrowthAITextPart = {
text: string;
href?: string;
};
const internalTerms: Record<string, { text: string; page: string }> = {
'تعداد و مبلغ پرداخت موفق': { text: 'تعداد و مبلغ پرداخت موفق', page: '/dashboard' },
'نرخ موفقیت پرداخت': { text: 'نرخ موفقیت پرداخت', page: '/payments' },
'سرعت تأیید پرداخت': { text: 'سرعت تأیید پرداخت', page: '/payments' },
'کارمزد پرداخت‌شده': { text: 'کارمزد پرداخت‌شده', page: '/fees' },
'مبلغ پرداخت موفق': { text: 'مبلغ پرداخت موفق', page: '/dashboard' },
'تعداد پرداخت موفق': { text: 'تعداد پرداخت موفق', page: '/dashboard' },
'operational-efficiency': { text: 'سرعت تأیید پرداخت', page: '/payments' },
'payment-volume': { text: 'تعداد و مبلغ پرداخت موفق', page: '/dashboard' },
'payment-health': { text: 'نرخ موفقیت پرداخت', page: '/payments' },
success_rate: { text: 'نرخ موفقیت پرداخت', page: '/payments' },
paid_amount: { text: 'مبلغ پرداخت موفق', page: '/dashboard' },
paid_count: { text: 'تعداد پرداخت موفق', page: '/dashboard' },
avg_latency: { text: 'سرعت تأیید پرداخت', page: '/payments' },
fee_amount: { text: 'کارمزد پرداخت‌شده', page: '/fees' },
};
const internalTermPattern = new RegExp(
`(${Object.keys(internalTerms)
.sort((left, right) => right.length - left.length)
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|')})`,
'g',
);
export function localizeGrowthAIText(text: string, terminalId: string): GrowthAITextPart[] {
return text
.split(internalTermPattern)
.filter(Boolean)
.map((part) => {
const presentation = internalTerms[part];
if (!presentation) return { text: part };
const query = new URLSearchParams({ terminal: terminalId });
return { text: presentation.text, href: `${presentation.page}?${query.toString()}` };
});
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { millisecondsUntilTehranMidnight } from './growth-coach-schedule';
describe('growth coach daily schedule', () => {
it('targets the next 00:00 boundary in Tehran', () => {
const now = new Date('2026-08-21T20:29:00Z');
expect(millisecondsUntilTehranMidnight(now)).toBe(60_000);
});
});

View File

@@ -0,0 +1,34 @@
const tehranParts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
function parts(value: Date) {
return Object.fromEntries(
tehranParts
.formatToParts(value)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, Number(part.value)]),
);
}
function tehranOffset(value: Date) {
const local = parts(value);
return (
Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second) - value.getTime()
);
}
export function millisecondsUntilTehranMidnight(now = new Date()): number {
const local = parts(now);
const targetAsUTC = Date.UTC(local.year, local.month - 1, local.day + 1);
let target = new Date(targetAsUTC - tehranOffset(now));
target = new Date(targetAsUTC - tehranOffset(target));
return Math.max(0, target.getTime() - now.getTime());
}

View File

@@ -1,3 +1,3 @@
import { PUBLIC_AI_GROWTH_ENABLED } from '$env/static/public';
import { env } from '$env/dynamic/public';
export const growthFeatureEnabled = PUBLIC_AI_GROWTH_ENABLED === 'true';
export const growthFeatureEnabled = (env.PUBLIC_AI_COACH_ENABLED ?? env.PUBLIC_AI_GROWTH_ENABLED) === 'true';

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { statusOrder, statusTrendSegments } from './transaction-analytics';
describe('transaction analytics chart data', () => {
it('keeps the canonical status order', () => {
expect(statusOrder).toEqual(['Paid', 'Verified', 'Failed', 'Reversed']);
});
it('builds a complete 100 percent stack from status counts', () => {
expect(
statusTrendSegments({
localDate: '2026-08-01',
paidSessions: 10,
verifiedSessions: 60,
failedSessions: 20,
reversedSessions: 10,
totalSessions: 100,
}),
).toEqual([
{ status: 'Paid', count: 10, percent: 10, startPercent: 0, endPercent: 10 },
{ status: 'Verified', count: 60, percent: 60, startPercent: 10, endPercent: 70 },
{ status: 'Failed', count: 20, percent: 20, startPercent: 70, endPercent: 90 },
{ status: 'Reversed', count: 10, percent: 10, startPercent: 90, endPercent: 100 },
]);
});
it('returns zero-height segments for an empty bucket', () => {
const segments = statusTrendSegments({
localDate: '2026-08-02',
paidSessions: 0,
verifiedSessions: 0,
failedSessions: 0,
reversedSessions: 0,
totalSessions: 0,
});
expect(segments.every((segment) => segment.startPercent === 0 && segment.endPercent === 0)).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
export const statusOrder = ['Paid', 'Verified', 'Failed', 'Reversed'] as const;
export type TransactionStatus = (typeof statusOrder)[number];
export const statusLabels: Record<TransactionStatus, string> = {
Paid: 'پرداخت‌شده، در انتظار تأیید',
Verified: 'تأیید نهایی',
Failed: 'ناموفق',
Reversed: 'برگشت‌خورده',
};
export type StatusTrendPoint = {
localDate: string;
localHour?: number;
paidSessions: number;
verifiedSessions: number;
failedSessions: number;
reversedSessions: number;
totalSessions: number;
};
export type StatusTrendSegment = {
status: TransactionStatus;
count: number;
percent: number;
startPercent: number;
endPercent: number;
};
function countFor(point: StatusTrendPoint, status: TransactionStatus): number {
if (status === 'Paid') return point.paidSessions;
if (status === 'Verified') return point.verifiedSessions;
if (status === 'Failed') return point.failedSessions;
return point.reversedSessions;
}
export function statusTrendSegments(point: StatusTrendPoint): StatusTrendSegment[] {
let cumulative = 0;
return statusOrder.map((status) => {
const count = countFor(point, status);
const share = point.totalSessions > 0 ? (count * 100) / point.totalSessions : 0;
const segment = {
status,
count,
percent: share,
startPercent: cumulative,
endPercent: cumulative + share,
};
cumulative += share;
return segment;
});
}