diff --git a/.env.example b/.env.example index e460a78..16be266 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/DESIGN.md b/DESIGN.md index d426d3c..832174c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -130,37 +130,38 @@ 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 | -| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | -| 0.9.3 | 2026-08-20 | Replaced stacked field focus outlines with one narrow semantic-blue border across text fields, textareas, native selects, and custom select triggers; other keyboard targets retain a subtle 1px outline. | -| 0.9.2 | 2026-08-20 | Pinned the initialization skeleton sidebar to the physical right and reserved matching right-side content space so loading and loaded shells share the same geometry. | -| 0.9.1 | 2026-08-20 | Distributed the desktop period-type group and exact-period control across the available filter-bar width while preserving the compact mobile sheet layout. | -| 0.9.0 | 2026-08-20 | Added labeled X/Y axes and Persian ticks to the payment-history chart, and made the fee-share donut fluidly fill its dedicated visual column on larger viewports. | -| 0.8.0 | 2026-08-20 | Increased light-theme skeleton contrast, constrained and animated chart tooltips, made default period choices explicitly applicable, hid collapsed-sidebar labels, and strengthened the separation between period type and exact-period controls. | -| 0.7.0 | 2026-08-20 | Made period changes confirm-on-selection, moved narrow-screen filters into a bottom sheet, enlarged collapsed navigation targets, simplified focus rings, and promoted the fee-share chart into a dedicated visual column. | -| 0.6.0 | 2026-08-20 | Added full-span Jalali period selection, unified transaction status/amount controls, persistent skeleton states, custom RTL dropdowns, semantic peer/retry tones, and fee-share visualization; tightened the narrow navbar and 600px KPI breakpoint. | -| 0.5.0 | 2026-08-20 | Reconciled the approved baseline with the shipped routed frontend and desktop, mobile, and 639px review captures; converted the file to canonical DESIGN.md format and added the design sidecar. | -| 0.4.0 | 2026-08-20 | Adopted the current ZarinPal panel as the closer interaction reference: top wordmark bar, 256/72px collapsible right sidebar, 8px data surfaces, routed sections, URL-backed period and terminal filters, payment-history chart, transaction table, attempt history, formula help, and supportive insights. | -| 0.3.3 | 2026-08-20 | Replaced merchant/cohort revision freshness language with the approved immutable completed-import dataset snapshot. | -| 0.3.2 | 2026-08-20 | Made the licensed Iran Yekan X FaNum variable WOFF2 and its required license file Git-tracked frontend assets. | -| 0.3.1 | 2026-08-20 | Verified the purchased Iran Yekan X Professional FaNum variable assets and added merchant-owned detail CSV export as an MVP contract. | -| 0.3.0 | 2026-08-20 | Approved Iran Yekan X Professional FaNum, Persian digits, Rial-only values, first-class light/dark themes, and the deterministic insight threshold. | -| 0.2.4 | 2026-08-20 | Fixed the default period, comparison fallback, KPI priority, read-only drill-down scope, and accurate NoAttempt/InBank stage language. | -| 0.2.3 | 2026-08-20 | Kept authentication UX intentionally demo-sized with a clean replacement boundary for ZarinPal SSO. | -| 0.2.2 | 2026-08-20 | Defined backend-owned session UX and generic accessible expiry/revocation handling. | -| 0.2.1 | 2026-08-20 | Defined peer cards as an equal-per-merchant median plus direction-aware percentile. | -| 0.2.0 | 2026-08-20 | Corrected payment lifecycle semantics: Paid and Verified are bank-payment successes, Paid remains unverified, and session fees are real Rial amounts. | -| 0.1.9 | 2026-08-20 | Added rate-limit UX, SSE heartbeat/recovery, replay, and failed-section-only retry. | -| 0.1.8 | 2026-08-20 | Fixed the parallel analytics section identifiers, cache, idempotent start/SSE flow, and section timeout/retry behavior. | -| 0.1.7 | 2026-08-20 | Defined partial, parallel section delivery through authenticated SSE. | -| 0.1.6 | 2026-08-20 | Replaced time-based analytics freshness with lazy calculation and defined Saturday–Friday weeks. | -| 0.1.5 | 2026-08-20 | Limited period controls to daily, weekly, Jalali-month, and Persian-season views. | -| 0.1.2 | 2026-08-20 | Added the insufficient-data state and visibility threshold for anonymized peer comparisons. | -| 0.1.1 | 2026-08-20 | Fixed merchant-facing time presentation to the Jalali calendar in Asia/Tehran with UTC API instants. | -| 0.1.0 | 2026-08-20 | Established the ZarinPal-inspired RTL foundation, responsive shell, core components, accessibility, chart, and motion rules. | +| 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. | +| 0.9.3 | 2026-08-20 | Replaced stacked field focus outlines with one narrow semantic-blue border across text fields, textareas, native selects, and custom select triggers; other keyboard targets retain a subtle 1px outline. | +| 0.9.2 | 2026-08-20 | Pinned the initialization skeleton sidebar to the physical right and reserved matching right-side content space so loading and loaded shells share the same geometry. | +| 0.9.1 | 2026-08-20 | Distributed the desktop period-type group and exact-period control across the available filter-bar width while preserving the compact mobile sheet layout. | +| 0.9.0 | 2026-08-20 | Added labeled X/Y axes and Persian ticks to the payment-history chart, and made the fee-share donut fluidly fill its dedicated visual column on larger viewports. | +| 0.8.0 | 2026-08-20 | Increased light-theme skeleton contrast, constrained and animated chart tooltips, made default period choices explicitly applicable, hid collapsed-sidebar labels, and strengthened the separation between period type and exact-period controls. | +| 0.7.0 | 2026-08-20 | Made period changes confirm-on-selection, moved narrow-screen filters into a bottom sheet, enlarged collapsed navigation targets, simplified focus rings, and promoted the fee-share chart into a dedicated visual column. | +| 0.6.0 | 2026-08-20 | Added full-span Jalali period selection, unified transaction status/amount controls, persistent skeleton states, custom RTL dropdowns, semantic peer/retry tones, and fee-share visualization; tightened the narrow navbar and 600px KPI breakpoint. | +| 0.5.0 | 2026-08-20 | Reconciled the approved baseline with the shipped routed frontend and desktop, mobile, and 639px review captures; converted the file to canonical DESIGN.md format and added the design sidecar. | +| 0.4.0 | 2026-08-20 | Adopted the current ZarinPal panel as the closer interaction reference: top wordmark bar, 256/72px collapsible right sidebar, 8px data surfaces, routed sections, URL-backed period and terminal filters, payment-history chart, transaction table, attempt history, formula help, and supportive insights. | +| 0.3.3 | 2026-08-20 | Replaced merchant/cohort revision freshness language with the approved immutable completed-import dataset snapshot. | +| 0.3.2 | 2026-08-20 | Made the licensed Iran Yekan X FaNum variable WOFF2 and its required license file Git-tracked frontend assets. | +| 0.3.1 | 2026-08-20 | Verified the purchased Iran Yekan X Professional FaNum variable assets and added merchant-owned detail CSV export as an MVP contract. | +| 0.3.0 | 2026-08-20 | Approved Iran Yekan X Professional FaNum, Persian digits, Rial-only values, first-class light/dark themes, and the deterministic insight threshold. | +| 0.2.4 | 2026-08-20 | Fixed the default period, comparison fallback, KPI priority, read-only drill-down scope, and accurate NoAttempt/InBank stage language. | +| 0.2.3 | 2026-08-20 | Kept authentication UX intentionally demo-sized with a clean replacement boundary for ZarinPal SSO. | +| 0.2.2 | 2026-08-20 | Defined backend-owned session UX and generic accessible expiry/revocation handling. | +| 0.2.1 | 2026-08-20 | Defined peer cards as an equal-per-merchant median plus direction-aware percentile. | +| 0.2.0 | 2026-08-20 | Corrected payment lifecycle semantics: Paid and Verified are bank-payment successes, Paid remains unverified, and session fees are real Rial amounts. | +| 0.1.9 | 2026-08-20 | Added rate-limit UX, SSE heartbeat/recovery, replay, and failed-section-only retry. | +| 0.1.8 | 2026-08-20 | Fixed the parallel analytics section identifiers, cache, idempotent start/SSE flow, and section timeout/retry behavior. | +| 0.1.7 | 2026-08-20 | Defined partial, parallel section delivery through authenticated SSE. | +| 0.1.6 | 2026-08-20 | Replaced time-based analytics freshness with lazy calculation and defined Saturday–Friday weeks. | +| 0.1.5 | 2026-08-20 | Limited period controls to daily, weekly, Jalali-month, and Persian-season views. | +| 0.1.2 | 2026-08-20 | Added the insufficient-data state and visibility threshold for anonymized peer comparisons. | +| 0.1.1 | 2026-08-20 | Fixed merchant-facing time presentation to the Jalali calendar in Asia/Tehran with UTC API instants. | +| 0.1.0 | 2026-08-20 | Established the ZarinPal-inspired RTL foundation, responsive shell, core components, accessibility, chart, and motion rules. | ## Colors @@ -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 diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index beaca6f..4125c71 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -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 + >; 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(`/analytics/growth-reports/${encodeURIComponent(reportId)}`); }, - latestAIInsight(module: AIInsightModule, terminalId?: string) { - const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : ''; - return request(`/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(`/analytics/ai-insights/${module}${suffix}`); }, - refreshAIInsight(module: AIInsightModule, csrfToken: string, terminalId?: string) { + refreshAIInsight(module: AIInsightModule, csrfToken: string, terminalId?: string, periodId?: string) { return request(`/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( + `/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(`/analytics/ai-insight-refreshes/${encodeURIComponent(jobId)}`); }, + growthCoach(terminalId?: string) { + const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : ''; + return request(`/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( + `/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( + `/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( + `/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(`/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(`/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( + `/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(`/growth-coach/refreshes/${encodeURIComponent(jobId)}`); + }, exportUrl( periodId: string, view: DetailView, diff --git a/src/lib/api/coach-client.test.ts b/src/lib/api/coach-client.test.ts new file mode 100644 index 0000000..d02a912 --- /dev/null +++ b/src/lib/api/coach-client.test.ts @@ -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' }); + }); +}); diff --git a/src/lib/api/insight-client.test.ts b/src/lib/api/insight-client.test.ts new file mode 100644 index 0000000..ea065fe --- /dev/null +++ b/src/lib/api/insight-client.test.ts @@ -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', + ); + }); +}); diff --git a/src/lib/api/schema.d.ts b/src/lib/api/schema.d.ts index 3ed0b10..34926df 100644 --- a/src/lib/api/schema.d.ts +++ b/src/lib/api/schema.d.ts @@ -365,6 +365,23 @@ export interface paths { patch?: never; trace?: never; }; + "/analytics/ai-insights/{module}/sections/{sectionId}/refreshes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh one independently persisted AI narrative section */ + post: operations["refreshAIInsightSection"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/analytics/ai-insight-refreshes/{jobId}": { parameters: { query?: never; @@ -398,10 +415,411 @@ export interface paths { patch?: never; trace?: never; }; + "/growth-coach": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return a terminal coach workspace or an all-terminal rollup */ + get: operations["getGrowthCoachDashboard"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/terminals/{terminalId}/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGrowthCoachProfile"]; + put: operations["putGrowthCoachProfile"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/terminals/{terminalId}/refreshes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["refreshGrowthCoach"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/plans": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listGrowthCoachPlans"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/actions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listGoldenSteps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/goals": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMonthlyGoals"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/goals/{goalId}/subtasks/{subtaskId}/transitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transitionGoalSubtask"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/deadline-proposals/{proposalId}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["decideGoalDeadlineProposal"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/recommendations/{id}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["decideGrowthCoachRecommendation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/actions/{id}/transitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transitionGoldenStep"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/difficulty-proposals/{id}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["decideGrowthCoachDifficulty"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/refreshes/{jobId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGrowthCoachJob"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/growth-coach/refreshes/{jobId}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["streamGrowthCoachJob"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + GrowthCoachProfileInput: { + businessDescription: string; + niches: string[]; + salesChannels: string[]; + primaryGoal: string; + constraints: string; + }; + GrowthCoachProfile: components["schemas"]["GrowthCoachProfileInput"] & { + profileId: string; + terminalId: string; + version: number; + difficulty: number; + /** Format: date-time */ + createdAt: string; + }; + GrowthCoachJob: { + jobId: string; + /** @enum {string} */ + state: "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED"; + eventsUrl: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + GrowthCoachRefreshInput: { + /** @enum {string} */ + cadence: "WEEKLY" | "MONTHLY"; + }; + GrowthCoachProfileResult: { + profile: components["schemas"]["GrowthCoachProfile"]; + generationJobs: components["schemas"]["GrowthCoachJob"][]; + pageInsightJobs: components["schemas"]["AIInsightJob"][]; + }; + GrowthCoachRecommendation: { + recommendationId: string; + planId: string; + rank: number; + /** @enum {string} */ + status: "OFFERED" | "ACCEPTED" | "REJECTED" | "EXPIRED"; + /** @enum {string} */ + actionType: "MANUAL" | "METRIC"; + title: string; + rationale: string; + expectedImpact: string; + /** @enum {string} */ + effort: "LOW" | "MEDIUM" | "HIGH"; + evidenceIds: string[]; + metricId?: string | null; + metricDirection?: string | null; + actionLevel?: string | null; + baseline?: number | null; + target?: number | null; + /** Format: int64 */ + sampleSize: number; + /** Format: date */ + deadline: string; + subtasks: components["schemas"]["GeneratedGoalSubtask"][]; + eventIds: string[]; + }; + GeneratedGoalSubtask: { + title: string; + /** Format: date */ + deadline: string | null; + eventId: string | null; + }; + GoalSubtask: { + subtaskId: string; + position: number; + title: string; + /** @enum {string} */ + status: "TODO" | "DONE"; + /** Format: date */ + deadline: string; + eventId?: string | null; + /** Format: date-time */ + completedAt?: string | null; + }; + GoalDeadlineProposal: { + proposalId: string; + goalId: string; + subtaskId: string; + eventId: string; + /** Format: date */ + currentDeadline: string; + /** Format: date */ + proposedDeadline: string; + reason: string; + /** @enum {string} */ + status: "OFFERED" | "ACCEPTED" | "REJECTED" | "EXPIRED"; + /** Format: date-time */ + decidedAt?: string | null; + }; + MonthlyGoal: { + goalId: string; + recommendationId: string; + terminalId: string; + title: string; + expectedImpact: string; + /** @enum {string} */ + status: "ACTIVE" | "COMPLETED" | "MISSED" | "ABANDONED"; + /** @enum {string} */ + metricId: "paid_count" | "paid_amount" | "fee_amount" | "avg_latency" | "success_rate"; + /** @enum {string} */ + metricDirection: "INCREASE" | "DECREASE"; + baseline: number; + target: number; + currentValue: number; + progressPercent: number; + /** Format: int64 */ + sampleSize: number; + /** Format: date */ + deadline: string; + eventIds: string[]; + subtasks: components["schemas"]["GoalSubtask"][]; + deadlineProposals: components["schemas"]["GoalDeadlineProposal"][]; + /** Format: date-time */ + updatedAt: string; + }; + GrowthCoachPlan: { + planId: string; + parentId?: string | null; + /** @enum {string} */ + cadence: "DAILY" | "WEEKLY" | "MONTHLY"; + status: string; + /** Format: date */ + periodStart: string; + /** Format: date */ + periodEnd: string; + dailyBrief?: string | null; + difficulty: number; + suggestions: components["schemas"]["GrowthCoachRecommendation"][]; + /** Format: date-time */ + generatedAt: string; + }; + GoldenStep: { + actionId: string; + recommendationId: string; + terminalId: string; + /** @enum {string} */ + actionType: "MANUAL" | "METRIC"; + title: string; + /** @enum {string} */ + status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "ABANDONED" | "MISSED"; + metricId?: string | null; + metricDirection?: string | null; + baseline?: number | null; + target?: number | null; + currentValue?: number | null; + /** Format: int64 */ + sampleSize: number; + /** Format: date */ + deadline: string; + /** Format: date-time */ + updatedAt: string; + }; + GrowthCoachTerminalRollup: { + terminalId: string; + /** @enum {string} */ + onboardingStatus: "REQUIRED" | "COMPLETE"; + activeActions: number; + overdueActions: number; + completionRate: number; + }; + GrowthCoachDashboard: { + /** @enum {string} */ + scope: "TERMINAL" | "ALL_TERMINALS"; + terminalId?: string | null; + /** Format: date */ + dataThrough?: string | null; + /** @enum {string} */ + generationState: "ONBOARDING_REQUIRED" | "GENERATING" | "IDLE" | "UNAVAILABLE"; + profile?: components["schemas"]["GrowthCoachProfile"] | null; + plans: components["schemas"]["GrowthCoachPlan"][]; + actions: components["schemas"]["GoldenStep"][]; + terminals: components["schemas"]["GrowthCoachTerminalRollup"][]; + difficultyProposals: components["schemas"]["GrowthCoachDifficultyProposal"][]; + }; + GrowthCoachDifficultyProposal: { + proposalId: string; + fromLevel: number; + toLevel: number; + reason: string; + /** @enum {string} */ + status: "OFFERED" | "ACCEPTED" | "REJECTED" | "EXPIRED"; + }; + CoachDecision: { + /** @enum {string} */ + decision: "ACCEPTED" | "REJECTED"; + reason: string; + note: string; + }; CreateGrowthReportRequest: { terminalId?: string; /** @description When omitted, the request reuses the current matching report. Manual retries remain available until the requested scope has persisted AI copy. */ @@ -489,6 +907,21 @@ export interface components { expectedAmountRial: components["schemas"]["GrowthRange"]; historicalEvidenceId: string; }; + GrowthUpcomingEvent: { + evidenceId: string; + eventId: string; + eventTitleFa: string; + /** Format: date-time */ + date: string; + eventType: string; + officialHoliday: boolean; + merchantCategories: string[]; + behavioralChannels: string[]; + }; + GrowthEventRecommendation: { + evidenceId: string; + relevanceReason: string; + }; GrowthRisk: { evidenceId: string; /** Format: date-time */ @@ -539,12 +972,12 @@ export interface components { evidenceIds: string[]; }; GrowthEvidence: { - forecast: components["schemas"]["GrowthForecast"]; - historicalWins: components["schemas"]["GrowthHistoricalWin"][]; - opportunities: components["schemas"]["GrowthOpportunity"][]; - risks: components["schemas"]["GrowthRisk"][]; - peer?: components["schemas"]["GrowthPeer"]; - fallbackBriefs: components["schemas"]["GrowthFallbackBrief"][]; + events: components["schemas"]["GrowthUpcomingEvent"][]; + merchantContext: { + category: string; + niches: string[]; + salesChannels: string[]; + }; }; GrowthReport: { reportId: string; @@ -556,11 +989,9 @@ export interface components { }; /** @enum {integer} */ horizonDays: 90; - /** Format: date-time */ - forecastStartDate?: string; deterministicEvidence?: components["schemas"]["GrowthEvidence"]; aiCopy?: { - briefs: components["schemas"]["GrowthCampaignBrief"][]; + eventRecommendations: components["schemas"]["GrowthEventRecommendation"][]; }; failureCode?: string; cached: boolean; @@ -614,6 +1045,7 @@ export interface components { status: "READY" | "PARTIAL"; scope: { terminalId?: string | null; + periodId?: string | null; }; /** Format: date */ asOfDate: string; @@ -636,15 +1068,23 @@ export interface components { refreshState: { [key: string]: unknown; }; + sectionRefreshState: { + [key: string]: components["schemas"]["AIInsightRefreshState"]; + }; /** Format: date-time */ generatedAt: string; }; AIInsightRefreshRequest: { terminalId?: string; + /** @description Used only by the transactions module. */ + periodId?: string; }; AIInsightJob: { jobId: string; snapshotId: string; + module: components["schemas"]["AIInsightModule"]; + /** @enum {string} */ + sectionId: "full" | "past" | "latest-observed" | "future-7-days" | "future-30-days" | "future-90-days" | "findings" | "options"; /** @enum {string} */ state: "QUEUED" | "RUNNING" | "COMPLETED" | "FAILED"; /** Format: uri-reference */ @@ -654,6 +1094,17 @@ export interface components { /** Format: date-time */ updatedAt: string; }; + /** @enum {string} */ + AIInsightSection: "past" | "latest-observed" | "future-7-days" | "future-30-days" | "future-90-days" | "findings" | "options"; + AIInsightRefreshState: { + /** @enum {string} */ + state: "IDLE" | "QUEUED" | "RUNNING" | "FAILED" | "LIMITED"; + jobId?: string; + /** Format: uri-reference */ + eventsUrl?: string; + /** Format: date-time */ + nextRefreshAt?: string; + }; LoginRequest: { /** @example merchant-0001 */ username: string; @@ -745,7 +1196,7 @@ export interface components { /** @example run_01K2M7YYYYYYYYYYYYYYYYYYYY */ RunId: string; /** @enum {string} */ - SectionId: "overview" | "payment_performance" | "fees" | "peer_comparison"; + SectionId: "overview" | "payment_performance" | "fees" | "peer_comparison" | "transactions"; /** @enum {string} */ AnalyticsRunState: "IN_PROGRESS" | "COMPLETE" | "COMPLETE_WITH_FAILURES"; /** @enum {string} */ @@ -757,7 +1208,7 @@ export interface components { /** @description Opaque identifier for the immutable completed-import snapshot. */ datasetSnapshotId: string; /** @constant */ - metricDefinitionVersion: "0.4.0"; + metricDefinitionVersion: "0.5.0"; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -770,6 +1221,7 @@ export interface components { paymentPerformance: components["schemas"]["PaymentPerformanceSection"]; fees: components["schemas"]["FeesSection"]; peerComparison: components["schemas"]["PeerComparisonSection"]; + transactions: components["schemas"]["TransactionsSection"]; }; }; QueuedSection: { @@ -800,7 +1252,7 @@ export interface components { calculatedAt: string; datasetSnapshotId: string; /** @constant */ - metricDefinitionVersion: "0.4.0"; + metricDefinitionVersion: "0.5.0"; }; IntegerMetric: { metricId: string; @@ -885,6 +1337,36 @@ export interface components { initTiming: components["schemas"]["TimingDistribution"]; verifyTiming: components["schemas"]["TimingDistribution"]; }; + TransactionStatusDistribution: { + status: components["schemas"]["SessionStatus"]; + /** Format: int64 */ + count: number; + sharePercent: string | null; + }; + TransactionStatusTrendPoint: { + /** Format: date */ + localDate: string; + localHour?: number; + /** Format: int64 */ + paidSessions: number; + /** Format: int64 */ + verifiedSessions: number; + /** Format: int64 */ + failedSessions: number; + /** Format: int64 */ + reversedSessions: number; + /** Format: int64 */ + totalSessions: number; + }; + TransactionsData: { + totalSessions: components["schemas"]["IntegerMetric"]; + bankSuccessRate: components["schemas"]["DecimalMetric"]; + verificationCompletionRate: components["schemas"]["DecimalMetric"]; + failureRate: components["schemas"]["DecimalMetric"]; + /** @description Canonical order is Paid, Verified, Failed, Reversed. */ + statusDistribution: components["schemas"]["TransactionStatusDistribution"][]; + statusTrend: components["schemas"]["TransactionStatusTrendPoint"][]; + }; FeesData: { paidFeeAmountRial: components["schemas"]["IntegerMetric"]; bankPaidAmountRial: components["schemas"]["IntegerMetric"]; @@ -921,6 +1403,14 @@ export interface components { metadata: components["schemas"]["ReadySectionMetadata"]; data: components["schemas"]["PaymentPerformanceData"]; }; + TransactionsReadySection: { + /** @constant */ + sectionId: "transactions"; + /** @constant */ + state: "READY"; + metadata: components["schemas"]["ReadySectionMetadata"]; + data: components["schemas"]["TransactionsData"]; + }; FeesReadySection: { /** @constant */ sectionId: "fees"; @@ -959,6 +1449,7 @@ export interface components { }; OverviewSection: components["schemas"]["QueuedSection"] | components["schemas"]["CalculatingSection"] | components["schemas"]["FailedSection"] | components["schemas"]["OverviewReadySection"]; PaymentPerformanceSection: components["schemas"]["QueuedSection"] | components["schemas"]["CalculatingSection"] | components["schemas"]["FailedSection"] | components["schemas"]["PaymentPerformanceReadySection"]; + TransactionsSection: components["schemas"]["QueuedSection"] | components["schemas"]["CalculatingSection"] | components["schemas"]["FailedSection"] | components["schemas"]["TransactionsReadySection"]; FeesSection: components["schemas"]["QueuedSection"] | components["schemas"]["CalculatingSection"] | components["schemas"]["FailedSection"] | components["schemas"]["FeesReadySection"]; PeerComparisonSection: components["schemas"]["QueuedSection"] | components["schemas"]["CalculatingSection"] | components["schemas"]["FailedSection"] | components["schemas"]["PeerComparisonReadySection"] | components["schemas"]["PeerComparisonSuppressedSection"]; /** @enum {string} */ @@ -1037,7 +1528,7 @@ export interface components { }; SectionReadyEvent: components["schemas"]["SseEventBase"] & { sectionId: components["schemas"]["SectionId"]; - section: components["schemas"]["OverviewReadySection"] | components["schemas"]["PaymentPerformanceReadySection"] | components["schemas"]["FeesReadySection"] | components["schemas"]["PeerComparisonReadySection"]; + section: components["schemas"]["OverviewReadySection"] | components["schemas"]["PaymentPerformanceReadySection"] | components["schemas"]["TransactionsReadySection"] | components["schemas"]["FeesReadySection"] | components["schemas"]["PeerComparisonReadySection"]; }; SectionFailedEvent: components["schemas"]["SseEventBase"] & { sectionId: components["schemas"]["SectionId"]; @@ -1978,6 +2469,8 @@ export interface operations { parameters: { query?: { terminalId?: string; + /** @description Used only by the transactions module to match the selected reporting period. */ + periodId?: string; }; header?: { /** @description Optional safe correlation id. The server may replace invalid values. */ @@ -2057,6 +2550,59 @@ export interface operations { 503: components["responses"]["AnalyticsUnavailable"]; }; }; + refreshAIInsightSection: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + /** @description Strict same-origin evidence validated with Fetch Metadata where available. */ + Origin: components["parameters"]["OriginHeader"]; + }; + path: { + module: components["schemas"]["AIInsightModule"]; + sectionId: components["schemas"]["AIInsightSection"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AIInsightRefreshRequest"]; + }; + }; + responses: { + /** @description Idempotent job reused. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AIInsightJob"]; + }; + }; + /** @description Section refresh accepted. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AIInsightJob"]; + }; + }; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["ResourceNotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 429: components["responses"]["RateLimited"]; + 503: components["responses"]["AnalyticsUnavailable"]; + }; + }; getAIInsightRefresh: { parameters: { query?: never; @@ -2107,4 +2653,424 @@ export interface operations { 404: components["responses"]["ResourceNotFound"]; }; }; + getGrowthCoachDashboard: { + parameters: { + query?: { + terminalId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Persisted coach state and deterministic progress. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GrowthCoachDashboard"]; + }; + }; + 401: components["responses"]["Unauthenticated"]; + 404: components["responses"]["ResourceNotFound"]; + 503: components["responses"]["AnalyticsUnavailable"]; + }; + }; + getGrowthCoachProfile: { + parameters: { + query?: never; + header?: never; + path: { + terminalId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current profile version. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GrowthCoachProfile"]; + }; + }; + 404: components["responses"]["ResourceNotFound"]; + }; + }; + putGrowthCoachProfile: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + terminalId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GrowthCoachProfileInput"]; + }; + }; + responses: { + /** @description New profile version and generation jobs. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GrowthCoachProfileResult"]; + }; + }; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + }; + }; + refreshGrowthCoach: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + terminalId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GrowthCoachRefreshInput"]; + }; + }; + responses: { + /** @description Cadence-scoped coach refresh queued. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GrowthCoachJob"]; + }; + }; + 404: components["responses"]["ResourceNotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["ValidationFailed"]; + 429: components["responses"]["RateLimited"]; + }; + }; + listGrowthCoachPlans: { + parameters: { + query?: { + terminalId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Coach plans. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + plans: components["schemas"]["GrowthCoachPlan"][]; + }; + }; + }; + }; + }; + listGoldenSteps: { + parameters: { + query?: { + terminalId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Shared Golden Steps workspace. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + actions: components["schemas"]["GoldenStep"][]; + }; + }; + }; + }; + }; + listMonthlyGoals: { + parameters: { + query?: { + terminalId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Automatically tracked monthly goals with manual action subtasks. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + goals: components["schemas"]["MonthlyGoal"][]; + }; + }; + }; + }; + }; + transitionGoalSubtask: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + goalId: string; + subtaskId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + transition: "COMPLETE" | "REOPEN"; + }; + }; + }; + responses: { + /** @description Updated user-completed subtask. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GoalSubtask"]; + }; + }; + 409: components["responses"]["Conflict"]; + }; + }; + decideGoalDeadlineProposal: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + proposalId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + decision: "ACCEPT" | "REJECT"; + }; + }; + }; + responses: { + /** @description Deadline proposal decision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GoalDeadlineProposal"]; + }; + }; + 409: components["responses"]["Conflict"]; + }; + }; + decideGrowthCoachRecommendation: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CoachDecision"]; + }; + }; + responses: { + /** @description Decision recorded and accepted action created when applicable. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 409: components["responses"]["Conflict"]; + }; + }; + transitionGoldenStep: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + transition: "START" | "COMPLETE" | "ABANDON"; + }; + }; + }; + responses: { + /** @description Updated action. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GoldenStep"]; + }; + }; + 409: components["responses"]["Conflict"]; + }; + }; + decideGrowthCoachDifficulty: { + parameters: { + query?: never; + header: { + /** + * @description Client-generated intent key reused unchanged for retries of one intent. + * @example 95f6ec2d-26e8-4983-afd4-8a6d4434726b + */ + "Idempotency-Key": components["parameters"]["IdempotencyKeyHeader"]; + /** @description CSRF token for the current authenticated session; never log this value. */ + "X-CSRF-Token": components["parameters"]["CsrfTokenHeader"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CoachDecision"]; + }; + }; + responses: { + /** @description Difficulty decision recorded. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 409: components["responses"]["Conflict"]; + }; + }; + getGrowthCoachJob: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Generation job. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GrowthCoachJob"]; + }; + }; + 404: components["responses"]["ResourceNotFound"]; + }; + }; + streamGrowthCoachJob: { + parameters: { + query?: never; + header?: never; + path: { + jobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Job snapshots. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": string; + }; + }; + }; + }; } diff --git a/src/lib/components/AIInsightPanel.svelte b/src/lib/components/AIInsightPanel.svelte index 9d1c756..5034eee 100644 --- a/src/lib/components/AIInsightPanel.svelte +++ b/src/lib/components/AIInsightPanel.svelte @@ -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(null); let loading = $state(true); let refreshing = $state(false); + let refreshingSections = $state>>({}); + let sectionErrors = $state>>({}); 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 @@
- {#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}
-

{section.headline}

-

{section.body}

+
+

{section.copy.headline}

+ +
+

{section.copy.body}

+ {#if sectionErrors[section.id]}

+ {sectionErrors[section.id]} +

{/if}
{/each}
{#if insight.copy.future90Days}
-

{insight.copy.future90Days.headline}

+
+

{insight.copy.future90Days.headline}

+ +

{insight.copy.future90Days.body}

{/if} @@ -170,16 +249,45 @@ />
- {#each insight.copy.findings as finding}
-

{finding.title}

-

{finding.summary}

- {finding.evidenceId} -
{/each}{#each insight.copy.options as option}
-

{option.title}

-

{option.description}

-
{/each} +
+
+

یافته‌ها

+ +
+ {#each insight.copy.findings as finding}
+

{finding.title}

+

{finding.summary}

+ {finding.evidenceId} +
{/each} +
+
+
+

گزینه‌ها

+ +
+ {#each insight.copy.options as option}
+

{option.title}

+

{option.description}

+
{/each} +
diff --git a/src/lib/components/AIInsightPanel.test.ts b/src/lib/components/AIInsightPanel.test.ts new file mode 100644 index 0000000..322de1b --- /dev/null +++ b/src/lib/components/AIInsightPanel.test.ts @@ -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(); + 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'); + }); +}); diff --git a/src/lib/components/AmountRangeFilter.test.ts b/src/lib/components/AmountRangeFilter.test.ts index 576bd93..6ec5ca1 100644 --- a/src/lib/components/AmountRangeFilter.test.ts +++ b/src/lib/components/AmountRangeFilter.test.ts @@ -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); }); }); diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index e2cdc2b..97d8c03 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -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 @@ {#snippet child({ props })} - - {item.label} + {#if item.href === '/growth'} + + + + + {:else} + + {/if} + + {item.label} + {#if item.href === '/growth'} + AI + {/if} + {/snippet} diff --git a/src/lib/components/DashboardControls.svelte b/src/lib/components/DashboardControls.svelte index cda3818..a8bc839 100644 --- a/src/lib/components/DashboardControls.svelte +++ b/src/lib/components/DashboardControls.svelte @@ -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('month'); let desktopPickerOpen = $state(false); @@ -95,76 +91,7 @@
{/snippet} -{#snippet transactionFilters()} -
- update({ status: value })} - items={[ - { value: 'all', label: 'همه وضعیت‌ها' }, - { value: 'Verified', label: 'تأییدشده' }, - { value: 'Paid', label: 'پرداخت‌شده' }, - { value: 'Failed', label: 'ناموفق' }, - { value: 'Reversed', label: 'برگشت‌خورده' }, - ]} - > - - - {filters.status === 'Verified' - ? 'تأییدشده' - : filters.status === 'Paid' - ? 'پرداخت‌شده' - : filters.status === 'Failed' - ? 'ناموفق' - : filters.status === 'Reversed' - ? 'برگشت‌خورده' - : 'همه وضعیت‌ها'} - - - - - - - - - - - - - - - update({ - minAmount: minAmount === null ? null : String(minAmount), - maxAmount: maxAmount === null ? null : String(maxAmount), - })} - /> - - {#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null} - - {/if} -
-{/snippet} - @@ -188,17 +115,9 @@ > فیلتر گزارش - بازه دقیق و فیلترهای این صفحه را انتخاب کنید. + بازه دقیق گزارش را انتخاب کنید. - {#if isTransactionsPage} -
-

فیلتر تراکنش‌ها

- {@render transactionFilters()} -
-
- {/if} -

بازه زمانی

{@render periodControls(true)} diff --git a/src/lib/components/GoldenStepDetailsDialog.svelte b/src/lib/components/GoldenStepDetailsDialog.svelte new file mode 100644 index 0000000..7760617 --- /dev/null +++ b/src/lib/components/GoldenStepDetailsDialog.svelte @@ -0,0 +1,107 @@ + + + + + + +
+
+ جزئیات پیشنهاد پذیرفته‌شده + + متن کامل پیشنهاد و وضعیت فعلی قدم زرین + +
+ + {#snippet child({ props })} + + {/snippet} + +
+ + {#if action} +
+
+
+ + {action.actionType === 'METRIC' ? 'سنجش خودکار' : 'انجام دستی'} + + وضعیت: {statusLabel(action.status)} + {#if recommendation} + تلاش {effortLabel(recommendation.effort)} + {/if} +
+

+

مهلت: {formatDateOnly(action.deadline)}

+
+ + {#if recommendation} +
+

چرا این پیشنهاد ارائه شد؟

+

+ +

+
+
+ اثر مورد انتظار: + +
+ {/if} + + {#if action.actionType === 'METRIC'} +
+
+ خط پایه{metricValue(action.baseline)} +
+
+ مقدار فعلی{metricValue(action.currentValue)} +
+
+ هدف قطعی{metricValue(action.target)} +
+
+ {/if} +
+ {/if} +
+
+
diff --git a/src/lib/components/GoldenStepDetailsDialog.test.ts b/src/lib/components/GoldenStepDetailsDialog.test.ts new file mode 100644 index 0000000..3e94a16 --- /dev/null +++ b/src/lib/components/GoldenStepDetailsDialog.test.ts @@ -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(); + }); +}); diff --git a/src/lib/components/GrowthAIText.svelte b/src/lib/components/GrowthAIText.svelte new file mode 100644 index 0000000..3636de5 --- /dev/null +++ b/src/lib/components/GrowthAIText.svelte @@ -0,0 +1,12 @@ + + +{#each parts as part}{#if part.href}{part.text}{:else}{part.text}{/if}{/each} diff --git a/src/lib/components/GrowthEventImpactChart.svelte b/src/lib/components/GrowthEventImpactChart.svelte new file mode 100644 index 0000000..c78d6d3 --- /dev/null +++ b/src/lib/components/GrowthEventImpactChart.svelte @@ -0,0 +1,57 @@ + + +{#if visibleEvents.length} +
+ {#each visibleEvents as event (event.evidenceId)} +
+

رویداد پیش رو

+

{event.eventTitleFa}

+

{formatDateOnly(event.date)}

+

{event.relevanceReason}

+
+ {/each} +
+{:else} +
+

رویداد مرتبطی در تقویم بررسی‌شده پیدا نشد

+

با به‌روزرسانی تقویم، این بخش دوباره بررسی می‌شود.

+
+{/if} diff --git a/src/lib/components/GrowthEventImpactChart.test.ts b/src/lib/components/GrowthEventImpactChart.test.ts new file mode 100644 index 0000000..6aa9c1a --- /dev/null +++ b/src/lib/components/GrowthEventImpactChart.test.ts @@ -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(); + }); +}); diff --git a/src/lib/components/GrowthPage.svelte b/src/lib/components/GrowthPage.svelte index 60f87e5..5060722 100644 --- a/src/lib/components/GrowthPage.svelte +++ b/src/lib/components/GrowthPage.svelte @@ -1,303 +1,888 @@ -تحلیل‌گر رشد | تحلیل زرین‌پال - - - - - -{#if !growthFeatureEnabled} -

تحلیل‌گر رشد هنوز فعال نشده است

-

- پس از آماده‌شدن سرویس تحلیل، این بخش برای حساب شما نمایش داده می‌شود. -

-{:else if (loading && !visibleReport) || displayMode === 'loading'} - مربی رشد | تحلیل زرین‌پال +
+ -{:else if displayMode === 'error' || !report || !visibleReport || !evidence} - -

{message}

-
-{:else} -
- - -
-

افق گزارش: {visibleReport.horizonDays} روز پس از آخرین داده کامل

-

- {visibleReport.cached - ? 'این تحلیل از نسخه ذخیره‌شده نمایش داده می‌شود.' - : 'نسخه تازه تحلیل در حال آماده‌شدن است.'} -

-
-
- - - {refreshAvailabilityLabel( - visibleReport.status === 'READY' && Boolean(visibleReport.aiCopy?.briefs.length), - formatDate(report.nextRefreshAt), - )} - -
-
-
- {#if message}
- {message} -
{/if} + {#if loading && !coachDashboard}{/if} - {#if report.status === 'PARTIAL'} -
- اعداد و پیش‌بینی‌ها آماده‌اند، اما توضیح نوشتاری تازه در دسترس نبود. داده‌های قابل اتکا همچنان نمایش - داده می‌شوند. + {#if message}
+ {message} +
{/if} + + {#if loading && !coachDashboard} + + {:else} + +

{message || 'مربی رشد در دسترس نیست.'}

+
+ {/if} +
diff --git a/src/lib/components/GrowthTeaser.svelte b/src/lib/components/GrowthTeaser.svelte index c70fab3..9424b27 100644 --- a/src/lib/components/GrowthTeaser.svelte +++ b/src/lib/components/GrowthTeaser.svelte @@ -17,7 +17,7 @@
-

تحلیل‌گر رشد

+

مربی رشد

فرصت‌های پیش رو و دوره‌های کم‌رونق احتمالی را با تکیه بر داده‌های خودتان ببینید.

diff --git a/src/lib/components/GrowthTeaser.test.ts b/src/lib/components/GrowthTeaser.test.ts index 18a72c4..b977074 100644 --- a/src/lib/components/GrowthTeaser.test.ts +++ b/src/lib/components/GrowthTeaser.test.ts @@ -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(); }); }); diff --git a/src/lib/components/TagInput.svelte b/src/lib/components/TagInput.svelte new file mode 100644 index 0000000..8616029 --- /dev/null +++ b/src/lib/components/TagInput.svelte @@ -0,0 +1,83 @@ + + +
+
+ {#each value as tag (tag)} + + {tag} + + + {/each} + +
+

با فاصله، Enter یا ویرگول جدا کنید.

+
+ پرکاربرد: + {#each suggestions as suggestion} + + {/each} +
+
diff --git a/src/lib/components/TagInput.test.ts b/src/lib/components/TagInput.test.ts new file mode 100644 index 0000000..b03860f --- /dev/null +++ b/src/lib/components/TagInput.test.ts @@ -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); + }); +}); diff --git a/src/lib/components/TransactionStatusChart.svelte b/src/lib/components/TransactionStatusChart.svelte new file mode 100644 index 0000000..134a101 --- /dev/null +++ b/src/lib/components/TransactionStatusChart.svelte @@ -0,0 +1,107 @@ + + +
+ +
+
+ {#each ordered as entry} +
+
+ {#if active === entry.status} + + {/if} + +
+
+ {formatValue(entry.count)} + + {statusLabels[entry.status]} + +
+
+ {/each} +
+
+ +
+ + جدول داده‌های نمودار وضعیت + +
+ + + + + + + {#each ordered as entry} + + + + + + {/each} + +
تعداد و سهم تراکنش‌ها به تفکیک وضعیت
وضعیتتعدادسهم از کل
{statusLabels[entry.status]}{formatValue(entry.count)}{formatPercent(entry.sharePercent)}
+
+
+
diff --git a/src/lib/components/TransactionStatusChart.test.ts b/src/lib/components/TransactionStatusChart.test.ts new file mode 100644 index 0000000..9c0a8d2 --- /dev/null +++ b/src/lib/components/TransactionStatusChart.test.ts @@ -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(); + }); +}); diff --git a/src/lib/components/TransactionStatusTrendChart.svelte b/src/lib/components/TransactionStatusTrendChart.svelte new file mode 100644 index 0000000..6b78d7a --- /dev/null +++ b/src/lib/components/TransactionStatusTrendChart.svelte @@ -0,0 +1,138 @@ + + +
+
+ {#each statusOrder as status} + {statusLabels[status]} + {/each} +
+ + +
+
+ {#if data.length === 0} +
داده‌ای برای این بازه ثبت نشده است.
+ {:else} + + {#each [0, 1, 2, 3, 4] as row} + {@const y = pad.top + (row * plotHeight) / 4} + + {formatValue(Math.round(maxCount * (1 - row / 4)))} + {/each} + + + {#each xTickIndexes as tickIndex} + {@const tick = series[0]?.points[tickIndex]} + {#if tick}{bucketLabel(tick.point)}{/if} + {/each} + {periodType === 'DAY' ? 'ساعت' : 'زمان'} + تعداد تراکنش + + {#each series as statusSeries} + + {#each statusSeries.points as point} + (active = { bucket: point.bucket, status: statusSeries.status })} onmouseleave={() => (active = null)} onfocus={() => (active = { bucket: point.bucket, status: statusSeries.status })} onblur={() => (active = null)} /> + {/each} + {/each} + + + {#if active !== null && activePoint} + + {/if} + {/if} +
+
+ +
+ جدول داده‌های روند وضعیت +
+ + + {#each statusOrder as status}{/each} + + {#each data as point} + {#each statusOrder as status}{@const count = countFor(point, status)}{/each} + {/each} + +
تعداد تراکنش‌ها به تفکیک زمان و وضعیت
زمان{statusLabels[status]}کل
{bucketLabel(point)}{formatValue(count)} ({formatPercent(share(point, count))}){formatValue(point.totalSessions)}
+
+
+
+ + diff --git a/src/lib/components/TransactionStatusTrendChart.test.ts b/src/lib/components/TransactionStatusTrendChart.test.ts new file mode 100644 index 0000000..0d20f99 --- /dev/null +++ b/src/lib/components/TransactionStatusTrendChart.test.ts @@ -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); + }); +}); diff --git a/src/lib/components/TransactionsPage.svelte b/src/lib/components/TransactionsPage.svelte index 3d908c6..94a8a9b 100644 --- a/src/lib/components/TransactionsPage.svelte +++ b/src/lib/components/TransactionsPage.svelte @@ -1,201 +1,181 @@ تراکنش‌ها | تحلیل زرین‌پال + - +{#if loading} +
+ {#each Array(4) as _}{/each} +
+
+ +
+{:else if error} + + +

{error}

+ +
+
+{:else if run} + {@const transactions = run.sections.transactions} + {#if transactions.state === 'READY'} +
+ + + + +
- - - {#if loading} -
-
- {#each Array(7) as _}{/each} -
- {#each Array(8) as _, index} -
- - - - - - - + + +
+
+ توزیع وضعیت تراکنش‌ها +

+ ارتفاع هر ستون، تعداد مطلق تراکنش‌ها را نشان می‌دهد. واحد: تراکنش؛ سهم هر وضعیت از کل بازه + محاسبه شده است. +

- {/each} -
- {:else if error} -
-
-

{error}

- + {run.period.jalaliLabelFa}
-
- {:else if result && result.items.length} -
- - شناسه پرداختدرگاهزمانمبلغتلاش‌هاوضعیتجزئیات + + {#if transactions.data.totalSessions.value === 0}

- - {#each result.items as item} - - {item.sessionId} - {item.terminalId} - {formatDate(item.createdAt)} - {formatMoney(item.amountRial)} - {formatValue(item.attemptCount)} - {statusLabels[item.sessionStatus]} - - - {/each} - - -

-
- {formatValue(result.items.length)} مورد در این صفحه -
- {:else} -
-
- -

تراکنشی با این فیلتر پیدا نشد

-

بازه یا فیلتر مبلغ و وضعیت را تغییر دهید.

+ در این بازه تراکنشی ثبت نشده است؛ ستون‌ها با مقدار صفر نمایش داده می‌شوند. +

{/if} + + + + + + +
+
+ روند ترکیب وضعیت‌ها +

+ هر خط، تغییر تعداد یک وضعیت را در طول زمان نشان می‌دهد؛ جزئیات تعداد و سهم هر نقطه با + تمرکز صفحه‌کلید یا اشاره‌گر در دسترس است. +

+
+ {run.period.jalaliLabelFa}
-
- {/if} - - + + + + {:else if transactions.state === 'FAILED'} + بخش تحلیل تراکنش‌ها آماده نشد. + {:else} + تحلیل وضعیت تراکنش‌ها در حال آماده‌سازی است. + {/if} +{/if} + + diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 23d6c3b..e0d6a92 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -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}/); + }); }); diff --git a/src/lib/format.ts b/src/lib/format.ts index a75a13a..30d84fd 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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)); +} diff --git a/src/lib/growth-ai-text.test.ts b/src/lib/growth-ai-text.test.ts new file mode 100644 index 0000000..636733a --- /dev/null +++ b/src/lib/growth-ai-text.test.ts @@ -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' }, + ]); + }); +}); diff --git a/src/lib/growth-ai-text.ts b/src/lib/growth-ai-text.ts new file mode 100644 index 0000000..f4b9352 --- /dev/null +++ b/src/lib/growth-ai-text.ts @@ -0,0 +1,41 @@ +export type GrowthAITextPart = { + text: string; + href?: string; +}; + +const internalTerms: Record = { + 'تعداد و مبلغ پرداخت موفق': { 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()}` }; + }); +} diff --git a/src/lib/growth-coach-schedule.test.ts b/src/lib/growth-coach-schedule.test.ts new file mode 100644 index 0000000..8f6480d --- /dev/null +++ b/src/lib/growth-coach-schedule.test.ts @@ -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); + }); +}); diff --git a/src/lib/growth-coach-schedule.ts b/src/lib/growth-coach-schedule.ts new file mode 100644 index 0000000..301a450 --- /dev/null +++ b/src/lib/growth-coach-schedule.ts @@ -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()); +} diff --git a/src/lib/growth-feature.ts b/src/lib/growth-feature.ts index 5c24641..195399c 100644 --- a/src/lib/growth-feature.ts +++ b/src/lib/growth-feature.ts @@ -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'; diff --git a/src/lib/transaction-analytics.test.ts b/src/lib/transaction-analytics.test.ts new file mode 100644 index 0000000..3faa500 --- /dev/null +++ b/src/lib/transaction-analytics.test.ts @@ -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); + }); +}); diff --git a/src/lib/transaction-analytics.ts b/src/lib/transaction-analytics.ts new file mode 100644 index 0000000..65a54aa --- /dev/null +++ b/src/lib/transaction-analytics.ts @@ -0,0 +1,51 @@ +export const statusOrder = ['Paid', 'Verified', 'Failed', 'Reversed'] as const; +export type TransactionStatus = (typeof statusOrder)[number]; + +export const statusLabels: Record = { + 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; + }); +}