first commit
This commit is contained in:
144
src/features/engagement/components/ActivitiesPanel.tsx
Normal file
144
src/features/engagement/components/ActivitiesPanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/features/engagement/components/AttributionNote.tsx
Normal file
18
src/features/engagement/components/AttributionNote.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/features/engagement/components/CampaignsPanel.tsx
Normal file
106
src/features/engagement/components/CampaignsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/features/engagement/components/EngagementSection.tsx
Normal file
25
src/features/engagement/components/EngagementSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/features/engagement/components/JourneyPanel.tsx
Normal file
59
src/features/engagement/components/JourneyPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/features/engagement/components/RecordActivityDialog.tsx
Normal file
111
src/features/engagement/components/RecordActivityDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user