first commit

This commit is contained in:
2026-09-25 16:31:10 +05:30
parent e43cebb43e
commit 88f658fba9
378 changed files with 33242 additions and 115 deletions

View File

@@ -0,0 +1,144 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {useToast} from '@astryxdesign/core/Toast';
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 {formatCount, formatInr} from '@/shared/utils/format';
import {useActivities} from '@/features/engagement/hooks/useEngagement';
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_ACTIVITIES} from '@/features/engagement/mocks/engagementMock';
import type {ActivityRow} from '@/features/engagement/types/engagement';
/** Null is "not reported", which is not the same fact as zero. */
function maybe(v: number | null, format: (n: number) => string = formatCount): string {
return v === null ? '—' : format(v);
}
/**
* Each activity: how many took part, and — ATTRIBUTED — how many of them came
* back and bought afterwards. The disclosure sits in the panel whenever any
* row's basis is estimated.
*/
export function ActivitiesPanel() {
const toast = useToast();
const activities = useActivities();
// Recording always acts on the REAL catalogue, never the sample.
const shown = withSample(activities, MOCK_ACTIVITIES, (d) => !!d?.length);
const [recording, setRecording] = useState(false);
const canRecord =
activities.status === 'success' && activities.data.some((a) => a.isActive);
const columns: TableColumn<ActivityRow>[] = [
{
key: 'name',
header: 'Activity',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.name}
</Text>
{row.isActive ? null : <Badge variant="neutral" label="Paused" />}
</HStack>
),
},
{
key: 'participants',
header: 'Took part',
width: pixel(120),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{formatCount(row.participants)}
</Text>
{row.anonymous > 0 ? (
<Text size="xsm" color="secondary">
+{formatCount(row.anonymous)} unrecognised
</Text>
) : null}
</VStack>
),
},
{
key: 'repeatVisitors',
header: 'Came back',
width: pixel(110),
renderCell: (row) => <Text size="sm">{maybe(row.repeatVisitors)}</Text>,
},
{
key: 'purchases',
header: 'Bought after',
width: pixel(120),
renderCell: (row) => <Text size="sm">{maybe(row.purchases)}</Text>,
},
{
key: 'attributedRevenue',
header: 'Attributed revenue',
width: proportional(1),
renderCell: (row) => (
<Text size="sm" weight="medium">
{maybe(row.attributedRevenue, formatInr)}
</Text>
),
},
];
return (
<>
<PanelCard
title="Customer activities"
subtitle="Who took part, and what they did afterwards"
actions={
canRecord ? (
<Button size="sm" variant="secondary" onClick={() => setRecording(true)} label="Record activity" />
) : (
<SampleTag show={shown.isSample} />
)
}
resource={shown}
loading={<SkeletonRows count={4} height={44} />}
empty={
<EmptyPanel
icon="activeRewards"
title="No activities set up"
description="Selfies, spins, challenges and referrals appear here once they are set up for your company and somebody takes part."
/>
}
>
{(rows) => (
<VStack gap={3}>
<AttributionNote
basis={rows.some((r) => r.attribution === 'estimated') ? 'estimated' : 'observed'}
/>
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="name" />
</VStack>
)}
</PanelCard>
{recording && activities.data ? (
<RecordActivityDialog
activities={activities.data}
onClose={() => setRecording(false)}
onRecorded={() => {
setRecording(false);
toast({body: 'Activity recorded'});
activities.refetch();
}}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import {Text} from '@astryxdesign/core/Text';
import {ATTRIBUTION_NOTE, type Attribution} from '@/features/engagement/types/engagement';
/**
* The disclosure beside every attributed figure. Renders NOTHING when the
* basis is `observed`, so a real attribution backend retires it with no copy
* change. The basis is read off the payload, never hardcoded.
*/
export function AttributionNote({basis}: {basis: Attribution}) {
if (basis === 'observed') return null;
return (
<Text size="xsm" color="secondary">
{ATTRIBUTION_NOTE}
</Text>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Timestamp} from '@astryxdesign/core/Timestamp';
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 {formatCount, formatInr} from '@/shared/utils/format';
import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_CAMPAIGNS} from '@/features/engagement/mocks/engagementMock';
import type {Campaign} from '@/features/engagement/types/engagement';
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
export function CampaignsPanel() {
const campaigns = withSample(useCampaigns(), MOCK_CAMPAIGNS, (d) => !!d?.length);
const columns: TableColumn<Campaign>[] = [
{
key: 'name',
header: 'Campaign',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
<Timestamp value={row.startsAt} format="date" />
{row.endsAt ? (
<>
{' – '}
<Timestamp value={row.endsAt} format="date" />
</>
) : (
' onwards'
)}
</Text>
</VStack>
),
},
{
key: 'isActive',
header: 'Status',
width: pixel(100),
renderCell: (row) => (
<Badge variant={row.isActive ? 'success' : 'neutral'} label={row.isActive ? 'Live' : 'Ended'} />
),
},
{
key: 'participants',
header: 'Took part',
width: pixel(110),
renderCell: (row) => <Text size="sm">{formatCount(row.participants)}</Text>,
},
{
key: 'purchasers',
header: 'Bought',
width: pixel(100),
renderCell: (row) => <Text size="sm">{formatCount(row.purchasers)}</Text>,
},
{
key: 'attributedRevenue',
header: 'Attributed revenue',
width: proportional(1),
renderCell: (row) => (
<Text size="sm" weight="medium">
{formatInr(row.attributedRevenue)}
</Text>
),
},
];
return (
<PanelCard
title="Campaigns"
subtitle="Each campaign's funnel in this period"
actions={<SampleTag show={campaigns.isSample} />}
resource={campaigns}
loading={<SkeletonRows count={3} height={44} />}
empty={
<EmptyPanel
icon="campaign"
title="No campaigns in this period"
description="Campaigns that ran during the selected dates appear here with who took part and who bought."
/>
}
>
{(rows) => (
<VStack gap={3}>
<AttributionNote
basis={rows.some((r) => r.attribution === 'estimated') ? 'estimated' : 'observed'}
/>
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="name" />
</VStack>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {ActivitiesPanel} from '@/features/engagement/components/ActivitiesPanel';
import {CampaignsPanel} from '@/features/engagement/components/CampaignsPanel';
import {JourneyPanel} from '@/features/engagement/components/JourneyPanel';
/**
* Customer activity and engagement, from the platform: activities and their
* attributed impact, campaigns, and the customer journey. Every panel follows
* the dashboard's shop and range, and says plainly when it has nothing yet —
* nothing here is estimated to fill a gap.
*/
export function EngagementSection() {
return (
<VStack gap={4}>
<ActivitiesPanel />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<CampaignsPanel />
<JourneyPanel />
</Grid>
</VStack>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {CHART} from '@/shared/components/charts/palette';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {formatCompact} from '@/shared/utils/format';
import {useJourney} from '@/features/engagement/hooks/useEngagement';
import {withSample} from '@/shared/mocks/withSample';
import {SampleTag} from '@/shared/mocks/SampleTag';
import {MOCK_JOURNEY} from '@/features/engagement/mocks/engagementMock';
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
/**
* Visit → take part → buy → come back → refer: distinct people per stage.
*
* Drawn as bars, NOT a funnel, and the caption says why: somebody can buy
* without ever taking part, so a later stage is not a subset of the one before
* and a funnel shape would imply a drop-off that did not happen.
*/
export function JourneyPanel() {
const journey = withSample(useJourney(), MOCK_JOURNEY, (d) =>
!!d?.stages.some((s) => s.customers > 0),
);
return (
<ChartCard
title="Customer journey"
subtitle="Distinct people who reached each stage in this period"
actions={<SampleTag show={journey.isSample} />}
resource={journey}
empty={
<EmptyPanel
icon="journey"
title="No journey yet"
description="Stages fill in as customers visit, take part and buy."
/>
}
>
{(j) => (
<VStack gap={2}>
<BarChartView
data={j.stages}
xKey="label"
yFormat={formatCompact}
isInteger
series={[{key: 'customers', label: 'Customers', color: CHART.brand.cool}]}
/>
<Text size="xsm" color="secondary">
Not a funnel: somebody can buy without taking part, so each stage is counted on its own.
</Text>
<AttributionNote basis={j.attribution} />
</VStack>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {engagementRepository} from '@/features/engagement/repositories/engagementRepository';
import type {ActivityRow} from '@/features/engagement/types/engagement';
const NO_SHOP = '__none__';
/**
* Record that somebody took part in an activity — a selfie, a spin, a
* challenge. The cameras cannot observe these, so without this the activity
* panels stay at zero forever.
*
* ── One id per real-world event ──────────────────────────────────────────
* `sourceEventId` is minted ONCE, when the dialog opens, and reused by every
* retry of this submission. The platform de-duplicates on it, so tapping
* "Record" again after a timeout cannot count the same spin twice. A new
* dialog is a new event and gets a new id.
*/
export function RecordActivityDialog({
activities,
onClose,
onRecorded,
}: {
activities: ActivityRow[];
onClose: () => void;
onRecorded: () => void;
}) {
const {stores, storeId} = useWorkspace();
const live = activities.filter((a) => a.isActive);
const [sourceEventId] = useState(() => crypto.randomUUID());
const [kind, setKind] = useState(live[0]?.kind ?? '');
const [site, setSite] = useState(storeId !== 'all' ? storeId : NO_SHOP);
const [customer, setCustomer] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setBusy(true);
setError(null);
const res = await engagementRepository.recordEvent({
kind,
sourceEventId,
site: site === NO_SHOP ? undefined : site,
visitorId: customer.trim() || undefined,
});
setBusy(false);
if (!res.ok) {
setError(res.message ?? 'Could not record that.');
return;
}
onRecorded();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="required"
width={440}
aria-label="Record an activity"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Record an activity"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<Selector
label="Activity"
value={kind}
onChange={(v) => setKind(v)}
options={live.map((a) => ({value: a.kind, label: a.name}))}
/>
<Selector
label="Shop"
value={site}
onChange={(v) => setSite(v)}
options={[
{value: NO_SHOP, label: 'Not at a shop'},
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
]}
/>
<TextInput
label="Customer number"
value={customer}
onChange={setCustomer}
placeholder="V-42"
isOptional
description="Leave blank for somebody the cameras have not recognised — it still counts, but cannot appear in the impact figures."
/>
{error ? <Banner status="error" title={error} /> : null}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
onClick={() => void submit()}
isDisabled={!kind || busy}
isLoading={busy}
label="Record"
/>
</HStack>
</VStack>
</Dialog>
);
}