ligth theme design fix

This commit is contained in:
2026-08-10 15:33:13 +05:30
parent 6cc3c9a0b7
commit 5ce580dced
34 changed files with 2399 additions and 87 deletions

View File

@@ -0,0 +1,146 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {ACCENT} from '@/shared/utils/accent';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
/**
* One activity, at a weight the dashboard can afford.
*
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
* rather than a white card on the page, the number is `display-3` against the
* KPI row's own display-3 but in a card a third the height, and there is no
* sparkline. Six activities rendered at KPI weight would out-shout the four
* metrics that actually describe the business — the same reasoning that made
* store comparison small multiples instead of five full charts.
*
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
* "N customers → N purchases", because an activity count on its own is a
* vanity metric: 724 spins is not a fact a merchant can do anything with until
* they know what it was worth. `detail` opens the chain up to its full length
* for the analytics page, where there is room for the middle of the story.
*/
/**
* Status is a DOT, not a badge.
*
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
* thing on a page whose subject is the numbers beside them, and a third hue
* competing with the two the brand actually owns. An 8px dot carries the same
* three states at a fraction of the ink, which is the project's own rule:
* status → StatusDot, badge → counts and enumerated states.
*/
const STATUS: Record<
ActivityMetric['status'],
{label: string; variant: 'success' | 'warning' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
paused: {label: 'Paused', variant: 'warning'},
draft: {label: 'Draft', variant: 'neutral'},
};
export function ActivityCard({
metric,
variant = 'summary',
}: {
metric: ActivityMetric;
/**
* `summary` — the dashboard's six. Count, delta, and the two ends of the
* impact chain. No status: a summary of six live activities does not need
* six "Live" badges competing with the numbers.
*
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
* rather than reads: whether it is running, what it costs in LYTs, and the
* full chain including reward claims and repeat visits.
*/
variant?: 'summary' | 'catalogue';
}) {
const accent = ACCENT[metric.accent];
const isCatalogue = variant === 'catalogue';
const chain = impactChain(metric, {compact: !isCatalogue});
const status = STATUS[metric.status];
// `card-nested` keeps the dark theme's raised fill and gives the light theme
// a white surface with a hairline edge instead — see globals.css.
return (
<Card variant="muted" className="card-nested">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center" className="min-w-0">
{/*
The only place the brand hue appears on this card. A tinted chip
at 16px is enough to identify the activity at a glance; tinting
the card itself is how a grid of six turns into a paint chart.
*/}
<HStack
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
vAlign="center"
>
<Icon
icon={ACTIVITY_ICON[metric.id]}
size="sm"
className={accent.ink}
/>
</HStack>
<Text size="sm" weight="medium" className="truncate">
{metric.label}
</Text>
</HStack>
{isCatalogue ? (
<HStack gap={1.5} vAlign="center" className="shrink-0">
<StatusDot variant={status.variant} label={status.label} />
<Text size="xsm" color="secondary">
{status.label}
</Text>
</HStack>
) : (
<MetricDelta value={metric.deltaPct} size="xsm" />
)}
</HStack>
{/* Data, not a section title — Text rather than Heading, so a grid of
activity counts stays out of the document outline. */}
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
<Text type="display-3">{formatCompact(metric.count)}</Text>
{/* The catalogue traded its delta for the status badge above, so the
trend comes back down here — a merchant deciding whether to keep
an activity running needs both. */}
{isCatalogue ? (
<MetricDelta value={metric.deltaPct} size="xsm" />
) : null}
</HStack>
{isCatalogue ? (
<Text size="xsm" color="secondary">
{metric.description}
</Text>
) : null}
<Text size="xsm" color="secondary">
{chain
.map((s) => `${formatCount(s.value)} ${s.label}`)
.join(' → ')}
</Text>
{/* The LYT link, and the one number on this card that is MEASURED
rather than attributed — the ledger knows what it issued. Omitted
where the activity grants nothing, rather than shown as zero. */}
{isCatalogue && metric.lytsIssued > 0 ? (
<HStack gap={1} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
<Text size="xsm" color="secondary">
{formatLyt(metric.lytsIssued)} issued
</Text>
</HStack>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The complete ecosystem, grouped by the question each group answers.
*
* Ten activities laid out as one flat grid is a list of features. Split three
* ways it becomes a diagnosis: engagement says what customers are doing,
* growth says what is bringing people in, commerce says whether either turned
* into business. A merchant with a flat conversion number and a rising
* engagement number knows which of the three to look at.
*
* One resource, three panels. The panels share a single fetch, so the groups
* can never disagree about a total, and a group with nothing in it renders its
* own empty state rather than leaving a headed card with a void under it.
*/
export function ActivityGroups({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<VStack gap={5}>
{ACTIVITY_GROUPS.map((group) => (
<PanelCard
key={group.id}
title={group.label}
subtitle={group.question}
actions={<AttributionNote basis={basis} />}
resource={resource}
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
empty={
<EmptyPanel
icon="activityVisit"
title={`No ${group.label.toLowerCase()} activity`}
description="Activities appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.group === group.id)
.map((m) => (
// `catalogue` — this is the management view, so each card
// adds what the dashboard summary has no room for: status,
// LYTs issued, and the full impact chain.
<ActivityCard key={m.id} metric={m} variant="catalogue" />
))}
</Grid>
)}
</PanelCard>
))}
</VStack>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
import {
ACTIVITY_ICON,
conversionPct,
} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Every activity's chain, side by side.
*
* The cards above answer "how is Spin doing?". This answers the question they
* cannot: "which activity is worth running?" — and that is a comparison across
* rows, which is a table. Ten small charts would say the same thing in ten
* times the space and still not let anyone rank the last column.
*
* Rows are ordered by the END of the chain, not the start. Sorting by
* interactions puts Walk on top, which is the activity that converts worst;
* sorting by attributed revenue puts the table in the order a merchant would
* spend their next hour in.
*/
/** A flattened row. The table generic needs an index signature; the domain
* type deliberately does not have one. */
interface ImpactRow extends Record<string, unknown> {
id: string;
label: string;
accent: ActivityMetric['accent'];
activityId: ActivityMetric['id'];
count: number;
purchases: number;
conversion: number;
revenueInr: number;
}
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
return metrics
.map((m) => ({
id: m.id,
label: m.label,
accent: m.accent,
activityId: m.id,
count: m.count,
purchases: m.impact.purchases,
conversion: conversionPct(m),
revenueInr: m.impact.attributedRevenueInr,
}))
.sort((a, b) => b.revenueInr - a.revenueInr);
}
const num = (v: number) => (
<Text size="sm" className="tabular-nums">
{formatCount(v)}
</Text>
);
const COLUMNS: TableColumn<ImpactRow>[] = [
{
key: 'label',
header: 'Activity',
width: proportional(1),
// Label only. The one-line description belongs on the cards above, where
// there is width for it; in a cell 120px wide it wrapped to four lines and
// tripled the height of every row in a table whose whole job is to be
// scanned down a column.
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Icon
icon={ACTIVITY_ICON[row.activityId]}
size="sm"
className={ACCENT[row.accent].ink}
/>
<Text size="sm" weight="medium">
{row.label}
</Text>
</HStack>
),
},
{
key: 'count',
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
// so a header longer than its column is silently truncated to "Interacti…"
// at EVERY width, because these columns are fixed px. The subtitle already
// says what is being counted; the header only has to say which column it
// is, and a short one lets the whole set fit the 525px the panel gives it
// with Loyaly AI open.
header: 'Count',
align: 'end',
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
// is ~625px — enough width that the LAST column, the attributed revenue
// this table is sorted by, fell off the edge behind an internal scrollbar.
// The trend is already on every card above; the ranking is only here.
width: pixel(80),
renderCell: (row) => num(row.count),
},
/*
* FIVE columns, and the number is measured rather than chosen. Inside a
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
* each side, plus the 24/32px cell inset contract from globals.css. Six
* columns needed 580 and pushed `Attributed` — the column the table is
* SORTED by — behind an internal scrollbar, which makes the ranking
* invisible at exactly the width most merchants use.
*
* So the two intermediate chain steps are dropped here: customers and
* repeat visits are on every card above and in the phone fallback. What
* survives is what ranking needs — how much happened, what it produced,
* how efficiently, and what it was worth.
*/
{
key: 'purchases',
header: 'Purchases',
align: 'end',
width: pixel(105),
renderCell: (row) => num(row.purchases),
},
{
key: 'conversion',
header: 'Converted',
align: 'end',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium" className="tabular-nums">
{formatPct(row.conversion, 0)}
</Text>
),
},
{
key: 'revenueInr',
// "Revenue" would be a claim this data cannot support. The column ranks
// the table, so it is the one header that most needs to be honest.
header: 'Attributed',
align: 'end',
width: pixel(112),
renderCell: (row) => (
<Text size="sm" weight="semibold" className="tabular-nums">
{formatInrCompact(row.revenueInr)}
</Text>
),
},
];
export function ActivityImpactTable({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Activity impact"
subtitle="Interactions through to estimated attributed revenue, highest first"
resource={resource}
loading={<SkeletonRows count={6} height={44} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="analytics"
title="Nothing to compare yet"
description="Once two or more activities have run, their conversion can be ranked here."
/>
}
>
{(metrics) => (
<ResponsiveTable
data={toRows(metrics)}
columns={COLUMNS}
idKey="id"
primaryKey="label"
density="balanced"
// Conversion earns its place on the phone card: it is the one field
// that ranks activities against each other, which is the whole
// reason this table exists. Repeat visits is the one dropped.
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
/>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Icon} from '@astryxdesign/core/Icon';
import {Tooltip} from '@astryxdesign/core/Tooltip';
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
/**
* The disclosure that has to sit beside any attributed figure.
*
* Every downstream number in the activity chain — repeat visits, purchases,
* revenue — is MODELLED from observed footfall and purchase patterns. None of
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
* to "Selfie" without saying so invites a merchant to read it as "selfies
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
* product when the till disagrees.
*
* So the panels that show attributed figures carry this mark, and the copy
* around them says "attributed", never "generated" or "earned".
*
* It renders NOTHING when the basis is `'observed'`. That is the point of the
* flag: when a backend arrives that can join purchases to activity events, the
* disclosure retires itself with no copy edit and no component removal.
*
* A quiet secondary glyph, not a warning — this is a footnote about method,
* and an amber icon would rank it above the data it annotates.
*/
export function AttributionNote({basis}: {basis: AttributionBasis}) {
if (basis === 'observed') return null;
return (
<Tooltip content={ATTRIBUTION_NOTE}>
{/*
Focusable, so the note is reachable by keyboard and not only by hover —
it is the only place the estimation is explained, which makes it
content rather than decoration.
*/}
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
</HStack>
</Tooltip>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact} from '@/shared/utils/format';
import {ACTIVITY_ICON} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {
CampaignStatus,
CampaignSummary,
} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* A campaign is a funnel with a name, so each one is a ROW, not a chart.
*
* The temptation here is a second analytics module — participation over time,
* a conversion gauge per campaign, a comparison bar. That is four more charts
* for a question with one shape: how many took part, how many came back, how
* many bought, what it earned. Four numbers fit on a row, and four rows fit in
* the space one chart would have taken.
*/
/**
* `live` is the only status a merchant can still act on, so it is the only one
* that gets a filled badge. Ended and scheduled are stated, not highlighted.
*/
const STATUS: Record<
CampaignStatus,
{label: string; variant: 'success' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
ended: {label: 'Ended', variant: 'neutral'},
scheduled: {label: 'Scheduled', variant: 'neutral'},
};
function CampaignRow({campaign}: {campaign: CampaignSummary}) {
const accent = ACCENT[campaign.accent];
const status = STATUS[campaign.status];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack className={`${accent.soft} rounded-md p-1.5 shrink-0`} vAlign="center">
<Icon
icon={ACTIVITY_ICON[campaign.activityId]}
size="sm"
className={accent.ink}
/>
</HStack>
<VStack gap={1} width="100%">
<HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{campaign.name}
</Text>
<Badge variant={status.variant} label={status.label} />
</HStack>
{/*
The row's outcome, at the end where the eye lands last — the same
place a receipt puts it. And that is exactly why it cannot be a
bare rupee figure: "Weekend Challenge … ₹5L" reads as money the
campaign earned. It is money attributed to it, so the word rides
with the number rather than living only in a tooltip.
*/}
<HStack gap={1} vAlign="center">
<Text size="sm" weight="semibold">
{formatInrCompact(campaign.attributedRevenueInr)}
</Text>
<Text size="xsm" color="secondary">
{campaign.attribution === 'estimated' ? 'attributed (est.)' : 'attributed'}
</Text>
</HStack>
</HStack>
<Text size="sm" color="secondary">
{campaign.steps
.map((s) => `${formatCount(s.value)} ${s.label.toLowerCase()}`)
.join(' → ')}
</Text>
</VStack>
</HStack>
);
}
export function CampaignPerformance({
resource,
}: {
resource: Resource<CampaignSummary[]>;
}) {
const basis = resource.data?.[0]?.attribution ?? 'estimated';
return (
<PanelCard
title="Campaign performance"
subtitle="Participants through to attributed revenue, per campaign"
resource={resource}
loading={<SkeletonRows count={4} height={52} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="campaign"
title="No campaigns running"
description="Challenges, referral drives and events show their funnel here once they go live."
/>
}
>
{(campaigns) => (
<VStack gap={0}>
{campaigns.map((c) => (
<CampaignRow key={c.id} campaign={c} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The dashboard's activity summary — six of the ten, and no more.
*
* The full ecosystem is ten activities. Rendering all ten here, at equal
* weight, is how a dashboard becomes a legend: the merchant is asked to hold
* ten categories in their head before they have learned what any single one is
* worth. So the payload carries all ten, this panel filters to the featured
* six, and "View all activity" leads to the page that has room for the rest.
*
* One panel, not six cards on the page. The section reads as a single band of
* secondary information sitting under the primary metrics and the two headline
* charts, which is exactly its rank in the hierarchy.
*/
export function CustomerActivity({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
// Read off the payload rather than hardcoded, so the disclosure retires
// itself the day a backend returns 'observed'.
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Customer activity"
subtitle="What customers did, and what it is estimated to have been worth"
resource={resource}
loading={<SkeletonCardGrid count={6} height={116} minWidth={200} />}
actions={
<>
{/* Every figure after "customers" on these cards is attributed, not
measured. The note says so once for the whole panel. */}
<AttributionNote basis={basis} />
{/*
/lyts, not /activity. The full ecosystem is the Lyts page's job —
all ten activities with their status and LYT cost — and /activity
is the raw event log, which is a different question. Sending "view
all" to the log would answer "what happened at 14:32" when the
merchant asked "what else can customers do".
*/}
<Button
variant="ghost"
size="sm"
label="View all activity"
href="/lyts"
/>
</>
}
empty={
<EmptyPanel
icon="activityVisit"
title="No activity recorded"
description="Visits, spins, challenges and referrals appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.isFeatured)
.map((m) => (
<ActivityCard key={m.id} metric={m} />
))}
</Grid>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import {Fragment} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {JourneyStage} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Visit → Engage → Purchase → Return → Refer, as five numbers and four arrows.
*
* NOT a Sankey, and not a node graph. A funnel of five stages carries exactly
* five numbers and four ratios; a flow diagram spends 400px of vertical space
* and a chart library rendering the same nine facts, and the merchant still
* has to read the labels to know which band is which. The horizontal strip
* says it in one line.
*
* The panel's actual job is the WEAKEST link, so it is called out rather than
* left to be spotted: the stage with the lowest conversion from its
* predecessor gets the badge. Everything else on this strip is context for
* that one finding.
*/
export function CustomerJourney({
resource,
}: {
resource: Resource<JourneyStage[]>;
}) {
return (
<PanelCard
title="Customer journey"
subtitle="Where customers progress, and where they stop"
resource={resource}
loading={<SkeletonRows count={2} height={56} />}
empty={
<EmptyPanel
icon="journey"
title="No journey to plot"
description="Once visits and purchases are recorded the progression appears here."
/>
}
>
{(stages) => {
// The weakest step, measured against the stage before it. The first
// stage has nothing to convert from and can never be the answer.
const weakest = stages.reduce<JourneyStage | undefined>(
(worst, s) =>
s.conversionPct === undefined
? worst
: worst?.conversionPct === undefined ||
s.conversionPct < worst.conversionPct
? s
: worst,
undefined,
);
return (
<HStack gap={4} vAlign="start" wrap="wrap">
{stages.map((stage, i) => (
<Fragment key={stage.id}>
{i === 0 ? null : (
// Decorative: the reading order already carries the
// progression, so the arrow is not announced.
//
// Hidden below `sm`, where the strip wraps to two per row and
// a connector ends up pointing at the start of a line or at
// the stage above it — an arrow that lies about the order is
// worse than no arrow, and stacked cards already read as a
// sequence.
<HStack className="hidden pt-6 sm:flex" vAlign="center">
<Icon
icon={ICONS.arrowRight}
size="sm"
className={ACCENT.cool.ink}
/>
</HStack>
)}
<VStack gap={1}>
<Text size="sm" color="secondary">
{stage.label}
</Text>
<Text type="display-3">{formatCompact(stage.value)}</Text>
{stage.conversionPct === undefined ? (
<Text size="xsm" color="secondary">
Everyone starts here
</Text>
) : stage.id === weakest?.id ? (
<Badge
variant="warning"
label={`${formatPct(stage.conversionPct, 0)} · biggest drop-off`}
/>
) : (
<Text size="xsm" color="secondary">
{formatPct(stage.conversionPct, 0)} of previous
</Text>
)}
</VStack>
</Fragment>
))}
</HStack>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {IconColor} from '@astryxdesign/core/Icon';
import type {Resource} from '@/shared/hooks/useResource';
/**
* What needs your attention — findings, not a chat box.
*
* The thing that makes an AI panel get ignored is a paragraph of narration
* with nothing at the end of it. So the contract here is strict: every row
* states a number the merchant can check against the panels above, says what
* it means, and ends in ONE action. An insight with no action does not belong
* on this panel; it belongs in whichever chart already shows it.
*
* Severity earns the only colour on the row. A warning is amber because a
* warning is one of the three semantic states; everything else is a cool-tinted
* bulb, which is this product's mark for "the system worked this out" and is
* the same hue the analytics carry.
*/
const SEVERITY: Record<
InsightSeverity,
{icon: IconKey; color?: IconColor; className?: string}
> = {
error: {icon: 'alert', color: 'error'},
warning: {icon: 'alert', color: 'warning'},
success: {icon: 'leaderboard', color: 'success'},
info: {icon: 'insight', className: ACCENT.cool.ink},
};
function InsightRow({insight}: {insight: Insight}) {
const tone = SEVERITY[insight.severity];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[tone.icon]}
size="sm"
color={tone.color}
className={tone.className}
label={insight.severity}
/>
</HStack>
<VStack gap={1} width="100%">
<Text size="sm" weight="medium">
{insight.title}
</Text>
<Text size="sm" color="secondary">
{insight.body}
</Text>
{insight.action ? (
<HStack>
<Button
variant="secondary"
size="sm"
label={insight.action.label}
href={insight.action.href}
/>
</HStack>
) : null}
</VStack>
</HStack>
);
}
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
return (
<PanelCard
title="What needs your attention"
subtitle="Read from this period's activity, most urgent first"
resource={resource}
loading={<SkeletonRows count={3} height={72} />}
empty={
<EmptyPanel
icon="insight"
title="Nothing needs attention"
description="Findings appear here when an activity's performance moves enough to act on."
/>
}
>
{(insights) => (
<VStack gap={0}>
{insights.map((i) => (
<InsightRow key={i.id} insight={i} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -56,3 +56,34 @@ export function useDashboardPerformance(granularity: Granularity) {
export function useDashboardBriefing() {
return useResource(dashboardRepository.briefing(useScope()));
}
// ---------------------------------------------------------------------------
// Store intelligence — the activity layer
// ---------------------------------------------------------------------------
/**
* All ten activities, always. The dashboard filters to `isFeatured` in the
* component rather than asking the server for a subset: the same request then
* serves the summary and the full Activity Analytics page, so the two cannot
* report different counts for Spin, and moving an activity into or out of the
* summary is a fixture change rather than an API change.
*/
export function useActivityMetrics(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.activityMetrics(scope ?? active));
}
export function useCustomerJourney(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.journey(scope ?? active));
}
export function useCampaigns(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.campaigns(scope ?? active));
}
export function useStoreInsights(scope?: Scope) {
const active = useScope();
return useResource(dashboardRepository.insights(scope ?? active));
}

View File

@@ -0,0 +1,491 @@
import type {Insight} from '@/features/dashboard/types/dashboard';
import type {
ActivityId,
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {buildTimeseries} from './dashboard.mock';
/**
* Activity fixtures, DERIVED rather than invented.
*
* Every number here is anchored to buildTimeseries() — the same generator the
* Footfall and Revenue charts read. That is not tidiness: an activity layer
* generated independently would let the dashboard claim 1,248 visitors in one
* card and 3,400 visits in the card directly below it, and no merchant would
* trust either number again. Activity counts are a share of real visitors,
* journey stages are the real funnel, and campaign rows are slices of the
* activity they run on.
*
* The chain is also enforced by construction: customers ≤ interactions,
* repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can
* produce more purchases than participants would make the funnel UI render
* a widening cone, and the bug would look like a design problem.
*/
interface ActivitySeed {
id: ActivityId;
label: string;
description: string;
group: ActivityMetric['group'];
accent: ActivityMetric['accent'];
isFeatured: boolean;
/** Interactions per visitor. Above 1 means a visitor does it more than once. */
perVisitor: number;
/** Interactions per distinct customer — the repeat rate within the activity. */
intensity: number;
/** Fraction of participants who come back afterwards. */
returnRate: number;
/** Fraction of returning participants who then buy. */
buyRate: number;
/** Whether taking part can grant a reward. */
hasRewards: boolean;
/** LYTs granted per participating customer, on average. 0 where none. */
lytsPerCustomer: number;
status: ActivityMetric['status'];
}
/**
* The ecosystem, in reading order.
*
* Accents alternate deliberately and are fixed per activity: warm is what the
* brand gives the customer, cool is what the system measures back. Two hues
* across ten rows is what keeps this from becoming a colour-coded legend
* nobody can hold in their head.
*
* `isFeatured` is what the dashboard shows. Six is the ceiling — the summary
* exists to be glanced at, and ten small cards is a second dashboard.
*/
const CATALOG: ActivitySeed[] = [
{
id: 'walk',
label: 'Walk',
description: 'Passers-by detected near the store',
group: 'engagement',
accent: 'warm',
isFeatured: false,
perVisitor: 1.9,
intensity: 1.4,
returnRate: 0.11,
buyRate: 0.34,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
{
id: 'visit',
label: 'Visit',
description: 'Customers who checked in at the store',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 1.0,
intensity: 1.7,
returnRate: 0.31,
buyRate: 0.52,
hasRewards: false,
lytsPerCustomer: 12,
status: 'live',
},
{
id: 'selfie',
label: 'Selfie',
description: 'In-store photos shared to the feed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.3,
intensity: 1.8,
returnRate: 0.32,
buyRate: 0.47,
hasRewards: true,
lytsPerCustomer: 25,
status: 'live',
},
{
id: 'spin',
label: 'Spin',
description: 'Reward wheel plays',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 0.58,
intensity: 1.76,
returnRate: 0.23,
buyRate: 0.43,
hasRewards: true,
lytsPerCustomer: 40,
status: 'live',
},
{
id: 'scratch',
label: 'Scratch',
description: 'Scratch cards opened',
group: 'engagement',
accent: 'cool',
isFeatured: false,
perVisitor: 0.36,
intensity: 1.5,
returnRate: 0.19,
buyRate: 0.38,
hasRewards: true,
lytsPerCustomer: 30,
status: 'live',
},
{
id: 'challenge',
label: 'Challenge',
description: 'Multi-step tasks completed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.17,
intensity: 1.2,
returnRate: 0.48,
buyRate: 0.55,
hasRewards: true,
lytsPerCustomer: 75,
status: 'live',
},
{
id: 'friend',
label: 'Referral',
description: 'Friends invited by existing customers',
group: 'growth',
accent: 'warm',
isFeatured: true,
perVisitor: 0.07,
intensity: 1.1,
returnRate: 0.42,
buyRate: 0.58,
hasRewards: true,
lytsPerCustomer: 120,
status: 'live',
},
{
id: 'event',
label: 'Event',
description: 'Attendance at in-store events',
group: 'growth',
accent: 'cool',
isFeatured: true,
perVisitor: 0.11,
intensity: 1.05,
returnRate: 0.37,
buyRate: 0.62,
hasRewards: false,
lytsPerCustomer: 50,
status: 'paused',
},
{
id: 'brand',
label: 'Brand campaign',
description: 'Reach from partner and brand pushes',
group: 'growth',
accent: 'warm',
isFeatured: false,
perVisitor: 0.24,
intensity: 1.3,
returnRate: 0.16,
buyRate: 0.29,
hasRewards: true,
lytsPerCustomer: 20,
status: 'draft',
},
{
id: 'shop',
label: 'Shop',
description: 'Catalogue browsing that ended in a basket',
group: 'commerce',
accent: 'cool',
isFeatured: false,
perVisitor: 0.26,
intensity: 1.15,
returnRate: 0.35,
buyRate: 0.71,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
];
function totalVisitors(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.visitors,
0,
);
}
function totalPurchases(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.purchases,
0,
);
}
/**
* One activity's numbers for a scope.
*
* Every step is clamped to at least 1 below its parent once the parent is
* non-trivial, so the funnel narrows even for a quiet store where rounding
* would otherwise collapse two stages onto the same value and make a 100%
* conversion appear out of nowhere.
*/
function buildMetric(
seed: ActivitySeed,
visitors: number,
range: RangeKey,
storeId: string,
): ActivityMetric {
const rng = createRng('activity-metric', seed.id, storeId, range);
const jitter = rng.float(0.88, 1.12);
const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter));
const customers = Math.max(1, Math.round(count / seed.intensity));
const repeatVisits = Math.round(customers * seed.returnRate);
const purchases = Math.round(repeatVisits * seed.buyRate);
const attributedRevenueInr = Math.round(purchases * rng.float(310, 540));
return {
id: seed.id,
label: seed.label,
description: seed.description,
group: seed.group,
accent: seed.accent,
count,
status: seed.status,
// Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who
// spins six times is not granted six rewards, and multiplying by `count`
// would inflate the liability by the intensity factor on every activity.
lytsIssued: Math.round(customers * seed.lytsPerCustomer),
// Compared against the previous period of equal length, same convention as
// the KPI deltas. Drawn rather than recomputed: the previous window's
// activity split is not something the timeseries carries.
deltaPct: Number(rng.float(-9, 24).toFixed(1)),
impact: {
customers,
rewardClaims: seed.hasRewards
? Math.round(customers * rng.float(0.36, 0.62))
: undefined,
repeatVisits,
purchases,
attributedRevenueInr,
// Modelled, and says so. The whole chain above is derived from observed
// footfall through fixed rates — no purchase here is joined to a
// specific activity event, and the UI reads this field to disclose that.
attribution: 'estimated',
},
isFeatured: seed.isFeatured,
};
}
export function buildActivityMetrics(
range: RangeKey,
storeId: string,
endMs: number,
): ActivityMetric[] {
const visitors = totalVisitors(range, storeId, endMs);
return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId));
}
/**
* The five-stage progression, built from the funnel that already exists.
*
* Visit and Purchase are NOT invented — they are the same visitor and purchase
* totals the KPI row shows, so the first and third stage of the journey and
* the first two KPI cards can never disagree. Engage and Return come from the
* activity layer, Refer is the referral activity's own count.
*/
export function buildJourney(
range: RangeKey,
storeId: string,
endMs: number,
): JourneyStage[] {
const visitors = totalVisitors(range, storeId, endMs);
const purchases = totalPurchases(range, storeId, endMs);
const metrics = buildActivityMetrics(range, storeId, endMs);
const engaged = metrics
.filter((m) => m.group === 'engagement' && m.id !== 'walk')
.reduce((a, m) => Math.max(a, m.impact.customers), 0);
const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0;
const returned = Math.round(purchases * 0.69);
const raw: {id: JourneyStage['id']; label: string; value: number}[] = [
{id: 'visit', label: 'Visit', value: visitors},
// Engagement cannot exceed footfall, and on a quiet store the strongest
// single activity can round above it.
{id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)},
{id: 'purchase', label: 'Purchase', value: purchases},
{id: 'return', label: 'Return', value: returned},
{id: 'refer', label: 'Refer', value: Math.min(referrals, returned)},
];
return raw.map((stage, i) => ({
...stage,
conversionPct:
i === 0 || raw[i - 1].value === 0
? undefined
: Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)),
}));
}
/** Campaign name and framing per activity. The numbers come from the activity. */
const CAMPAIGNS: {
id: string;
name: string;
activityId: ActivityId;
status: CampaignSummary['status'];
/** Labels for the three funnel steps, in order. */
steps: [string, string, string];
}[] = [
{
id: 'c-challenge',
name: 'Weekend Challenge',
activityId: 'challenge',
status: 'live',
steps: ['Participants', 'Repeat visits', 'Purchases'],
},
{
id: 'c-friend',
name: 'Refer a Friend',
activityId: 'friend',
status: 'live',
steps: ['Referrals', 'New customers', 'Purchases'],
},
{
id: 'c-event',
name: 'Saturday Event',
activityId: 'event',
status: 'ended',
steps: ['Attendees', 'Repeat visits', 'Purchases'],
},
{
id: 'c-spin',
name: 'Spin & Win',
activityId: 'spin',
status: 'live',
steps: ['Spins', 'Reward claims', 'Purchases'],
},
];
export function buildCampaigns(
range: RangeKey,
storeId: string,
endMs: number,
): CampaignSummary[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
return CAMPAIGNS.flatMap((c) => {
const m = metrics.find((x) => x.id === c.activityId);
if (!m) return [];
// The middle step differs by campaign shape: a referral drive converts to
// new customers, a spin converts to claimed rewards, everything else to a
// return visit. Reading it off the impact chain rather than drawing a
// fresh number is what keeps a campaign row consistent with the activity
// card above it.
const middle =
c.activityId === 'friend'
? m.impact.customers
: c.activityId === 'spin'
? (m.impact.rewardClaims ?? m.impact.repeatVisits)
: m.impact.repeatVisits;
return [
{
id: c.id,
name: c.name,
activityId: m.id,
accent: m.accent,
status: c.status,
steps: [
{label: c.steps[0], value: m.count},
{label: c.steps[1], value: middle},
{label: c.steps[2], value: m.impact.purchases},
],
attributedRevenueInr: m.impact.attributedRevenueInr,
attribution: m.impact.attribution,
},
];
});
}
const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100);
/**
* Insights, computed from the numbers actually on screen.
*
* Deliberately not a static list of sentences. Every claim below is derived
* from the same fixtures the panels render, so an insight cannot contradict
* the card next to it — which is the failure mode that makes merchants stop
* reading an AI panel after the second week.
*
* Each one carries an action. An observation with no next step belongs in a
* chart, not in a section called "What needs your attention".
*/
export function buildInsights(
range: RangeKey,
storeId: string,
endMs: number,
): Insight[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
const by = (id: ActivityId) => metrics.find((m) => m.id === id)!;
const spin = by('spin');
const challenge = by('challenge');
const visit = by('visit');
const friend = by('friend');
const spinConversion = pct(spin.impact.purchases, spin.impact.customers);
const challengeReturn = pct(
challenge.impact.repeatVisits,
challenge.impact.customers,
);
const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers);
const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn;
const visitConversion = pct(visit.impact.purchases, visit.impact.customers);
const insights: Insight[] = [
{
id: 'i-spin',
severity: spinConversion < 15 ? 'warning' : 'info',
// The headline states the CONVERSION, not the direction of the count.
// "Spin engagement is up 16%" reads as good news and is the wrong thing
// to lead with — and it is also plainly wrong on a period where plays
// fell, which is how a generated insight loses a merchant's trust.
title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`,
body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`,
action: {label: 'Refresh reward catalogue', href: '/lyts'},
},
{
id: 'i-challenge',
severity: 'success',
title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`,
body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`,
action: {label: 'Plan another challenge', href: '/activity'},
},
{
id: 'i-visit',
severity: visitConversion < 20 ? 'warning' : 'info',
title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`,
body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`,
action: {label: 'Launch a visit offer', href: '/lyts'},
},
{
id: 'i-friend',
severity: 'info',
title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`,
body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`,
action: {label: 'Promote the referral reward', href: '/lyts'},
},
];
// Most severe first, so the panel's top row is always the thing that most
// needs attention rather than whichever activity happens to be listed first.
const rank = {error: 0, warning: 1, success: 2, info: 3} as const;
return insights.sort((a, b) => rank[a.severity] - rank[b.severity]);
}

View File

@@ -5,12 +5,18 @@ import type {
DashboardBriefing,
Granularity,
HourCell,
Insight,
Kpi,
PeriodPoint,
RewardUsagePoint,
StoreComparison,
TimePoint,
} from '@/features/dashboard/types/dashboard';
import type {
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
/**
* Every URL the dashboard knows, and the only place it knows them.
@@ -48,4 +54,18 @@ export const dashboardRepository = {
performance: (scope: Scope, granularity: Granularity): Endpoint<PeriodPoint[]> =>
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
// ---- Store intelligence: the activity layer -----------------------------
activityMetrics: (scope: Scope): Endpoint<ActivityMetric[]> =>
scopedEndpoint('/api/dashboard/activity-metrics', scope),
journey: (scope: Scope): Endpoint<JourneyStage[]> =>
scopedEndpoint('/api/dashboard/journey', scope),
campaigns: (scope: Scope): Endpoint<CampaignSummary[]> =>
scopedEndpoint('/api/dashboard/campaigns', scope),
insights: (scope: Scope): Endpoint<Insight[]> =>
scopedEndpoint('/api/dashboard/insights', scope),
};

View File

@@ -0,0 +1,100 @@
/**
* Domain rules for the activity layer.
*
* Two things live here that would otherwise be duplicated in every panel that
* renders an activity: which glyph an activity gets, and how its impact chain
* is read out. Both are decisions about the DOMAIN, not about a layout — the
* dashboard summary, the analytics page and a campaign row must all describe
* Spin the same way, or the merchant is looking at three products.
*/
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
import type {
ActivityGroup,
ActivityId,
ActivityMetric,
} from '@/features/dashboard/types/intelligence';
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
walk: ICONS.activityWalk,
visit: ICONS.activityVisit,
selfie: ICONS.activitySelfie,
spin: ICONS.activitySpin,
scratch: ICONS.activityScratch,
brand: ICONS.activityBrand,
challenge: ICONS.activityChallenge,
friend: ICONS.activityFriend,
shop: ICONS.activityShop,
event: ICONS.activityEvent,
};
/**
* The three questions the grouping answers. The description is the point of
* the group — a heading that only says "Engagement" tells a merchant nothing
* they could not have guessed from the cards under it.
*/
export const ACTIVITY_GROUPS: {
id: ActivityGroup;
label: string;
question: string;
}[] = [
{
id: 'engagement',
label: 'Engagement',
question: 'What are customers doing in and around the store?',
},
{
id: 'growth',
label: 'Growth',
question: 'What is bringing new people in?',
},
{
id: 'commerce',
label: 'Commerce',
question: 'Is any of it converting into business?',
},
];
/** A step in the activity → customer → return → purchase chain. */
export interface ImpactStep {
label: string;
value: number;
}
/**
* The impact chain as an ordered list, ready to render.
*
* `rewardClaims` is dropped rather than zeroed when an activity grants no
* reward: a chain reading "→ 0 reward claims" states a failure where there is
* only an absence, and a merchant reads those very differently.
*
* `compact` drops the middle of the chain for the dashboard's small cards,
* which have room for the two ends of the story and not the whole of it. The
* full chain always survives on /activity — this trims the summary, it never
* decides what the data contains.
*/
export function impactChain(
m: ActivityMetric,
{compact = false}: {compact?: boolean} = {},
): ImpactStep[] {
const steps: ImpactStep[] = [
{label: 'customers', value: m.impact.customers},
];
if (!compact && m.impact.rewardClaims !== undefined) {
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
}
if (!compact) {
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
}
steps.push({label: 'purchases', value: m.impact.purchases});
return steps;
}
/** Share of participants who ended up buying — the number the chain exists for. */
export function conversionPct(m: ActivityMetric): number {
if (m.impact.customers === 0) return 0;
return (m.impact.purchases / m.impact.customers) * 100;
}

View File

@@ -0,0 +1,184 @@
/**
* Store intelligence contracts — the activity layer.
*
* Sits beside dashboard.ts and answers a different question. Those types
* describe what the STORE did (visitors, revenue, conversion). These describe
* what CUSTOMERS did, and — the part that makes it intelligence rather than a
* leaderboard — what each of those things was worth.
*
* THE ONE RULE THIS FILE ENCODES
* An activity count on its own is a vanity metric. 724 spins tells a merchant
* nothing they can act on. So `count` never travels alone: every activity
* carries an `ActivityImpact` describing the chain
*
* activity → customers → return visits → purchases
*
* and every surface that renders an activity is expected to show at least one
* downstream step. The numbers behind this are fixtures today; the SHAPE is
* the contract a real backend has to satisfy, and it is deliberately not
* "count plus a delta" — that would let the useful half be dropped silently.
*/
import type {BrandAccent} from '@/shared/utils/accent';
/**
* How an impact figure was arrived at. Required, never defaulted.
*
* This is the most important field in the file. Today every number in the
* chain below is MODELLED — a share of observed footfall pushed through fixed
* conversion rates — and a modelled ₹6.5L rendered in the same type as a real
* one is how a dashboard quietly starts lying. Making the basis part of the
* payload means the UI can label it honestly without guessing, and a backend
* that later joins real till receipts to real activity events flips this to
* `'observed'` and the disclosure disappears on its own. Nothing else has to
* change: the field names already say "attributed", not "earned".
*
* estimated modelled from observed activity and purchase patterns
* observed each purchase is joined to a specific activity event
*/
export type AttributionBasis = 'estimated' | 'observed';
/**
* The one sentence the UI shows wherever an attributed figure appears. Defined
* once so the disclosure cannot drift between the three panels that carry it.
*/
export const ATTRIBUTION_NOTE =
'Attribution is estimated from observed customer activity and purchase patterns. It shows association, not proven cause.';
/**
* The complete activity ecosystem. The dashboard summarises the six marked
* `isFeatured`; all ten live on /activity.
*/
export type ActivityId =
| 'walk'
| 'visit'
| 'selfie'
| 'spin'
| 'scratch'
| 'brand'
| 'challenge'
| 'friend'
| 'shop'
| 'event';
/**
* What the merchant is asking when they look at a group.
*
* engagement "what are customers doing?"
* growth "what is bringing people in?"
* commerce "is any of it converting?"
*/
export type ActivityGroup = 'engagement' | 'growth' | 'commerce';
/**
* The chain that connects an interaction to money.
*
* Read every field after `customers` as ATTRIBUTED rather than caused. A
* customer who spun the wheel and later bought something is an association;
* whether the spin is why they bought is not knowable from this data, and the
* UI must not phrase it as though it were.
*/
export interface ActivityImpact {
/** Distinct customers behind the interactions. Directly observed. */
customers: number;
/** Rewards claimed off the back of it. Absent where the activity grants none. */
rewardClaims?: number;
/** Participants who returned to the store within the period. */
repeatVisits: number;
/** Purchases by those participants, attributed to this activity. */
purchases: number;
/**
* Revenue on those purchases. Named `attributed` rather than `revenue`
* because that is what it is — see AttributionBasis.
*/
attributedRevenueInr: number;
attribution: AttributionBasis;
}
/**
* Whether the merchant is currently running the activity.
*
* Lives on the shared metric rather than on a Lyts-only type: the dashboard
* and the Lyts catalogue must never disagree about whether Spin is live, and
* two types would guarantee that they eventually do.
*/
export type ActivityStatus = 'live' | 'paused' | 'draft';
export interface ActivityMetric {
id: ActivityId;
label: string;
/** One line on what the activity is — the merchant may not have run it yet. */
description: string;
group: ActivityGroup;
/**
* Fixed per activity, carried on the payload rather than derived from grid
* position, so an activity keeps its colour across every screen it appears
* on and a reordered grid does not repaint.
*/
accent: BrandAccent;
/** Interactions recorded in the period — the headline number. */
count: number;
deltaPct: number;
/**
* Whether the activity is running. The dashboard summary does not show this
* — a summary of six live activities does not need six "Live" badges — but
* the Lyts catalogue is where a merchant turns things on and off, so it is
* the same field rather than a second source of truth.
*/
status: ActivityStatus;
/**
* LYTs issued through this activity in the period. 1 LYT = ₹1, so this is
* simultaneously a count and the rupee liability the activity created —
* which is what connects the activity model to the Lyts programme.
*
* Directly observed, unlike everything in `impact` past `customers`: the
* ledger knows exactly how many LYTs it issued and why.
*/
lytsIssued: number;
impact: ActivityImpact;
/** Surfaced on the dashboard summary. The rest are /activity only. */
isFeatured: boolean;
}
export type JourneyStageId =
| 'visit'
| 'engage'
| 'purchase'
| 'return'
| 'refer';
export interface JourneyStage {
id: JourneyStageId;
label: string;
/** Customers who reached this stage. Monotonically decreasing by definition. */
value: number;
/**
* Share of the PREVIOUS stage that made it here. Undefined on the first
* stage, which has nothing to convert from.
*/
conversionPct?: number;
}
export type CampaignStatus = 'live' | 'ended' | 'scheduled';
export interface CampaignStep {
label: string;
value: number;
}
export interface CampaignSummary {
id: string;
name: string;
/** The activity it runs on — gives the row its icon and accent. */
activityId: ActivityId;
accent: BrandAccent;
status: CampaignStatus;
/**
* Participants → engagement → conversion, in order. A campaign that is not
* a funnel is not a campaign, so this is a list rather than named fields:
* a referral drive ends at "new customers", an event ends at "purchases".
*/
steps: CampaignStep[];
attributedRevenueInr: number;
attribution: AttributionBasis;
}

View File

@@ -0,0 +1,89 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups';
import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable';
import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard';
import {formatCompact, formatLyt} from '@/shared/utils/format';
/**
* The activity ecosystem, as the Lyts page presents it.
*
* ── Why this lives here and not on the dashboard ──────────────────────────
* The two pages ask different questions of the same data. The dashboard asks
* "how are customers engaging and what is it worth?" and answers with six
* activities and their business impact. Lyts asks "what am I running, what
* does it cost me in LYTs, and is it working?" — which needs all ten, their
* status, and the LYT liability each one creates.
*
* ── One model, not two ────────────────────────────────────────────────────
* Everything here reads `useActivityMetrics()` — the same hook, the same
* endpoint, the same fixtures the dashboard uses. If the dashboard says Visit
* is 35.3k, this page says 35.3k, because there is nowhere for a second
* number to come from. The grouped cards and the impact table are the exact
* components the dashboard's own activity work produced, rendered in
* `catalogue` variant rather than reimplemented.
*
* The summary strip is StatPairs rather than MetricCards on purpose: the KPI
* row directly above it is already four MetricCards about LYT liability, and
* a second row of the same object would read as eight equal headline metrics
* on a page whose headline is the programme, not the activities.
*/
export function ActivityProgramme() {
const metrics = useActivityMetrics();
return (
<VStack gap={5}>
<StaticPanel
title="Customer activities"
subtitle="Every way a customer can earn, and what each one issues in LYTs"
>
{/*
Derived at render rather than fetched as its own summary endpoint.
A totals endpoint could disagree with the cards below it — this
cannot, because it is the same array.
*/}
{metrics.status === 'success' || metrics.status === 'empty' ? (
<StatRow>
<StatPair
label="Live activities"
value={`${metrics.data.filter((m) => m.status === 'live').length} of ${metrics.data.length}`}
/>
<StatPair
label="Total participation"
value={formatCompact(
metrics.data.reduce((a, m) => a + m.count, 0),
)}
/>
<StatPair
label="Customers reached"
value={formatCompact(
// The MAXIMUM, not the sum: one customer who visits, spins and
// takes a selfie is one customer. Adding the per-activity
// counts would claim three, and inflate "customers reached"
// past the store's own footfall.
metrics.data.reduce(
(a, m) => Math.max(a, m.impact.customers),
0,
),
)}
/>
<StatPair
label="LYTs issued"
value={formatLyt(
metrics.data.reduce((a, m) => a + m.lytsIssued, 0),
)}
align="end"
/>
</StatRow>
) : null}
</StaticPanel>
<ActivityGroups resource={metrics} />
<ActivityImpactTable resource={metrics} />
</VStack>
);
}

View File

@@ -58,18 +58,6 @@ export const SETTINGS_NAV: SettingsSection[] = [
icon: ICONS.revenue,
description: 'Subscription plans, payment methods and LYT settlement.',
},
{
label: 'Integrations',
href: '/settings/integrations',
icon: ICONS.integrations,
description: 'Connect Shopify, WooCommerce, Razorpay, WhatsApp and Meta.',
},
{
label: 'API & Webhooks',
href: '/settings/api',
icon: ICONS.api,
description: 'Developer API keys, webhook endpoints and delivery logs.',
},
{
label: 'Security',
href: '/settings/security',