112 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|