Compare commits

..

7 Commits

30 changed files with 3059 additions and 1417 deletions

View File

@@ -1,2 +1,4 @@
# Same-origin by default. Vite proxies /api to http://127.0.0.1:8080 locally.
PUBLIC_API_BASE_URL=/api/v1
# Keep disabled until the backend growth-report capability and budgets are configured.
PUBLIC_AI_GROWTH_ENABLED=false

View File

@@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti
| Version | Date | Decision |
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |

View File

@@ -0,0 +1,31 @@
# AI Growth Analyst UX Spec
- Status: Approved — implementation authorized 2026-08-21
- Route: `/growth`
- Language/direction: Persian, RTL
## Experience
- Add `تحلیل‌گر رشد` to the authenticated sidebar and a compact dashboard teaser linking to it.
- Reuse the global terminal selector. Merchant scope may show peers; terminal scope explains that peer comparison is unavailable.
- Keep the previous successful report visible during refresh. Initial and uncached states use visible light/dark skeletons; no state renders an empty page.
- Header states the 90-day horizon starts after the latest complete dataset date. A daily `به‌روزرسانی تحلیل` action shows `nextRefreshAt`, loading, and typed rate-limit feedback.
- Render a responsive, keyboard-accessible forecast chart with count/amount mode, hover/focus points, P10–P90 band, axes/units, chart summary, and a non-visual data alternative.
- Show historical wins, up to five upcoming opportunities, up to three gentle low-period warnings, and a peer card when permitted.
- Each brief contains server-rendered timing/range/evidence plus validated AI headline, interpretation, offer/message suggestion, one preparation step, confidence, and limitation.
- `PARTIAL` keeps deterministic charts/cards and explains that the written AI interpretation was unavailable. `INSUFFICIENT_DATA` explains the minimum evidence without presenting a forecast. `FAILED` offers safe retry without hiding an older successful report.
- Motion is restrained, supports reduced motion, and does not block reading. All interactive targets are at least 44 px.
## Copy rules
- Use natural professional Persian, Persian `ی`/`ک`, appropriate نیم‌فاصله, Persian digits, and Rial.
- Keep tone constructive: describe opportunity, preparation, and uncertainty; do not shame the merchant or claim guaranteed outcomes.
- Every recommendation visibly cites its deterministic evidence card. AI is labeled advisory.
- No chat, notifications, campaign execution, user memory, provider/model names, token usage, or internal failure details appear in merchant UI.
## Acceptance
- Works at 360, 768, 1024, and wide desktop widths in light/dark RTL layouts.
- All job, cache, refresh, partial, insufficient, unauthorized, and recoverable error states are accessible and tested.
- Generated OpenAPI types are used; no handwritten duplicate growth DTOs or browser secrets exist.
- Cached navigation performs no generation request; manual refresh is explicit and limited once per merchant per Tehran day.

View File

@@ -1,4 +1,4 @@
import type { components } from './schema';
import type { components, operations } from './schema';
export type AuthSession = components['schemas']['AuthSession'];
export type ReportingPeriod = components['schemas']['ReportingPeriod'];
@@ -11,6 +11,9 @@ export type SessionStatus = components['schemas']['SessionStatus'];
export type DetailView = components['schemas']['DetailView'];
export type FailureStage = components['schemas']['FailureStage'];
export type Problem = components['schemas']['Problem'];
export type GrowthReport = components['schemas']['GrowthReport'];
export type CreateGrowthReportRequest =
operations['createGrowthReport']['requestBody']['content']['application/json'];
const apiBase = (import.meta.env.PUBLIC_API_BASE_URL || '/api/v1').replace(/\/$/, '');
@@ -119,6 +122,23 @@ export const api = {
sessionDetail(sessionId: string) {
return request<SessionDetail>(`/analytics/sessions/${encodeURIComponent(sessionId)}`);
},
latestGrowthReport(terminalId?: string) {
const query = terminalId ? `?terminalId=${encodeURIComponent(terminalId)}` : '';
return request<GrowthReport>(`/analytics/growth-reports/latest${query}`);
},
createGrowthReport(csrfToken: string, options: CreateGrowthReportRequest = {}) {
return request<GrowthReport>('/analytics/growth-reports', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(options),
});
},
growthReport(reportId: string) {
return request<GrowthReport>(`/analytics/growth-reports/${encodeURIComponent(reportId)}`);
},
exportUrl(
periodId: string,
view: DetailView,

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from './client';
const report = {
reportId: 'report-1',
status: 'QUEUED',
scope: { terminalId: null },
horizonDays: 90,
deterministicEvidence: {},
cached: false,
stale: false,
nextRefreshAt: '2026-08-22T00:00:00Z',
createdAt: '2026-08-21T00:00:00Z',
updatedAt: '2026-08-21T00:00:00Z',
eventsUrl: '/api/v1/analytics/growth-reports/report-1/events',
};
afterEach(() => vi.unstubAllGlobals());
describe('growth report API client', () => {
it('reads the cached terminal report without creating a new report', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(report), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await api.latestGrowthReport('terminal-1');
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/analytics/growth-reports/latest?terminalId=terminal-1');
expect(fetchMock.mock.calls[0][1].method).toBeUndefined();
});
it('marks manual refresh explicitly and sends the authenticated write headers', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(report), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'idempotency-1' });
await api.createGrowthReport('csrf-1', { terminalId: 'terminal-1', refresh: true });
const init = fetchMock.mock.calls[0][1];
expect(init.method).toBe('POST');
expect(init.headers['X-CSRF-Token']).toBe('csrf-1');
expect(init.headers['Idempotency-Key']).toBe('idempotency-1');
expect(JSON.parse(init.body)).toEqual({ terminalId: 'terminal-1', refresh: true });
});
it('creates a report with an empty request when optional fields are omitted', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(report), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('crypto', { randomUUID: () => 'idempotency-2' });
await api.createGrowthReport('csrf-2');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({});
});
});

3236
src/lib/api/schema.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,9 @@
retryOpportunity,
} from '$lib/insights';
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import { growthFeatureEnabled } from '$lib/growth-feature';
import KpiCard from './KpiCard.svelte';
import GrowthTeaser from './GrowthTeaser.svelte';
import FeeShareDonut from './FeeShareDonut.svelte';
import PageHeader from './PageHeader.svelte';
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
@@ -204,6 +206,7 @@
</div>
</CardContent>
</Card>
{#if growthFeatureEnabled}<GrowthTeaser href={`/growth${page.url.search}`} />{/if}
{:else if kind === 'payments' && performance.state === 'READY'}
{@const retryRate = retryEngagementRate(
performance.data.retriedSessions.value,

View File

@@ -9,6 +9,7 @@
import MoonIcon from '@lucide/svelte/icons/moon';
import PanelsTopLeftIcon from '@lucide/svelte/icons/panels-top-left';
import SunIcon from '@lucide/svelte/icons/sun';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
@@ -18,6 +19,7 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { setDashboardQuery } from '$lib/dashboard-filters';
import { growthFeatureEnabled } from '$lib/growth-feature';
let { children } = $props();
const dashboard = useDashboardContext();
@@ -29,6 +31,7 @@
{ href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon },
{ href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon },
{ href: '/peers', label: 'هم‌گروه‌ها', icon: UsersRoundIcon },
...(growthFeatureEnabled ? [{ href: '/growth', label: 'تحلیل‌گر رشد', icon: TrendingUpIcon }] : []),
];
function navUrl(path: string) {

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { buildForecastChart, formatConfidence } from '$lib/growth-chart';
import { formatMoney, formatValue } from '$lib/format';
import type { components } from '$lib/api/schema';
type ForecastPoint = components['schemas']['GrowthForecastPoint'];
let { points, confidence, startDate }: { points: ForecastPoint[]; confidence: string; startDate: string } =
$props();
let metric = $state<'count' | 'amount'>('count');
let active = $state<number | null>(null);
const width = 820;
const height = 320;
const chartData = $derived(
points.map((point) => ({
localDate: point.date,
...(metric === 'count' ? point.paidSessions : point.paidAmountRial),
})),
);
const chart = $derived(buildForecastChart(chartData, width, height));
const selected = $derived(active === null ? undefined : chart.points[active]);
const xTicks = $derived([
...new Set(
Array.from({ length: Math.min(7, points.length) }, (_, index) =>
Math.round((index * (points.length - 1)) / Math.max(1, Math.min(7, points.length) - 1)),
),
),
]);
function dateLabel(value: string) {
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
month: 'short',
day: 'numeric',
timeZone: 'Asia/Tehran',
}).format(new Date(value));
}
function valueLabel(value: number) {
return metric === 'count' ? `${formatValue(value)} پرداخت` : formatMoney(value);
}
function onChartKeydown(event: KeyboardEvent) {
const current = active ?? 0;
if (event.key === 'ArrowLeft') active = Math.min(chart.points.length - 1, current + 1);
else if (event.key === 'ArrowRight') active = Math.max(0, current - 1);
else if (event.key === 'Home') active = 0;
else if (event.key === 'End') active = chart.points.length - 1;
else return;
event.preventDefault();
}
</script>
<Card class="overflow-hidden rounded-lg shadow-xs">
<CardHeader class="border-b pb-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2 text-base">
<ChartNoAxesCombinedIcon class="text-primary size-5" /> چشم‌انداز ۹۰ روز آینده
</CardTitle>
<p class="text-muted-foreground mt-1 text-xs">
خط میانی برآورد معمول و ناحیه کم‌رنگ بازه محتمل را نشان می‌دهد.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary" class="rounded-md">{formatConfidence(confidence)}</Badge>
<div class="bg-muted flex rounded-md p-1" aria-label="شاخص نمودار">
<Button
variant={metric === 'count' ? 'default' : 'ghost'}
size="sm"
class="min-h-9 rounded-sm px-3 text-xs"
aria-pressed={metric === 'count'}
onclick={() => (metric = 'count')}>تعداد</Button
>
<Button
variant={metric === 'amount' ? 'default' : 'ghost'}
size="sm"
class="min-h-9 rounded-sm px-3 text-xs"
aria-pressed={metric === 'amount'}
onclick={() => (metric = 'amount')}>مبلغ</Button
>
</div>
</div>
</div>
</CardHeader>
<CardContent class="px-2 pt-5 sm:px-5">
{#if chart.points.length}
<div
class="relative overflow-hidden"
dir="ltr"
role="slider"
tabindex="0"
aria-label={`نمودار پیش‌بینی ${metric === 'count' ? 'تعداد پرداخت' : 'مبلغ پرداخت'}؛ برای جابه‌جایی میان روزها از کلیدهای جهت‌دار استفاده کنید.`}
aria-valuemin="1"
aria-valuemax={chart.points.length}
aria-valuenow={(active ?? 0) + 1}
aria-valuetext={active === null
? 'برای بررسی روزها کلید جهت‌دار را بزنید'
: dateLabel(chart.points[active].localDate)}
onkeydown={onChartKeydown}
onfocus={() => (active = active ?? 0)}
onblur={() => (active = null)}
>
<svg viewBox={`0 0 ${width} ${height}`} class="h-auto min-h-72 w-full" aria-hidden="true">
{#each [0, 1, 2, 3, 4] as row}
{@const y = chart.padding.top + (row * (height - chart.padding.top - chart.padding.bottom)) / 4}
<line
x1={chart.padding.left}
x2={width - chart.padding.right}
y1={y}
y2={y}
stroke="var(--border)"
stroke-dasharray="4 6"
/>
<text
x={chart.padding.left - 10}
y={y + 4}
fill="var(--muted-foreground)"
font-size="10"
text-anchor="end"
>
{formatValue(Math.round(chart.max * (1 - row / 4)))}
</text>
{/each}
<line
x1={chart.padding.left}
x2={chart.padding.left}
y1={chart.padding.top}
y2={height - chart.padding.bottom}
stroke="var(--border)"
/>
<line
x1={chart.padding.left}
x2={width - chart.padding.right}
y1={height - chart.padding.bottom}
y2={height - chart.padding.bottom}
stroke="var(--border)"
/>
{#each xTicks as index}
{@const point = chart.points[index]}
<text
x={point.x}
y={height - chart.padding.bottom + 19}
fill="var(--muted-foreground)"
font-size="9"
text-anchor="middle"
direction="rtl"
>
{dateLabel(point.localDate)}
</text>
{/each}
<text
x={(chart.padding.left + width - chart.padding.right) / 2}
y={height - 5}
fill="var(--muted-foreground)"
font-size="10"
text-anchor="middle"
direction="rtl">تاریخ</text
>
<text
x={-((chart.padding.top + height - chart.padding.bottom) / 2)}
y="14"
fill="var(--muted-foreground)"
font-size="10"
text-anchor="middle"
transform="rotate(-90)"
direction="rtl"
>
{metric === 'count' ? 'تعداد پرداخت' : 'مبلغ (ریال)'}
</text>
<path d={chart.bandPath} fill="var(--primary)" fill-opacity=".12" />
<path
d={chart.medianPath}
fill="none"
stroke="var(--primary)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="forecast-line"
/>
{#each chart.points as point, index}
<circle
cx={point.x}
cy={point.medianY}
r={active === index ? 5 : 2.5}
fill="var(--card)"
stroke="var(--primary)"
stroke-width="2.5"
role="presentation"
onmouseenter={() => (active = index)}
onmouseleave={() => (active = null)}
/>
{/each}
</svg>
{#if selected}
<div
class="bg-popover text-popover-foreground pointer-events-none absolute bottom-14 left-1/2 z-10 w-48 -translate-x-1/2 rounded-md border p-3 text-right text-xs shadow-lg transition-opacity duration-150 motion-reduce:transition-none sm:top-4 sm:bottom-auto sm:left-24 sm:translate-x-0"
dir="rtl"
aria-live="polite"
>
<strong class="block">{dateLabel(selected.localDate)}</strong>
<span class="mt-1 block">برآورد معمول: {valueLabel(selected.p50)}</span>
<span class="text-muted-foreground block"
>بازه محتمل: {valueLabel(selected.p10)} تا {valueLabel(selected.p90)}</span
>
</div>
{/if}
</div>
<p class="text-muted-foreground px-3 pb-4 text-xs">
شروع پیش‌بینی: {dateLabel(startDate)}. این برآورد قطعی نیست و بر پایه الگوی داده‌های کامل واردشده است.
</p>
<details class="border-t px-3 py-3 text-xs">
<summary class="font-semibold">جدول داده‌های نمودار</summary>
<div class="mt-3 max-h-72 overflow-auto">
<table class="w-full min-w-[34rem] text-right">
<thead
><tr class="text-muted-foreground"
><th class="p-2">تاریخ</th><th class="p-2">کمینه محتمل</th><th class="p-2">برآورد معمول</th
><th class="p-2">بیشینه محتمل</th></tr
></thead
>
<tbody>
{#each chart.points as point}
<tr class="border-t"
><td class="p-2">{dateLabel(point.localDate)}</td><td class="p-2"
>{valueLabel(point.p10)}</td
><td class="p-2 font-bold">{valueLabel(point.p50)}</td><td class="p-2"
>{valueLabel(point.p90)}</td
></tr
>
{/each}
</tbody>
</table>
</div>
</details>
{:else}
<div class="text-muted-foreground grid min-h-72 place-items-center text-sm" role="status">
برای این بازه پیش‌بینی قابل اتکایی آماده نشده است.
</div>
{/if}
</CardContent>
</Card>
<style>
.forecast-line {
stroke-dasharray: 2200;
stroke-dashoffset: 2200;
animation: draw-forecast 800ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes draw-forecast {
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import GrowthForecastChart from './GrowthForecastChart.svelte';
const points = [
{
date: '2026-07-01T00:00:00Z',
baselineSessions: 95,
baselineAmountRial: 9_500_000,
paidSessions: { p10: 80, p50: 100, p90: 130 },
paidAmountRial: { p10: 8_000_000, p50: 10_000_000, p90: 13_000_000 },
},
{
date: '2026-07-02T00:00:00Z',
baselineSessions: 110,
baselineAmountRial: 11_000_000,
paidSessions: { p10: 90, p50: 120, p90: 150 },
paidAmountRial: { p10: 9_000_000, p50: 12_000_000, p90: 15_000_000 },
},
];
describe('GrowthForecastChart', () => {
it('offers an accessible table alternative and amount mode', async () => {
render(GrowthForecastChart, {
points,
confidence: 'MEDIUM',
startDate: '2026-07-01T00:00:00Z',
});
expect(screen.getByText('جدول داده‌های نمودار')).not.toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'مبلغ' }));
expect(screen.getAllByText(/ریال/).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import CalendarHeartIcon from '@lucide/svelte/icons/calendar-heart';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import LightbulbIcon from '@lucide/svelte/icons/lightbulb';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import TrophyIcon from '@lucide/svelte/icons/trophy';
import UsersRoundIcon from '@lucide/svelte/icons/users-round';
import { Badge } from '$lib/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { formatMoney, formatPercent, formatValue } from '$lib/format';
import type { components } from '$lib/api/schema';
type Win = components['schemas']['GrowthHistoricalWin'];
type Opportunity = components['schemas']['GrowthOpportunity'];
type Risk = components['schemas']['GrowthRisk'];
type Brief = components['schemas']['GrowthCampaignBrief'];
type Peer = components['schemas']['GrowthPeer'];
let {
wins = [],
opportunities = [],
risks = [],
briefs = [],
peer,
terminalScoped = false,
}: {
wins?: Win[];
opportunities?: Opportunity[];
risks?: Risk[];
briefs?: Brief[];
peer?: Peer | null;
terminalScoped?: boolean;
} = $props();
function dateLabel(value: string) {
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
dateStyle: 'medium',
timeZone: 'Asia/Tehran',
}).format(new Date(value));
}
function bps(value: number) {
return formatPercent(String(value / 100));
}
</script>
<div class="grid gap-4 xl:grid-cols-[1.25fr_.75fr]">
<div class="space-y-4">
<Card class="rounded-lg shadow-xs">
<CardHeader
><CardTitle class="flex items-center gap-2 text-base"
><CalendarHeartIcon class="text-primary size-5" /> فرصت‌های پیش رو</CardTitle
></CardHeader
>
<CardContent class="grid gap-3 md:grid-cols-2">
{#each opportunities.slice(0, 5) as opportunity, index}
<article id={`evidence-${opportunity.evidenceId}`} class="bg-muted/55 rounded-md border p-4">
<div class="flex items-start justify-between gap-3">
<div>
<span class="text-muted-foreground text-xs">فرصت {formatValue(index + 1)}</span>
<h3 class="mt-1 font-bold">{opportunity.eventTitleFa}</h3>
</div>
<Badge variant="secondary" class="shrink-0 rounded-md"
>شباهت {bps(opportunity.similarityBps)}</Badge
>
</div>
<p class="text-muted-foreground mt-3 text-xs">{dateLabel(opportunity.date)}</p>
<p class="mt-2 text-sm leading-7">
برآورد معمول <strong>{formatValue(opportunity.expectedSessions.p50)} پرداخت</strong> به ارزش
<strong>{formatMoney(opportunity.expectedAmountRial.p50)}</strong> است.
</p>
</article>
{:else}
<p class="text-muted-foreground md:col-span-2 text-sm">
در افق فعلی، فرصت رویدادی معناداری پیدا نشد.
</p>
{/each}
</CardContent>
</Card>
{#if briefs.length}
<section aria-labelledby="campaign-briefs-heading">
<h2 id="campaign-briefs-heading" class="mb-3 flex items-center gap-2 text-lg font-bold">
<SparklesIcon class="text-primary size-5" /> پیشنهادهای آماده‌سازی
</h2>
<div class="grid gap-3 md:grid-cols-2">
{#each briefs as brief}
<Card class="rounded-lg shadow-xs">
<CardContent class="p-5">
<h3 class="font-bold">{brief.headline}</h3>
<p class="text-muted-foreground mt-2 text-sm leading-7">{brief.interpretation}</p>
<dl class="mt-4 grid gap-3 text-sm">
<div>
<dt class="font-semibold">پیشنهاد فروش</dt>
<dd class="text-muted-foreground mt-1">{brief.offerSuggestion}</dd>
</div>
<div>
<dt class="font-semibold">پیام پیشنهادی</dt>
<dd class="text-muted-foreground mt-1">{brief.messageSuggestion}</dd>
</div>
<div>
<dt class="font-semibold">قدم آماده‌سازی</dt>
<dd class="text-muted-foreground mt-1">{brief.preparationAction}</dd>
</div>
</dl>
<p class="text-muted-foreground mt-4 border-t pt-3 text-xs">{brief.limitation}</p>
<div class="mt-3 flex flex-wrap gap-2">
{#each brief.evidenceIds as evidenceId}<a
class="text-primary text-xs font-semibold underline-offset-4 hover:underline"
href={`#evidence-${evidenceId}`}>مشاهده شاهد</a
>{/each}
</div>
</CardContent>
</Card>
{/each}
</div>
<p class="text-muted-foreground mt-3 text-xs">
این پیشنهادها نقش راهنما دارند؛ تصمیم نهایی را با شرایط کسب‌وکارتان هماهنگ کنید.
</p>
</section>
{/if}
</div>
<div class="space-y-4">
<Card class="rounded-lg shadow-xs">
<CardHeader
><CardTitle class="flex items-center gap-2 text-base"
><TrophyIcon class="size-5 text-[var(--warning)]" /> تجربه‌های موفق گذشته</CardTitle
></CardHeader
>
<CardContent class="space-y-3">
{#each wins as win}
<article id={`evidence-${win.evidenceId}`} class="border-b pb-3 last:border-0 last:pb-0">
<h3 class="font-semibold">{win.eventTitleFa}</h3>
<p class="text-muted-foreground mt-1 text-xs">{dateLabel(win.startDate)}</p>
<p class="mt-2 text-sm">
تعداد پرداخت <strong class="text-[var(--success)]"
>{bps(win.paidSessionUpliftBps ?? 0)} بیشتر</strong
>
و مبلغ
<strong>{bps(win.amountUpliftBps ?? 0)} بیشتر</strong> از روزهای مشابه بوده است.
</p>
</article>
{:else}<p class="text-muted-foreground text-sm">
هنوز رویداد موفقی با شواهد کافی ثبت نشده است.
</p>{/each}
</CardContent>
</Card>
<Card class="rounded-lg border-amber-300/60 shadow-xs dark:border-amber-800/70">
<CardHeader
><CardTitle class="flex items-center gap-2 text-base"
><CircleAlertIcon class="size-5 text-[var(--warning)]" /> دوره‌های نیازمند آمادگی</CardTitle
></CardHeader
>
<CardContent class="space-y-3">
{#each risks.slice(0, 3) as risk}
<article
id={`evidence-${risk.evidenceId}`}
class="rounded-md border bg-amber-50/50 p-4 dark:bg-amber-950/15"
>
<h3 class="font-semibold">{dateLabel(risk.date)}</h3>
<p class="text-muted-foreground mt-2 text-sm leading-7">
ممکن است تعداد پرداخت حدود {bps(risk.sessionDeclineBps)} کمتر از روند معمول باشد. برآورد میانی این
روز {formatValue(risk.expectedSessions.p50)} پرداخت است.
</p>
</article>
{:else}<p class="text-muted-foreground text-sm">افت معناداری برای هشدار پیدا نشد.</p>{/each}
</CardContent>
</Card>
<Card class="rounded-lg shadow-xs">
<CardHeader
><CardTitle class="flex items-center gap-2 text-base"
><UsersRoundIcon class="text-primary size-5" /> مقایسه امن با هم‌گروه‌ها</CardTitle
></CardHeader
>
<CardContent>
{#if terminalScoped}
<p class="text-muted-foreground text-sm leading-7">
برای حفظ معنای مقایسه، جایگاه هم‌گروهی فقط در سطح کل پذیرنده نمایش داده می‌شود و برای یک درگاه
محاسبه نمی‌شود.
</p>
{:else if peer?.state === 'READY'}
<p class="text-sm leading-7">
این مقایسه با {formatValue(peer.eligiblePeerCount ?? 0)} کسب‌وکار واجد شرایط و بدون نمایش اطلاعات هیچ
پذیرنده‌ای ساخته شده است.
</p>
<div class="bg-muted mt-4 h-2 overflow-hidden rounded-sm" dir="ltr">
<div
class="bg-primary h-full rounded-sm"
style={`width:${Math.min(100, (peer.percentileBps ?? 0) / 100)}%`}
></div>
</div>
<p class="text-muted-foreground mt-2 text-xs">
جایگاه شما: صدک {formatValue(Math.round((peer.percentileBps ?? 0) / 100))}
</p>
{:else}
<p class="text-muted-foreground text-sm leading-7">
برای یک مقایسه محرمانه و قابل اتکا، تعداد هم‌گروه‌های واجد شرایط هنوز کافی نیست.
</p>
{/if}
</CardContent>
</Card>
</div>
</div>

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { page } from '$app/state';
import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit';
import CircleAlertIcon from '@lucide/svelte/icons/circle-alert';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { onDestroy } from 'svelte';
import { ApiError, api, type GrowthReport } from '$lib/api/client';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { growthFeatureEnabled } from '$lib/growth-feature';
import { reportDisplayMode } from '$lib/growth-report-state';
import { formatDate } from '$lib/format';
import GrowthForecastChart from './GrowthForecastChart.svelte';
import GrowthInsights from './GrowthInsights.svelte';
import GrowthReportSkeleton from './GrowthReportSkeleton.svelte';
import PageHeader from './PageHeader.svelte';
const dashboard = useDashboardContext();
const terminalId = $derived(page.url.searchParams.get('terminal') ?? 'all');
let report = $state<GrowthReport | null>(null);
let previousReport = $state<GrowthReport | null>(null);
let loading = $state(true);
let refreshing = $state(false);
let message = $state('');
let loadSequence = 0;
let eventStream: EventSource | undefined;
let polling: ReturnType<typeof setTimeout> | undefined;
const isRunning = $derived(report ? ['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status) : false);
const visibleReport = $derived.by(() => {
if (report?.deterministicEvidence?.forecast?.points) return report;
return previousReport;
});
const displayMode = $derived(
reportDisplayMode(
report?.status,
Boolean(report?.deterministicEvidence?.forecast?.points),
Boolean(previousReport?.deterministicEvidence?.forecast?.points),
),
);
const evidence = $derived(visibleReport?.deterministicEvidence);
const briefs = $derived.by(() => {
if (!visibleReport?.deterministicEvidence) return [];
if (visibleReport.aiCopy?.briefs.length) return visibleReport.aiCopy.briefs;
return visibleReport.deterministicEvidence.fallbackBriefs.map((brief) => ({
headline: brief.headlineFa,
interpretation: brief.guidanceFa,
offerSuggestion: brief.offerFa,
messageSuggestion: brief.messageFa,
preparationAction: brief.preparationFa,
limitation: brief.limitationFa,
evidenceIds: brief.evidenceIds,
}));
});
function scopedTerminal() {
return terminalId === 'all' ? undefined : terminalId;
}
async function loadReport() {
const sequence = ++loadSequence;
report = null;
previousReport = null;
loading = true;
message = '';
closeRealtime();
try {
let next: GrowthReport;
try {
next = await api.latestGrowthReport(scopedTerminal());
} catch (cause) {
if (!(cause instanceof ApiError) || cause.status !== 404 || !dashboard.session) throw cause;
next = await api.createGrowthReport(dashboard.session.csrfToken, {
terminalId: scopedTerminal(),
});
}
if (sequence !== loadSequence) return;
report = next;
connectRealtime();
} catch (cause) {
if (sequence !== loadSequence) return;
message =
cause instanceof ApiError && cause.status === 403
? 'این درگاه در محدوده حساب شما نیست.'
: 'تحلیل رشد در حال حاضر در دسترس نیست. کمی بعد دوباره تلاش کنید.';
} finally {
if (sequence === loadSequence) loading = false;
}
}
async function refresh() {
if (!dashboard.session || refreshing) return;
refreshing = true;
message = '';
previousReport = visibleReport;
try {
report = await api.createGrowthReport(dashboard.session.csrfToken, {
terminalId: scopedTerminal(),
refresh: true,
});
connectRealtime();
} catch (cause) {
if (cause instanceof ApiError && cause.status === 429) {
message = report?.nextRefreshAt
? `به‌روزرسانی بعدی از ${formatDate(report.nextRefreshAt)} در دسترس است.`
: 'سهم به‌روزرسانی امروز استفاده شده است؛ تحلیل ذخیره‌شده همچنان در دسترس است.';
} else {
message = 'نسخه تازه آماده نشد؛ آخرین تحلیل معتبر همچنان نمایش داده می‌شود.';
}
} finally {
refreshing = false;
}
}
function closeRealtime() {
eventStream?.close();
eventStream = undefined;
if (polling) clearTimeout(polling);
}
function connectRealtime() {
closeRealtime();
if (!report || !['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) return;
const source = new EventSource(report.eventsUrl, { withCredentials: true });
eventStream = source;
const update = (event: MessageEvent<string>) => {
try {
const payload: unknown = JSON.parse(event.data);
const candidate =
typeof payload === 'object' && payload !== null && 'report' in payload
? (payload as { report?: unknown }).report
: payload;
if (!isGrowthReport(candidate)) return;
const next = candidate;
report = next;
if (['READY', 'PARTIAL', 'FAILED'].includes(next.status)) closeRealtime();
} catch {
schedulePoll();
}
};
for (const name of ['report.snapshot', 'report.updated', 'report.completed', 'report.failed']) {
source.addEventListener(name, update as EventListener);
}
source.onerror = () => {
source.close();
schedulePoll();
};
}
function isGrowthReport(value: unknown): value is GrowthReport {
return (
typeof value === 'object' &&
value !== null &&
'reportId' in value &&
typeof (value as { reportId?: unknown }).reportId === 'string' &&
'status' in value &&
typeof (value as { status?: unknown }).status === 'string'
);
}
function schedulePoll() {
if (!report || !['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) return;
if (polling) clearTimeout(polling);
polling = setTimeout(async () => {
if (!report) return;
try {
report = await api.growthReport(report.reportId);
if (['QUEUED', 'ANALYZING', 'GENERATING'].includes(report.status)) schedulePoll();
} catch {
schedulePoll();
}
}, 2000);
}
$effect(() => {
terminalId;
if (growthFeatureEnabled) void loadReport();
});
onDestroy(closeRealtime);
</script>
<svelte:head><title>تحلیل‌گر رشد | تحلیل زرین‌پال</title></svelte:head>
<PageHeader
title="تحلیل‌گر رشد"
description="چشم‌انداز ۹۰ روز آینده بر پایه روندهای واقعی کسب‌وکارتان"
icon={BrainCircuitIcon}
controls={false}
/>
{#if !growthFeatureEnabled}
<Card class="rounded-lg"
><CardContent class="p-10 text-center"
><h2 class="font-bold">تحلیل‌گر رشد هنوز فعال نشده است</h2>
<p class="text-muted-foreground mt-2 text-sm">
پس از آماده‌شدن سرویس تحلیل، این بخش برای حساب شما نمایش داده می‌شود.
</p></CardContent
></Card
>
{:else if (loading && !visibleReport) || displayMode === 'loading'}
<GrowthReportSkeleton />
{:else if displayMode === 'error' || !report || !visibleReport || !evidence}
<Card class="border-destructive/30 rounded-lg"
><CardContent class="space-y-4 p-10 text-center"
><CircleAlertIcon class="text-destructive mx-auto size-9" />
<p role="alert">{message}</p>
<Button variant="outline" onclick={loadReport}><RefreshCcwIcon /> تلاش دوباره</Button></CardContent
></Card
>
{:else}
<div class="relative space-y-4" aria-busy={refreshing || isRunning}>
<Card class="rounded-lg shadow-xs">
<CardContent class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5">
<div>
<p class="text-sm font-bold">افق گزارش: {visibleReport.horizonDays} روز پس از آخرین داده کامل</p>
<p class="text-muted-foreground mt-1 text-xs">
{visibleReport.cached
? 'این تحلیل از نسخه ذخیره‌شده نمایش داده می‌شود.'
: 'نسخه تازه تحلیل در حال آماده‌شدن است.'}
</p>
</div>
<div class="flex flex-col items-start gap-1 sm:items-end">
<Button class="min-h-11 rounded-md" onclick={refresh} disabled={refreshing || isRunning}
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> به‌روزرسانی تحلیل</Button
>
<span class="text-muted-foreground text-[11px]">نوبت بعدی: {formatDate(report.nextRefreshAt)}</span>
</div>
</CardContent>
</Card>
{#if message}<div
class="rounded-md border border-amber-300/60 bg-amber-50/50 p-3 text-sm dark:border-amber-800/70 dark:bg-amber-950/15"
role="status"
>
{message}
</div>{/if}
{#if report.status === 'PARTIAL'}
<div
class="rounded-md border border-amber-300/60 bg-amber-50/50 p-4 text-sm dark:border-amber-800/70 dark:bg-amber-950/15"
role="status"
>
اعداد و پیش‌بینی‌ها آماده‌اند، اما توضیح نوشتاری تازه در دسترس نبود. داده‌های قابل اتکا همچنان نمایش
داده می‌شوند.
</div>
{:else if report.status === 'FAILED'}
<div class="border-destructive/30 bg-destructive/5 rounded-md border p-4 text-sm" role="alert">
آماده‌سازی این تحلیل کامل نشد. اگر تحلیل معتبری از قبل وجود داشته باشد، همان نسخه حفظ می‌شود.
</div>
{/if}
{#if evidence.forecast.confidence === 'INSUFFICIENT_DATA'}
<Card class="rounded-lg"
><CardContent class="p-10 text-center"
><h2 class="font-bold">برای پیش‌بینی قابل اتکا هنوز داده کافی نداریم</h2>
<p class="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-7">
این بخش فقط زمانی پیش‌بینی نشان می‌دهد که سابقه کافی برای مقایسه روزهای مشابه و ارزیابی دقت مدل
وجود داشته باشد.
</p></CardContent
></Card
>
{:else}
<GrowthForecastChart
points={evidence.forecast.points}
confidence={evidence.forecast.confidence}
startDate={evidence.forecast.startDate}
/>
<GrowthInsights
wins={evidence.historicalWins}
opportunities={evidence.opportunities}
risks={evidence.risks}
{briefs}
peer={evidence.peer}
terminalScoped={visibleReport.scope.terminalId !== null &&
visibleReport.scope.terminalId !== undefined}
/>
{/if}
{#if refreshing || isRunning}<GrowthReportSkeleton refreshing />{/if}
</div>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { Skeleton } from '$lib/components/ui/skeleton';
let { refreshing = false }: { refreshing?: boolean } = $props();
</script>
<div
class={refreshing
? 'bg-background/65 pointer-events-none absolute inset-0 z-20 rounded-lg p-4 backdrop-blur-[1px]'
: 'space-y-4'}
role="status"
aria-label={refreshing ? 'در حال به‌روزرسانی تحلیل' : 'در حال آماده‌سازی تحلیل رشد'}
aria-live="polite"
>
<span class="sr-only"
>{refreshing
? 'تحلیل قبلی نمایش داده می‌شود و نسخه تازه در حال آماده‌شدن است.'
: 'اطلاعات تحلیل رشد در حال بارگذاری است.'}</span
>
{#if refreshing}
<div class="flex justify-end"><Skeleton class="h-10 w-44 rounded-md" /></div>
{:else}
<Skeleton class="h-24 w-full rounded-lg" />
<Skeleton class="h-96 w-full rounded-lg" />
<div class="grid gap-4 md:grid-cols-2">
<Skeleton class="h-64 rounded-lg" />
<Skeleton class="h-64 rounded-lg" />
</div>
{/if}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { Card, CardContent } from '$lib/components/ui/card';
let { href = '/growth' }: { href?: string } = $props();
</script>
<Card class="border-primary/20 bg-primary/[.035] mt-4 rounded-lg shadow-xs">
<CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<SparklesIcon class="text-primary mt-1 size-5 shrink-0" />
<div>
<h2 class="font-bold">تحلیل‌گر رشد</h2>
<p class="text-muted-foreground mt-1 text-sm leading-7">
فرصت‌های پیش رو و دوره‌های کم‌رونق احتمالی را با تکیه بر داده‌های خودتان ببینید.
</p>
</div>
</div>
<a
{href}
class="border-primary/25 text-primary hover:bg-accent inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-md border px-4 text-sm font-bold"
>
دیدن تحلیل ۹۰ روزه
<ArrowLeftIcon class="size-4" />
</a>
</CardContent>
</Card>

View File

@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import GrowthTeaser from './GrowthTeaser.svelte';
describe('GrowthTeaser', () => {
it('links to the growth analyst without starting a generation request', () => {
render(GrowthTeaser, { href: '/growth?terminal=terminal-1' });
expect(screen.getByRole('link', { name: /دیدن تحلیل ۹۰ روزه/ }).getAttribute('href')).toBe(
'/growth?terminal=terminal-1',
);
expect(screen.getByText('تحلیل‌گر رشد')).not.toBeNull();
});
});

View File

@@ -8,6 +8,11 @@
ref = $bindable(null),
sideOffset = 4,
align = 'start',
collisionPadding = 8,
avoidCollisions = true,
sticky = 'always',
hideWhenDetached = true,
dir = 'rtl',
portalProps,
class: className,
...restProps
@@ -22,8 +27,13 @@
data-slot="dropdown-menu-content"
{sideOffset}
{align}
{collisionPadding}
{avoidCollisions}
{sticky}
{hideWhenDetached}
{dir}
class={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 w-(--bits-dropdown-menu-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden',
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 max-h-[min(20rem,var(--bits-dropdown-menu-content-available-height))] w-(--bits-dropdown-menu-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden',
className,
)}
{...restProps}

View File

@@ -5,15 +5,25 @@
let {
ref = $bindable(null),
class: className,
collisionPadding = 8,
avoidCollisions = true,
sticky = 'always',
hideWhenDetached = true,
dir = 'rtl',
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
{collisionPadding}
{avoidCollisions}
{sticky}
{hideWhenDetached}
{dir}
data-slot="dropdown-menu-sub-content"
class={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100',
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 max-h-[min(20rem,var(--bits-dropdown-menu-content-available-height))] w-auto min-w-[96px] overflow-y-auto overscroll-contain rounded-lg p-1 shadow-lg ring-1 duration-100',
className,
)}
{...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
let { open = $bindable(false), dir = 'rtl', ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />
<DropdownMenuPrimitive.Root bind:open {dir} {...restProps} />

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as DropdownMenu from './dropdown-menu';
import * as Popover from './popover';
import * as Select from './select';
let { kind }: { kind: 'select' | 'popover' | 'menu' } = $props();
</script>
<div dir="rtl">
{#if kind === 'select'}
<Select.Root type="single" value="" open items={[{ value: 'one', label: 'یک' }]}>
<Select.Trigger>انتخاب</Select.Trigger>
<Select.Content forceMount><Select.Item value="one" label="یک" /></Select.Content>
</Select.Root>
{:else if kind === 'popover'}
<Popover.Root open>
<Popover.Trigger>جزئیات</Popover.Trigger>
<Popover.Content forceMount>محتوای جزئیات</Popover.Content>
</Popover.Root>
{:else}
<DropdownMenu.Root open>
<DropdownMenu.Trigger>عملیات</DropdownMenu.Trigger>
<DropdownMenu.Content forceMount><DropdownMenu.Item>ویرایش</DropdownMenu.Item></DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>

View File

@@ -0,0 +1,33 @@
import { cleanup, render, screen } from '@testing-library/svelte';
import { afterEach, describe, expect, it } from 'vitest';
import FloatingContentHarness from './floating-content.test.svelte';
afterEach(cleanup);
describe('floating content primitives', () => {
it('bounds select menus to the available viewport with an internal scroll region', async () => {
render(FloatingContentHarness, { kind: 'select' });
const content = await screen.findByRole('listbox');
const viewport = content.querySelector('[data-select-viewport]');
expect(content.className).toContain('var(--bits-select-content-available-height)');
expect(viewport?.className).toContain('overscroll-contain');
expect(content.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
});
it('bounds popovers and menus to their collision-aware available height', async () => {
const popoverView = render(FloatingContentHarness, { kind: 'popover' });
const popover = screen.getByText('محتوای جزئیات');
expect(popover.className).toContain('var(--bits-popover-content-available-height)');
expect(popover.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
popoverView.unmount();
render(FloatingContentHarness, { kind: 'menu' });
const menu = screen.getByRole('menu');
expect(menu.className).toContain('var(--bits-dropdown-menu-content-available-height)');
expect(menu.className).toContain('overscroll-contain');
expect(menu.closest('[data-bits-floating-content-wrapper]')?.getAttribute('dir')).toBe('rtl');
});
});

View File

@@ -9,6 +9,11 @@
class: className,
sideOffset = 4,
align = 'center',
collisionPadding = 8,
avoidCollisions = true,
sticky = 'always',
hideWhenDetached = true,
dir = 'rtl',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
@@ -22,8 +27,13 @@
data-slot="popover-content"
{sideOffset}
{align}
{collisionPadding}
{avoidCollisions}
{sticky}
{hideWhenDetached}
{dir}
class={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 z-50 flex max-h-[min(32rem,var(--bits-popover-content-available-height))] w-72 origin-(--transform-origin) flex-col gap-2.5 overflow-y-auto overscroll-contain rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
className,
)}
{...restProps}

View File

@@ -11,6 +11,11 @@
ref = $bindable(null),
class: className,
sideOffset = 4,
collisionPadding = 8,
avoidCollisions = true,
sticky = 'always',
hideWhenDetached = true,
dir = 'rtl',
portalProps,
children,
preventScroll = true,
@@ -24,17 +29,25 @@
<SelectPrimitive.Content
bind:ref
{sideOffset}
{collisionPadding}
{avoidCollisions}
{sticky}
{hideWhenDetached}
{dir}
{preventScroll}
data-slot="select-content"
class={cn(
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 relative isolate z-50 min-w-36 overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100',
'bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 relative isolate z-50 max-h-[min(20rem,var(--bits-select-content-available-height))] min-w-36 overflow-x-hidden rounded-lg shadow-md ring-1 duration-100',
className,
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn('h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1')}
dir="rtl"
class={cn(
'max-h-[min(20rem,var(--bits-select-content-available-height))] w-full min-w-(--bits-select-anchor-width) scroll-my-1 overscroll-contain',
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildForecastChart, formatConfidence } from './growth-chart';
describe('growth forecast chart presentation', () => {
it('maps the p10, p50 and p90 series into a shared bounded plot', () => {
const chart = buildForecastChart(
[
{ localDate: '2026-07-01', p10: 80, p50: 100, p90: 130 },
{ localDate: '2026-07-02', p10: 90, p50: 120, p90: 150 },
],
760,
300,
);
expect(chart.points).toHaveLength(2);
expect(chart.points[0].highY).toBeLessThan(chart.points[0].medianY);
expect(chart.points[0].medianY).toBeLessThan(chart.points[0].lowY);
expect(chart.points.every((point) => point.x >= chart.padding.left)).toBe(true);
});
it('keeps the chart useful when every forecast value is zero', () => {
const chart = buildForecastChart([{ localDate: '2026-07-01', p10: 0, p50: 0, p90: 0 }], 760, 300);
expect(chart.max).toBe(1);
expect(chart.medianPath).toContain('M');
});
it('translates forecast confidence without implying certainty', () => {
expect(formatConfidence('HIGH')).toBe('اطمینان بالا');
expect(formatConfidence('MEDIUM')).toBe('اطمینان متوسط');
expect(formatConfidence('INSUFFICIENT')).toBe('داده ناکافی');
});
});

47
src/lib/growth-chart.ts Normal file
View File

@@ -0,0 +1,47 @@
export type ForecastChartDatum = {
localDate: string;
p10: number;
p50: number;
p90: number;
};
export type ForecastChartPoint = ForecastChartDatum & {
x: number;
lowY: number;
medianY: number;
highY: number;
};
export function buildForecastChart(data: ForecastChartDatum[], width: number, height: number) {
const padding = { top: 24, right: 24, bottom: 52, left: 72 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const max = Math.max(1, ...data.map((point) => point.p90));
const y = (value: number) => height - padding.bottom - (value / max) * innerHeight;
const points: ForecastChartPoint[] = data.map((point, index) => ({
...point,
x: padding.left + (index * innerWidth) / Math.max(1, data.length - 1),
lowY: y(point.p10),
medianY: y(point.p50),
highY: y(point.p90),
}));
const medianPath = points
.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.medianY}`)
.join(' ');
const bandPath = points.length
? `${points.map((point, index) => `${index ? 'L' : 'M'} ${point.x} ${point.highY}`).join(' ')} ${[
...points,
]
.reverse()
.map((point) => `L ${point.x} ${point.lowY}`)
.join(' ')} Z`
: '';
return { points, max, padding, medianPath, bandPath };
}
export function formatConfidence(value: string): string {
if (value === 'HIGH') return 'اطمینان بالا';
if (value === 'MEDIUM') return 'اطمینان متوسط';
return 'داده ناکافی';
}

View File

@@ -0,0 +1,3 @@
import { PUBLIC_AI_GROWTH_ENABLED } from '$env/static/public';
export const growthFeatureEnabled = PUBLIC_AI_GROWTH_ENABLED === 'true';

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { reportDisplayMode } from './growth-report-state';
describe('growth report display state', () => {
it('keeps an uncached queued report in a visible loading state', () => {
expect(reportDisplayMode('QUEUED', false, false)).toBe('loading');
});
it('keeps prior deterministic evidence visible during refresh', () => {
expect(reportDisplayMode('GENERATING', false, true)).toBe('content');
});
it('shows deterministic content when AI copy is partial', () => {
expect(reportDisplayMode('PARTIAL', true, false)).toBe('content');
});
it('offers recovery only when a failed report has no prior evidence', () => {
expect(reportDisplayMode('FAILED', false, false)).toBe('error');
});
});

View File

@@ -0,0 +1,11 @@
export type GrowthDisplayMode = 'loading' | 'content' | 'error';
export function reportDisplayMode(
status: string | undefined,
hasCurrentEvidence: boolean,
hasPreviousEvidence: boolean,
): GrowthDisplayMode {
if (hasCurrentEvidence || hasPreviousEvidence) return 'content';
if (status === 'QUEUED' || status === 'ANALYZING' || status === 'GENERATING') return 'loading';
return 'error';
}

View File

@@ -0,0 +1,5 @@
<script>
import GrowthPage from '$lib/components/GrowthPage.svelte';
</script>
<GrowthPage />

View File

@@ -0,0 +1,7 @@
# AI Growth Analyst Frontend Tasks
- [x] U1: Regenerate OpenAPI types and extend the typed client/report state model.
- [x] U2: Add `/growth`, sidebar navigation, dashboard teaser, and global-terminal scope integration.
- [x] U3: Add accessible 90-day forecast chart, confidence/summary states, event evidence, opportunity/risk, peer, and campaign brief components.
- [x] U4: Add initial/refresh skeletons, preserved previous report, SSE/poll recovery, daily refresh feedback, partial/insufficient/error states, and reduced-motion behavior.
- [x] U5: Add unit/component tests, format/check/build verification, responsive RTL review, and light/dark accessibility review without browser automation unless the user grants permission.

View File

@@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [tailwindcss(), sveltekit()],
resolve: {
conditions: mode === 'test' ? ['browser'] : undefined,
},
server: {
host: '127.0.0.1',
port: 5173,