ligth theme design fix
This commit is contained in:
146
src/features/dashboard/components/ActivityCard.tsx
Normal file
146
src/features/dashboard/components/ActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/features/dashboard/components/ActivityGroups.tsx
Normal file
68
src/features/dashboard/components/ActivityGroups.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal file
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/features/dashboard/components/AttributionNote.tsx
Normal file
44
src/features/dashboard/components/AttributionNote.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/features/dashboard/components/CampaignPerformance.tsx
Normal file
129
src/features/dashboard/components/CampaignPerformance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/features/dashboard/components/CustomerActivity.tsx
Normal file
80
src/features/dashboard/components/CustomerActivity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/features/dashboard/components/CustomerJourney.tsx
Normal file
114
src/features/dashboard/components/CustomerJourney.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/features/dashboard/components/StoreInsights.tsx
Normal file
108
src/features/dashboard/components/StoreInsights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
491
src/features/dashboard/mock/intelligence.mock.ts
Normal file
491
src/features/dashboard/mock/intelligence.mock.ts
Normal 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]);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
100
src/features/dashboard/services/activityService.ts
Normal file
100
src/features/dashboard/services/activityService.ts
Normal 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;
|
||||
}
|
||||
184
src/features/dashboard/types/intelligence.ts
Normal file
184
src/features/dashboard/types/intelligence.ts
Normal 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;
|
||||
}
|
||||
89
src/features/lyts/components/ActivityProgramme.tsx
Normal file
89
src/features/lyts/components/ActivityProgramme.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user