feat: redesign competitive position experience
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('سهم نشستها');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user