Files
zarinpal-analytics-frontend/src/lib/components/GrowthInsights.svelte

208 lines
9.1 KiB
Svelte

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