Files
loyaly_cutomerweb/src/features/engagement/components/RecordActivityDialog.tsx
2026-09-25 16:31:10 +05:30

112 lines
3.8 KiB
TypeScript

'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>
);
}