From 2d6519a7bb09fc63f09f820bf6dbc5b4bf42f087 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 22 Aug 2026 18:45:18 +0800 Subject: [PATCH] feat: redesign competitive position experience --- DESIGN.md | 4 + docs/DASHBOARD-UX-SPEC.md | 16 +- src/lib/components/AIInsightPanel.svelte | 48 +++- src/lib/components/AIInsightPanel.test.ts | 47 ++++ src/lib/components/AnalyticsPage.svelte | 287 +++++++++++++++++----- src/lib/components/AppShell.svelte | 2 +- src/lib/components/Dashboard.svelte | 8 +- src/lib/components/GrowthInsights.svelte | 10 +- src/lib/components/Login.svelte | 8 +- src/lib/peer-metrics.test.ts | 30 ++- src/lib/peer-metrics.ts | 81 ++++++ 11 files changed, 455 insertions(+), 86 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a57a1e9..919d799 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -339,3 +339,7 @@ Evidence cards stack beneath the compact chart on phones and form up to three ph - **Don't** use shadow as the primary boundary between resting financial surfaces. - **Don't** animate the page as a spectacle; keep motion to state feedback, the shell, overlays, and the chart trace, and honor reduced motion. - **Don't** hide status, scope, units, formulas, or important chart values behind color or pointer-only interaction. + +### Competitive Position Board (0.14.0) + +The «جایگاه رقابتی» page uses a ranked, full-width board rather than a generic KPI grid. Cards sort from the highest merchant percentile to the lowest, show a restrained rank, exact Persian percentile, semantic status text, and a dual-marker rail: the merchant score and the fixed cohort median at ۵۰. Each card is a keyboard-accessible disclosure for its definition, direction, and interpretation. The page pairs the board with a sticky AI panel on desktop and places the AI panel first on mobile. Motion is limited to the rail fill and is removed under `prefers-reduced-motion`. diff --git a/docs/DASHBOARD-UX-SPEC.md b/docs/DASHBOARD-UX-SPEC.md index bae1270..2984a33 100644 --- a/docs/DASHBOARD-UX-SPEC.md +++ b/docs/DASHBOARD-UX-SPEC.md @@ -78,7 +78,7 @@ Technical route names are proposed for planning only; final route contracts rema | Failed-session detail | پرداخت‌های ناموفق | Review observed failure stages without invented causes | From failure card; CSV eligible | | Retried-session detail | پرداخت‌های تکرارشده | Review attempt history and eventual recovery | From retry card; CSV eligible | | Fees | کارمزد و درآمد | Understand real merchant fee amount and paid fee rate in Rial | From navigation/overview; no attempt-grain fee multiplication | -| Peer comparison | مقایسه با هم‌گروه‌ها | Compare approved metrics with eligible same-category merchants | Aggregate-only; never links to peer rows or peer export | +| Competitive position | جایگاه رقابتی | Compare approved metrics with eligible same-category businesses | Aggregate-only; never links to peer rows or peer export | | Metric quality | کیفیت داده و شاخص‌ها | Understand definitions, periods, freshness, coverage, suppression, and limitations | From navigation and definition/provenance links | | Account/session | حساب کاربری | Confirm current merchant identity, switch theme, or log out | No roles/profile editing in MVP | @@ -88,7 +88,7 @@ Desktop navigation order from top to bottom: 1. Product mark and label. 2. Merchant identity block: merchant display name plus non-interactive account context. -3. Primary analysis links: پیشخوان، عملکرد پرداخت، کارمزد و درآمد، مقایسه با هم‌گروه‌ها. +3. Primary analysis links: پیشخوان، عملکرد پرداخت، کارمزد و درآمد، جایگاه رقابتی. 4. Trust link: کیفیت داده و شاخص‌ها. 5. Bottom utility group: theme control, account, logout. @@ -125,7 +125,7 @@ Wireframes show reading hierarchy, not pixel-perfect styling. In each diagram, t │ [بازه و مبنای مقایسه] [خلاصه صفحه]│ ● پیشخوان │ │ │ عملکرد پرداخت │ │ ┌────────┬────────┬────────┬────────┐ │ کارمزد و درآمد │ -│ │ KPI ۴ │ KPI ۳ │ KPI ۲ │ KPI ۱ │ ← ترتیب خواندن RTL │ مقایسه هم‌گروه‌ها │ +│ │ KPI ۴ │ KPI ۳ │ KPI ۲ │ KPI ۱ │ ← ترتیب خواندن RTL │ جایگاه رقابتی │ │ └────────┴────────┴────────┴────────┘ │ │ │ │ کیفیت داده و شاخص‌ها│ │ ┌───────────────────────────────────┬───────────────────────┐ │ │ @@ -366,7 +366,7 @@ The primary chart is a period trend for fee amount with an optional explicit swi The product calls the value «کارمزد واقعی بازرگان» or «کارمزد پرداخت‌های بانکی», never obfuscated, estimated, or attempt-based. A fee rate with a zero paid amount is undefined, not `۰٪`. -### 9.6 Peer comparison / مقایسه با هم‌گروه‌ها +### 9.6 Competitive position / جایگاه رقابتی #### Eligibility preface @@ -382,13 +382,13 @@ The page explains that comparison uses other eligible merchants in the active me 6. Direction note when lower is better for processing time or paid fee rate. 7. Definition and suppression policy link. -Recommended plot: a compact benchmark range/bar with two direct markers, «شما» and «میانه هم‌گروه‌ها», plus a textual percentile. It is not a distribution of individual peers and must not imply unseen individual ranks. +Recommended plot: a ranked competitive-position board with a compact benchmark rail, two direct markers, «شما» and «میانه گروه مقایسه», plus a textual percentile. Cards are ordered from highest to lowest percentile with stable metric-id tie ordering. It is not a distribution of individual peers and must not imply unseen individual ranks. #### Suppressed state If there are fewer than 10 other eligible merchants or fewer than 1,000 combined peer sessions, replace peer values and plot with: -«برای نمایش مقایسه قابل‌اعتماد، داده هم‌گروه‌های کافی در این بازه وجود ندارد. آمار خود شما همچنان کامل نمایش داده می‌شود.» +«برای نمایش مقایسه قابل‌اعتماد، داده کسب‌وکارهای مشابه در این بازه کافی نیست. آمار خود شما همچنان کامل نمایش داده می‌شود.» Do not reveal which threshold failed, a near-threshold count that could aid differencing, or any peer identity/value. Merchant-owned sections remain visible. @@ -509,7 +509,7 @@ Chart rules: | `InBank` | رسیده به درگاه بانکی | ردشده توسط بانک | | Undefined rate | نرخ قابل محاسبه نیست | `۰٪` | | No rows | داده‌ای در این بازه وجود ندارد | System failure | -| Peer suppression | داده هم‌گروه کافی نیست | Merchant underperformance | +| Peer suppression | داده گروه مقایسه کافی نیست | Merchant underperformance | ## 12. Persian RTL content and formatting @@ -580,7 +580,7 @@ Selecting «دریافت CSV» opens an inline panel or dialog with: - Applied approved filters and sort-independent export scope. - Known result count or a statement that the server will determine it. - Money unit: Rial. -- Data notice: «فایل فقط شامل ردیف‌ها و فیلدهای مجاز همین نمای جزئیات است و اطلاعات پرداخت‌کننده یا هم‌گروه‌ها را ندارد.» +- Data notice: «فایل فقط شامل ردیف‌ها و فیلدهای مجاز همین نمای جزئیات است و اطلاعات پرداخت‌کننده یا کسب‌وکارهای مشابه را ندارد.» - Primary action «ساخت فایل CSV» and cancel action. This is a scope confirmation, not a destructive confirmation. Focus returns to the export trigger when dismissed. diff --git a/src/lib/components/AIInsightPanel.svelte b/src/lib/components/AIInsightPanel.svelte index 0de81bd..f221b5e 100644 --- a/src/lib/components/AIInsightPanel.svelte +++ b/src/lib/components/AIInsightPanel.svelte @@ -3,18 +3,28 @@ import SparklesIcon from '@lucide/svelte/icons/sparkles'; import { ApiError, api, type AIInsight, type AIInsightModule } from '$lib/api/client'; + import type { components } from '$lib/api/schema'; 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 { Skeleton } from '$lib/components/ui/skeleton'; import { useDashboardContext } from '$lib/dashboard-context.svelte'; + import { formatValue } from '$lib/format'; + import { peerMetricLabel, sortPeerMetrics } from '$lib/peer-metrics'; let { module, terminalId, periodId, periodLabel, - }: { module: AIInsightModule; terminalId?: string; periodId?: string; periodLabel?: string } = $props(); + peerMetrics, + }: { + module: AIInsightModule; + terminalId?: string; + periodId?: string; + periodLabel?: string; + peerMetrics?: components['schemas']['PeerMetric'][]; + } = $props(); const dashboard = useDashboardContext(); let insight = $state(null); let loading = $state(true); @@ -27,7 +37,7 @@ overview: 'پیشخوان', payments: 'عملکرد پرداخت', fees: 'کارمزدها', - peers: 'هم‌گروه‌ها', + peers: 'تحلیل جایگاه رقابتی', transactions: 'تراکنش‌ها', growth: 'مربی رشد', }; @@ -123,8 +133,40 @@ + {#if module === 'peers' && peerMetrics?.length} + {@const orderedMetrics = sortPeerMetrics(peerMetrics)} + {@const strongest = orderedMetrics[0]} + {@const weakest = orderedMetrics[orderedMetrics.length - 1]} +
+
+ قوی‌ترین جایگاه{peerMetricLabel(strongest.metricId)}صدک {formatValue(Number(strongest.merchantPerformancePercentile))} +
+
+ فضای رشد{peerMetricLabel(weakest.metricId)}صدک {formatValue(Number(weakest.merchantPerformancePercentile))} +
+
+ {/if}

{insight.copy.latestObserved.body}

-

{insight.copy.past.body}

+ {#if module === 'peers'} + {#if insight.copy.options[0]} +
+ {insight.copy.options[0].title} +

{insight.copy.options[0].description}

+
+ {/if} + {:else} +

{insight.copy.past.body}

+ {/if}
{error || diff --git a/src/lib/components/AIInsightPanel.test.ts b/src/lib/components/AIInsightPanel.test.ts index 583401e..9ce8107 100644 --- a/src/lib/components/AIInsightPanel.test.ts +++ b/src/lib/components/AIInsightPanel.test.ts @@ -95,4 +95,51 @@ describe('AIInsightPanel', () => { expect(screen.getByText('مرداد ۱۴۰۵')).not.toBeNull(); expect(latestAIInsight).toHaveBeenCalledWith('overview', 'T83', 'period-7'); }); + + it('surfaces grounded competitive strengths, growth space, and the next action', async () => { + latestAIInsight.mockReset(); + latestAIInsight.mockResolvedValueOnce({ + ...insight, + module: 'peers', + copy: { + ...insight.copy, + latestObserved: { + headline: 'جایگاه رقابتی', + body: 'در این گروه، سرعت تأیید مزیت اصلی و تکمیل تأیید مهم‌ترین فرصت رشد شماست.', + evidenceIds: ['peer-position'], + }, + options: [ + { + title: 'تمرکز بر فرصت رشد', + description: 'مسیر تکمیل تأیید را بررسی کنید.', + evidenceIds: ['peer-weakest'], + }, + ], + }, + }); + + render(AIInsightPanel, { + module: 'peers', + peerMetrics: [ + { + metricId: 'verification_completion_rate', + cohortMedian: '70', + merchantPerformancePercentile: '35', + direction: 'HIGHER_IS_BETTER', + }, + { + metricId: 'verify_time_ms_p95', + cohortMedian: '980', + merchantPerformancePercentile: '88', + direction: 'LOWER_IS_BETTER', + }, + ], + }); + + await screen.findByText('قوی‌ترین جایگاه'); + expect(screen.getByText('صدک ۸۸')).not.toBeNull(); + expect(screen.getByText('صدک ۳۵')).not.toBeNull(); + expect(screen.getByText('مسیر تکمیل تأیید را بررسی کنید.')).not.toBeNull(); + expect(screen.queryByText('فروش در این بازه رشد کرده است.')).toBeNull(); + }); }); diff --git a/src/lib/components/AnalyticsPage.svelte b/src/lib/components/AnalyticsPage.svelte index d775d53..9b62102 100644 --- a/src/lib/components/AnalyticsPage.svelte +++ b/src/lib/components/AnalyticsPage.svelte @@ -1,4 +1,5 @@ {titles[kind][0]} | تحلیل زرین‌پال @@ -138,12 +152,14 @@ {@const fees = run.sections.fees} {@const peers = run.sections.peerComparison} - + {#if kind !== 'peers'} + + {/if} {#if kind === 'overview' && overview.state === 'READY'}
@@ -338,61 +354,164 @@
- {:else if kind === 'peers' && peers.state === 'READY'} - -

- {formatValue(peers.data.eligiblePeerCount)} کسب‌وکار مشابه با مجموع {formatValue( - peers.data.combinedPeerSessionCount, - )} نشست، مبنای مقایسه ناشناس این گزارش هستند. داده هیچ کسب‌وکاری جداگانه نمایش داده نمی‌شود. -

-
- {#each peers.data.metrics as metric} - - -
-

{peerMetricLabel(metric.metricId)}

- = 75 ? 'default' : 'secondary'} - class="rounded-md" - >صدک {formatValue(Math.round(percentile(metric.merchantPerformancePercentile)))} + +
+
+ {#if peers.state === 'READY'} + + + + + {#if filters.terminal !== 'all'} +
+ جایگاه رقابتی در سطح کل پذیرنده محاسبه می‌شود؛ برای دیدن مقایسهٔ کامل، فیلتر درگاه را بردارید. +
+ {/if} +
+ {#each sortPeerMetrics(peers.data.metrics) as metric, index (metric.metricId)} + {@const score = percentile(metric.merchantPerformancePercentile)} + {@const status = peerPositionStatus(score)} + {@const metricOpen = expandedPeerMetric === metric.metricId} + {@const detailId = `peer-detail-${metric.metricId}`} + -
-
-
-
-
- فضای رشدپیشرو -
-

- {describePercentile(metric.metricId, percentile(metric.merchantPerformancePercentile))} -

-

- میانه هم‌گروه: {formatPeerMetric(metric.metricId, metric.cohortMedian)} -

- - - {/each} + + + + {#if metricOpen} +
+

{describePercentile(metric.metricId, score)}

+
+
+
تعریف شاخص
+
{peerMetricDefinition(metric.metricId)}
+
+
+
جهت خواندن
+
{peerMetricDirectionLabel(metric.direction)}
+
+
+
+ {/if} + + {/each} +
+ {:else if peers.state === 'SUPPRESSED'} + + + + + {:else} + این بخش هنوز در حال آماده‌سازی است. + {/if} +
- {#if filters.terminal !== 'all'}

- مقایسه هم‌گروه در سطح کل پذیرنده محاسبه می‌شود؛ انتخاب درگاه فقط بخش‌های عملیاتی را محدود می‌کند. -

{/if} - {:else if kind === 'peers' && peers.state === 'SUPPRESSED'} - -

گروه مقایسه هنوز به‌اندازه کافی بزرگ نیست

-

- برای حفظ محرمانگی کسب‌وکارها، این بخش تا رسیدن گروه به حد امن نمایش داده نمی‌شود. -

{:else} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 97d8c03..782defb 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -30,7 +30,7 @@ { href: '/transactions', label: 'تراکنش‌ها', icon: ArrowUpDownIcon }, { href: '/payments', label: 'عملکرد پرداخت', icon: ActivityIcon }, { href: '/fees', label: 'کارمزدها', icon: HandCoinsIcon }, - { href: '/peers', label: 'هم‌گروه‌ها', icon: UsersRoundIcon }, + { href: '/peers', label: 'جایگاه رقابتی', icon: UsersRoundIcon }, ...(growthFeatureEnabled ? [{ href: '/growth', label: 'مربی رشد', icon: TrendingUpIcon }] : []), ]; diff --git a/src/lib/components/Dashboard.svelte b/src/lib/components/Dashboard.svelte index 55874ff..26b5f9e 100644 --- a/src/lib/components/Dashboard.svelte +++ b/src/lib/components/Dashboard.svelte @@ -31,7 +31,7 @@ { id: 'overview', label: 'پیشخوان', mark: '⌂' }, { id: 'payments', label: 'عملکرد پرداخت', mark: '↗' }, { id: 'fees', label: 'کارمزد و درآمد', mark: '٪' }, - { id: 'peers', label: 'مقایسه با هم‌گروه‌ها', mark: '◉' }, + { id: 'peers', label: 'جایگاه رقابتی', mark: '◉' }, ]; async function loadPeriods() { @@ -395,15 +395,15 @@ {@const peers = run.sections.peerComparison}
retry('peer_comparison')} > {#if peers.state === 'SUPPRESSED'}

برای مقایسه امن، داده کافی نیست

- تا رسیدن گروه به حداقل حریم خصوصی، هیچ مقدار یا هویت هم‌گروهی نمایش داده نمی‌شود. + تا رسیدن گروه به حداقل حریم خصوصی، هیچ مقدار یا هویت کسب‌وکار مشابهی نمایش داده نمی‌شود.

{:else if peers.state === 'READY'}
diff --git a/src/lib/components/GrowthInsights.svelte b/src/lib/components/GrowthInsights.svelte index 00ea3c2..f41f7f2 100644 --- a/src/lib/components/GrowthInsights.svelte +++ b/src/lib/components/GrowthInsights.svelte @@ -178,19 +178,19 @@ مقایسه امن با هم‌گروه‌ها جایگاه رقابتی امن {#if terminalScoped}

- برای حفظ معنای مقایسه، جایگاه هم‌گروهی فقط در سطح کل پذیرنده نمایش داده می‌شود و برای یک درگاه + برای حفظ معنای مقایسه، جایگاه رقابتی فقط در سطح کل پذیرنده نمایش داده می‌شود و برای یک درگاه محاسبه نمی‌شود.

{:else if peer?.state === 'READY'}

- این مقایسه با {formatValue(peer.eligiblePeerCount ?? 0)} کسب‌وکار واجد شرایط و بدون نمایش اطلاعات هیچ - پذیرنده‌ای ساخته شده است. + این مقایسه با {formatValue(peer.eligiblePeerCount ?? 0)} کسب‌وکار مشابه واجد شرایط و بدون نمایش اطلاعات + هیچ پذیرنده‌ای ساخته شده است.

{:else}

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

{/if} diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte index 1c67429..b1a57b3 100644 --- a/src/lib/components/Login.svelte +++ b/src/lib/components/Login.svelte @@ -41,7 +41,7 @@

داشبورد اختصاصی پذیرندگان

تصمیم‌های بهتر، با تصویری روشن از پرداخت‌ها

- موفقیت پرداخت، تلاش‌های مجدد، کارمزد واقعی و جایگاه شما در میان کسب‌وکارهای هم‌گروه را یک‌جا ببینید. + موفقیت پرداخت، تلاش‌های مجدد، کارمزد واقعی و جایگاه رقابتی شما میان کسب‌وکارهای مشابه را یک‌جا ببینید.

داده‌ها در محدوده حساب پذیرنده شما نمایش داده می‌شوند.

@@ -58,7 +58,7 @@

خوش آمدید

ورود به پنل پذیرنده

-

برای مشاهده گزارش‌های کسب‌وکار، وارد حساب آزمایشی خود شوید.

+

برای مشاهده گزارش‌های کسب‌وکار، وارد حساب آزمایشی خود شوید.

@@ -96,7 +96,9 @@ {loading ? 'در حال ورود…' : 'ورود به داشبورد'} -

این نسخه برای ارزیابی داده‌های آزمایشی ساخته شده است.

+

+ این نسخه برای ارزیابی داده‌های آزمایشی ساخته شده است. +

diff --git a/src/lib/peer-metrics.test.ts b/src/lib/peer-metrics.test.ts index 33facf4..71ef851 100644 --- a/src/lib/peer-metrics.test.ts +++ b/src/lib/peer-metrics.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { formatPeerMetric, peerMetricLabel } from './peer-metrics'; +import { + formatPeerMetric, + peerMetricDefinition, + peerMetricDirectionLabel, + peerMetricLabel, + peerPositionStatus, + sortPeerMetrics, +} from './peer-metrics'; describe('peer metric presentation', () => { it('formats timing medians in milliseconds instead of percent', () => { @@ -18,4 +25,25 @@ describe('peer metric presentation', () => { expect(peerMetricLabel('init_time_ms_p95')).toBe('زمان آغاز پرداخت — صدک ۹۵'); expect(peerMetricLabel('verify_time_ms_p50')).toBe('زمان تأیید — میانه'); }); + + it('sorts the competitive board by descending percentile with a stable metric tie-breaker', () => { + const metrics = [ + { metricId: 'paid_fee_rate' as const, merchantPerformancePercentile: '82' }, + { metricId: 'payment_success_rate' as const, merchantPerformancePercentile: '82' }, + { metricId: 'retry_recovery_rate' as const, merchantPerformancePercentile: '24' }, + ]; + + expect(sortPeerMetrics(metrics).map((metric) => metric.metricId)).toEqual([ + 'payment_success_rate', + 'paid_fee_rate', + 'retry_recovery_rate', + ]); + }); + + it('provides status language and direction guidance without relying on color', () => { + expect(peerPositionStatus(88).label).toBe('پیشرو'); + expect(peerPositionStatus(18).label).toBe('نیازمند توجه'); + expect(peerMetricDirectionLabel('LOWER_IS_BETTER')).toBe('هرچه کمتر، بهتر'); + expect(peerMetricDefinition('payment_success_rate')).toContain('سهم نشست‌ها'); + }); }); diff --git a/src/lib/peer-metrics.ts b/src/lib/peer-metrics.ts index b8e4181..3318fda 100644 --- a/src/lib/peer-metrics.ts +++ b/src/lib/peer-metrics.ts @@ -28,3 +28,84 @@ export function peerMetricLabel(metricId: PeerMetricId): string { export function formatPeerMetric(metricId: PeerMetricId, value: string): string { return timingMetrics.has(metricId) ? formatMilliseconds(Number(value)) : formatPercent(value); } + +const metricOrder: PeerMetricId[] = [ + 'payment_success_rate', + 'verification_completion_rate', + 'retry_recovery_rate', + 'init_time_ms_p50', + 'init_time_ms_p95', + 'verify_time_ms_p50', + 'verify_time_ms_p95', + 'paid_fee_rate', +]; + +/** Stable, highest-position-first ordering for the competitive-position board. */ +export function sortPeerMetrics( + metrics: readonly T[], +): T[] { + const order = new Map(metricOrder.map((metricId, index) => [metricId, index])); + return [...metrics].sort((a, b) => { + const percentileDifference = + Number(b.merchantPerformancePercentile) - Number(a.merchantPerformancePercentile); + if (percentileDifference !== 0) return percentileDifference; + return (order.get(a.metricId) ?? metricOrder.length) - (order.get(b.metricId) ?? metricOrder.length); + }); +} + +export type PeerPositionStatus = 'leading' | 'strong' | 'developing' | 'needs-attention'; + +export function peerPositionStatus(percentile: number): { + key: PeerPositionStatus; + label: string; + className: string; +} { + if (percentile >= 75) { + return { + key: 'leading', + label: 'پیشرو', + className: + 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300', + }; + } + if (percentile >= 50) { + return { + key: 'strong', + label: 'قدرتمند', + className: + 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900 dark:bg-sky-950/30 dark:text-sky-300', + }; + } + if (percentile >= 25) { + return { + key: 'developing', + label: 'در حال رشد', + className: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300', + }; + } + return { + key: 'needs-attention', + label: 'نیازمند توجه', + className: + 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900 dark:bg-rose-950/30 dark:text-rose-300', + }; +} + +export function peerMetricDirectionLabel(direction: 'HIGHER_IS_BETTER' | 'LOWER_IS_BETTER'): string { + return direction === 'HIGHER_IS_BETTER' ? 'هرچه بیشتر، بهتر' : 'هرچه کمتر، بهتر'; +} + +export function peerMetricDefinition(metricId: PeerMetricId): string { + const definitions: Record = { + payment_success_rate: 'سهم نشست‌هایی که با موفقیت بانکی پایان یافته‌اند.', + verification_completion_rate: 'سهم پرداخت‌های موفقی که تأیید نهایی شده‌اند.', + retry_recovery_rate: 'سهم مسیرهای ناموفق که با تلاش دوباره بازیابی شده‌اند.', + init_time_ms_p50: 'زمان معمول آغاز پرداخت، بر پایه میانهٔ مشاهده‌ها.', + init_time_ms_p95: 'زمان آغاز پرداخت در تجربه‌های کندتر، بر پایه صدک ۹۵.', + verify_time_ms_p50: 'زمان معمول تأیید پرداخت، بر پایه میانهٔ مشاهده‌ها.', + verify_time_ms_p95: 'زمان تأیید پرداخت در تجربه‌های کندتر، بر پایه صدک ۹۵.', + paid_fee_rate: 'سهم کارمزد پرداخت‌شده از مبلغ پرداخت‌های موفق.', + }; + return definitions[metricId]; +}