feat: redesign competitive position experience

This commit is contained in:
2026-08-22 18:45:18 +08:00
parent 2f856db441
commit 2d6519a7bb
11 changed files with 455 additions and 86 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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<AIInsight | null>(null);
let loading = $state(true);
@@ -27,7 +37,7 @@
overview: 'پیشخوان',
payments: 'عملکرد پرداخت',
fees: 'کارمزدها',
peers: 'هم‌گروه‌ها',
peers: 'تحلیل جایگاه رقابتی',
transactions: 'تراکنش‌ها',
growth: 'مربی رشد',
};
@@ -123,8 +133,40 @@
</div>
</CardHeader>
<CardContent class="space-y-4 p-5">
{#if module === 'peers' && peerMetrics?.length}
{@const orderedMetrics = sortPeerMetrics(peerMetrics)}
{@const strongest = orderedMetrics[0]}
{@const weakest = orderedMetrics[orderedMetrics.length - 1]}
<div
class="border-primary/15 bg-primary/[.035] grid gap-3 rounded-md border p-3 text-xs sm:grid-cols-2"
>
<div>
<span class="text-muted-foreground block">قوی‌ترین جایگاه</span><strong class="mt-1 block"
>{peerMetricLabel(strongest.metricId)}</strong
><span class="text-primary mt-1 block font-bold"
>صدک {formatValue(Number(strongest.merchantPerformancePercentile))}</span
>
</div>
<div>
<span class="text-muted-foreground block">فضای رشد</span><strong class="mt-1 block"
>{peerMetricLabel(weakest.metricId)}</strong
><span class="text-primary mt-1 block font-bold"
>صدک {formatValue(Number(weakest.merchantPerformancePercentile))}</span
>
</div>
</div>
{/if}
<p class="text-sm leading-8">{insight.copy.latestObserved.body}</p>
<p class="text-muted-foreground text-sm leading-8">{insight.copy.past.body}</p>
{#if module === 'peers'}
{#if insight.copy.options[0]}
<div class="border-primary/15 bg-primary/[.025] rounded-md border p-3 text-sm leading-7">
<strong class="block">{insight.copy.options[0].title}</strong>
<p class="text-muted-foreground mt-1">{insight.copy.options[0].description}</p>
</div>
{/if}
{:else}
<p class="text-muted-foreground text-sm leading-8">{insight.copy.past.body}</p>
{/if}
<div class="flex flex-col gap-2 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<span class="text-muted-foreground text-xs" aria-live="polite">
{error ||

View File

@@ -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();
});
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ActivityIcon from '@lucide/svelte/icons/activity';
import BanknoteIcon from '@lucide/svelte/icons/banknote';
@@ -19,7 +20,7 @@
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { parseDashboardFilters, setDashboardQuery } from '$lib/dashboard-filters';
import { formatMoney, formatMilliseconds, formatPercent, formatValue } from '$lib/format';
import {
describePercentile,
@@ -28,7 +29,14 @@
retryEngagementRate,
retryOpportunity,
} from '$lib/insights';
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import {
formatPeerMetric,
peerMetricDefinition,
peerMetricDirectionLabel,
peerMetricLabel,
peerPositionStatus,
sortPeerMetrics,
} from '$lib/peer-metrics';
import { growthFeatureEnabled } from '$lib/growth-feature';
import KpiCard from './KpiCard.svelte';
import GrowthTeaser from './GrowthTeaser.svelte';
@@ -45,12 +53,13 @@
let loading = $state(true);
let error = $state('');
let loadSequence = 0;
let expandedPeerMetric = $state<string | null>(null);
const titles = {
overview: ['پیشخوان', 'تصویری روشن از روند پرداخت‌ها و مهم‌ترین دستاوردهای این بازه'],
payments: ['عملکرد پرداخت', 'ببینید مشتریان در هر مرحله از مسیر پرداخت چه تجربه‌ای داشته‌اند'],
fees: ['کارمزدها', 'مبلغ و نرخ واقعی کارمزد، با مقایسه‌ای قابل فهم نسبت به بازه قبل'],
peers: ['هم‌گروه‌ها', 'جایگاه شما میان کسب‌وکارهای مشابه، بدون نمایش اطلاعات هیچ پذیرنده‌ای'],
peers: ['جایگاه رقابتی', 'جایگاه شما میان کسب‌وکارهای مشابه، بدون نمایش اطلاعات هیچ پذیرنده‌ای'],
} as const;
const icons = {
overview: PanelsTopLeftIcon,
@@ -114,6 +123,11 @@
function flowWidth(value: number, total: number) {
return total > 0 ? Math.max(0, Math.min(100, (value * 100) / total)) : 0;
}
function clearTerminalFilter() {
const next = setDashboardQuery(page.url, { terminal: null });
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
</script>
<svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head>
@@ -138,12 +152,14 @@
{@const fees = run.sections.fees}
{@const peers = run.sections.peerComparison}
<AIInsightPanel
module={kind}
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
periodId={run.period.periodId}
periodLabel={run.period.jalaliLabelFa}
/>
{#if kind !== 'peers'}
<AIInsightPanel
module={kind}
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
periodId={run.period.periodId}
periodLabel={run.period.jalaliLabelFa}
/>
{/if}
{#if kind === 'overview' && overview.state === 'READY'}
<div class="kpi-grid grid gap-3 sm:gap-4">
@@ -338,61 +354,164 @@
</div></CardContent
></Card
>
{:else if kind === 'peers' && peers.state === 'READY'}
<Card class="border-primary/20 bg-primary/[.035] mb-4 rounded-lg shadow-xs"
><CardContent class="flex items-start gap-3 p-5"
><UsersRoundIcon class="text-primary mt-1 size-5 shrink-0" />
<p class="text-sm leading-7">
<strong>{formatValue(peers.data.eligiblePeerCount)} کسب‌وکار مشابه</strong> با مجموع {formatValue(
peers.data.combinedPeerSessionCount,
)} نشست، مبنای مقایسه ناشناس این گزارش هستند. داده هیچ کسب‌وکاری جداگانه نمایش داده نمی‌شود.
</p></CardContent
></Card
{:else if kind === 'peers'}
<div
class="competitive-layout grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(19rem,24rem)] lg:items-start"
>
<div class="grid gap-4 lg:grid-cols-2">
{#each peers.data.metrics as metric}
<Card class="rounded-lg shadow-xs">
<CardContent class="p-5">
<div class="flex items-start justify-between gap-3">
<h2 class="font-bold">{peerMetricLabel(metric.metricId)}</h2>
<Badge
variant={percentile(metric.merchantPerformancePercentile) >= 75 ? 'default' : 'secondary'}
class="rounded-md"
>صدک {formatValue(Math.round(percentile(metric.merchantPerformancePercentile)))}</Badge
<div class="order-1 lg:sticky lg:top-20 lg:order-2">
<AIInsightPanel
module={kind}
terminalId={filters.terminal === 'all' ? undefined : filters.terminal}
periodId={run.period.periodId}
periodLabel={run.period.jalaliLabelFa}
peerMetrics={peers.state === 'READY' ? peers.data.metrics : undefined}
/>
</div>
<div class="order-2 min-w-0 lg:order-1">
{#if peers.state === 'READY'}
<Card class="border-primary/20 bg-primary/[.035] mb-4 rounded-lg shadow-xs">
<CardContent class="flex flex-wrap items-start gap-3 p-5">
<UsersRoundIcon class="text-primary mt-1 size-5 shrink-0" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-7">
<strong>{formatValue(peers.data.eligiblePeerCount)} کسب‌وکار مشابه</strong> با مجموع
{formatValue(peers.data.combinedPeerSessionCount)} نشست، مبنای مقایسهٔ ناشناس این گزارش هستند.
دادهٔ هیچ کسب‌وکاری جداگانه نمایش داده نمی‌شود.
</p>
<div class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<span>بازهٔ گزارش: {run.period.jalaliLabelFa}</span>
<span>میانهٔ ثابت در صدک ۵۰</span>
{#if filters.terminal !== 'all'}
<Button variant="link" size="sm" class="h-8 px-0" onclick={clearTerminalFilter}
>نمایش همهٔ درگاه‌ها</Button
>
{/if}
</div>
</div>
</CardContent>
</Card>
{#if filters.terminal !== 'all'}
<div
class="border-warning/40 bg-warning/10 text-warning-foreground mb-4 rounded-lg border px-4 py-3 text-xs leading-6"
role="note"
>
جایگاه رقابتی در سطح کل پذیرنده محاسبه می‌شود؛ برای دیدن مقایسهٔ کامل، فیلتر درگاه را بردارید.
</div>
{/if}
<div class="competitive-board space-y-3" aria-label="رتبه‌بندی شاخص‌های جایگاه رقابتی">
{#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}`}
<Card
class={`peer-position-card overflow-hidden rounded-lg shadow-xs ${metricOpen ? 'border-primary/40' : ''}`}
>
</div>
<div class="bg-muted mt-5 h-2 overflow-hidden rounded-sm" dir="ltr">
<div
class={`h-full rounded-sm transition-[width] duration-700 ${peerProgressClass(metric.merchantPerformancePercentile)}`}
style={`width:${percentile(metric.merchantPerformancePercentile)}%`}
></div>
</div>
<div class="text-muted-foreground mt-2 flex justify-between text-xs">
<span>فضای رشد</span><span>پیشرو</span>
</div>
<p class="text-muted-foreground mt-4 text-sm leading-7">
{describePercentile(metric.metricId, percentile(metric.merchantPerformancePercentile))}
</p>
<p class="mt-3 text-xs">
میانه هم‌گروه: <strong>{formatPeerMetric(metric.metricId, metric.cohortMedian)}</strong>
</p>
</CardContent>
</Card>
{/each}
<CardContent class="p-0">
<button
type="button"
class="focus-visible:border-ring focus-visible:ring-ring/50 block min-h-11 w-full p-4 text-right outline-none focus-visible:ring-3 sm:p-5"
aria-expanded={metricOpen}
aria-controls={detailId}
onclick={() => (expandedPeerMetric = metricOpen ? null : metric.metricId)}
>
<div class="flex items-start gap-3">
<span
class="bg-muted text-muted-foreground grid size-8 shrink-0 place-items-center rounded-md text-sm font-bold tabular-nums"
aria-label={`رتبه ${index + 1}`}
>
{formatValue(index + 1)}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
{#if index === 0}<span class="text-primary mb-1 block text-xs font-semibold"
>بهترین جایگاه شما</span
>{/if}
<h2 class="font-bold">{peerMetricLabel(metric.metricId)}</h2>
</div>
<div class="flex items-center gap-2">
<span
class={`rounded-md border px-2 py-1 text-xs font-semibold ${status.className}`}
>{status.label}</span
>
<span class="text-primary text-lg font-extrabold tabular-nums" dir="rtl"
>صدک {formatValue(score)}</span
>
</div>
</div>
<div
class="peer-rail bg-muted mt-5 h-3 rounded-sm"
dir="ltr"
style={`--peer-score:${score}%`}
aria-hidden="true"
>
<span class="peer-median-marker"></span>
<span
class={`peer-score-marker ${peerProgressClass(metric.merchantPerformancePercentile)}`}
></span>
</div>
<div class="text-muted-foreground mt-2 flex justify-between text-xs" dir="ltr">
<span dir="rtl">فضای رشد</span><span dir="rtl">پیشرو</span>
</div>
<div class="text-muted-foreground mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span
>میانهٔ گروه: <strong class="text-foreground"
>{formatPeerMetric(metric.metricId, metric.cohortMedian)}</strong
></span
>
<span
>{metricOpen
? 'برای بستن جزئیات کلیک کنید'
: 'برای دیدن تعریف و تفسیر کلیک کنید'}</span
>
</div>
</div>
</div>
</button>
</CardContent>
{#if metricOpen}
<div id={detailId} class="border-t px-5 py-4 text-sm leading-7" aria-live="polite">
<p>{describePercentile(metric.metricId, score)}</p>
<dl class="mt-3 grid gap-2 text-xs sm:grid-cols-2">
<div>
<dt class="text-muted-foreground">تعریف شاخص</dt>
<dd class="mt-1">{peerMetricDefinition(metric.metricId)}</dd>
</div>
<div>
<dt class="text-muted-foreground">جهت خواندن</dt>
<dd class="mt-1">{peerMetricDirectionLabel(metric.direction)}</dd>
</div>
</dl>
</div>
{/if}
</Card>
{/each}
</div>
{:else if peers.state === 'SUPPRESSED'}
<Card class="rounded-lg">
<CardContent class="p-10 text-center">
<UsersRoundIcon class="text-muted-foreground mx-auto mb-4 size-10" aria-hidden="true" />
<h2 class="font-bold">گروه مقایسه هنوز به‌اندازهٔ کافی بزرگ نیست</h2>
<p class="text-muted-foreground mt-2 text-sm leading-7">
برای حفظ محرمانگی کسب‌وکارها، این بخش تا رسیدن گروه به حد امن نمایش داده نمی‌شود.
</p>
{#if filters.terminal !== 'all'}
<Button class="mt-5" variant="outline" onclick={clearTerminalFilter}
>نمایش همهٔ درگاه‌ها</Button
>
{/if}
</CardContent>
</Card>
{:else}
<Card class="rounded-lg"
><CardContent class="text-muted-foreground p-8 text-center text-sm"
>این بخش هنوز در حال آماده‌سازی است.</CardContent
></Card
>
{/if}
</div>
</div>
{#if filters.terminal !== 'all'}<p class="text-muted-foreground mt-4 text-xs">
مقایسه هم‌گروه در سطح کل پذیرنده محاسبه می‌شود؛ انتخاب درگاه فقط بخش‌های عملیاتی را محدود می‌کند.
</p>{/if}
{:else if kind === 'peers' && peers.state === 'SUPPRESSED'}
<Card class="rounded-lg"
><CardContent class="p-10 text-center"
><UsersRoundIcon class="text-muted-foreground mx-auto mb-4 size-10" />
<h2 class="font-bold">گروه مقایسه هنوز به‌اندازه کافی بزرگ نیست</h2>
<p class="text-muted-foreground mt-2 text-sm">
برای حفظ محرمانگی کسب‌وکارها، این بخش تا رسیدن گروه به حد امن نمایش داده نمی‌شود.
</p></CardContent
></Card
>
{:else}
<Card class="rounded-lg"
><CardContent class="text-muted-foreground p-8 text-center text-sm"
@@ -406,4 +525,50 @@
.kpi-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
.peer-rail {
position: relative;
isolation: isolate;
}
.peer-rail::before {
position: absolute;
inset: 0;
z-index: -1;
width: var(--peer-score);
border-radius: inherit;
background: var(--primary);
content: '';
opacity: 0.2;
transition: width 240ms ease;
}
.peer-score-marker,
.peer-median-marker {
position: absolute;
top: 50%;
display: block;
width: 3px;
height: 18px;
border-radius: 2px;
transform: translate(-50%, -50%);
}
.peer-score-marker {
left: var(--peer-score);
box-shadow: 0 0 0 2px var(--card);
}
.peer-median-marker {
left: 50%;
z-index: 1;
background: var(--muted-foreground);
opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
.peer-rail::before {
transition: none;
}
}
</style>

View File

@@ -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 }] : []),
];

View File

@@ -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}
<div id="peers">
<SectionFrame
title="مقایسه با هم‌گروه‌ها"
description="میانه هم‌گروه‌های واجد شرایط؛ هر پذیرنده یک وزن برابر دارد"
title="جایگاه رقابتی"
description="میانه کسب‌وکارهای مشابه واجد شرایط؛ هر پذیرنده یک وزن برابر دارد"
state={peers.state}
onRetry={() => retry('peer_comparison')}
>
{#if peers.state === 'SUPPRESSED'}<div class="card p-7 text-center">
<p class="text-lg font-bold">برای مقایسه امن، داده کافی نیست</p>
<p class="muted mt-2">
تا رسیدن گروه به حداقل حریم خصوصی، هیچ مقدار یا هویت هم‌گروهی نمایش داده نمی‌شود.
تا رسیدن گروه به حداقل حریم خصوصی، هیچ مقدار یا هویت کسب‌وکار مشابهی نمایش داده نمی‌شود.
</p>
</div>
{:else if peers.state === 'READY'}<div class="card overflow-hidden">

View File

@@ -178,19 +178,19 @@
<Card class="rounded-lg shadow-xs">
<CardHeader
><CardTitle class="flex items-center gap-2 text-base"
><UsersRoundIcon class="text-primary size-5" /> مقایسه امن با هم‌گروه‌ها</CardTitle
><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)} کسب‌وکار واجد شرایط و بدون نمایش اطلاعات هیچ
پذیرنده‌ای ساخته شده است.
این مقایسه با {formatValue(peer.eligiblePeerCount ?? 0)} کسب‌وکار مشابه واجد شرایط و بدون نمایش اطلاعات
هیچ پذیرنده‌ای ساخته شده است.
</p>
<div class="bg-muted mt-4 h-2 overflow-hidden rounded-sm" dir="ltr">
<div
@@ -203,7 +203,7 @@
</p>
{:else}
<p class="text-muted-foreground text-sm leading-7">
برای یک مقایسه محرمانه و قابل اتکا، تعداد هم‌گروه‌های واجد شرایط هنوز کافی نیست.
برای یک مقایسه محرمانه و قابل اتکا، تعداد کسب‌وکارهای مشابه واجد شرایط هنوز کافی نیست.
</p>
{/if}
</CardContent>

View File

@@ -41,7 +41,7 @@
<p class="mb-5 text-sm font-semibold text-[#ffd400]">داشبورد اختصاصی پذیرندگان</p>
<h1 class="text-4xl leading-[1.55] font-extrabold">تصمیم‌های بهتر، با تصویری روشن از پرداخت‌ها</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-white/70">
موفقیت پرداخت، تلاش‌های مجدد، کارمزد واقعی و جایگاه شما در میان کسب‌وکارهای هم‌گروه را یک‌جا ببینید.
موفقیت پرداخت، تلاش‌های مجدد، کارمزد واقعی و جایگاه رقابتی شما میان کسب‌وکارهای مشابه را یک‌جا ببینید.
</p>
</div>
<p class="relative text-sm text-white/50">داده‌ها در محدوده حساب پذیرنده شما نمایش داده می‌شوند.</p>
@@ -58,7 +58,7 @@
</div>
<p class="mb-2 text-sm font-semibold text-[var(--brand)]">خوش آمدید</p>
<h1 class="text-3xl font-extrabold">ورود به پنل پذیرنده</h1>
<p class="mt-3 text-muted-foreground">برای مشاهده گزارش‌های کسب‌وکار، وارد حساب آزمایشی خود شوید.</p>
<p class="text-muted-foreground mt-3">برای مشاهده گزارش‌های کسب‌وکار، وارد حساب آزمایشی خود شوید.</p>
<form class="mt-9 space-y-5" onsubmit={submit}>
<div>
@@ -96,7 +96,9 @@
{loading ? 'در حال ورود…' : 'ورود به داشبورد'}
</button>
</form>
<p class="mt-7 text-center text-xs text-muted-foreground">این نسخه برای ارزیابی داده‌های آزمایشی ساخته شده است.</p>
<p class="text-muted-foreground mt-7 text-center text-xs">
این نسخه برای ارزیابی داده‌های آزمایشی ساخته شده است.
</p>
</div>
</section>
</main>

View File

@@ -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('سهم نشست‌ها');
});
});

View File

@@ -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<T extends { metricId: PeerMetricId; merchantPerformancePercentile: string }>(
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<PeerMetricId, string> = {
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];
}