update the adminpage ui

This commit is contained in:
2026-09-25 16:29:41 +05:30
parent 91e4db8217
commit b36a30385c
168 changed files with 10535 additions and 1008 deletions

View File

@@ -0,0 +1,189 @@
'use client';
import {useState} from 'react';
import Image from 'next/image';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {TextInput} from '@astryxdesign/core/TextInput';
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {Divider} from '@astryxdesign/core/Divider';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {hasRole} from '@/features/auth/services/roles';
import {customerRepository} from '@/features/customers/repositories/customerRepository';
import {useCustomerPhoto} from '@/features/customers/hooks/useCustomers';
import {CustomerHistory} from '@/features/customers/components/CustomerHistory';
import {RecordPurchaseDialog} from '@/features/customers/components/RecordPurchaseDialog';
import {EraseCustomerDialog} from '@/features/customers/components/EraseCustomerDialog';
import type {Customer} from '@/features/customers/types/customer';
/**
* One customer: who they are, when they came, and what staff can do about it.
*
* The photo is read ONCE here, not per component, because every read is a row
* in the platform's "who looked at my customers" audit log. A missing photo is
* the platform's own reason in plain text — most deployments store none.
*
* The profile save is a whole-object replace upstream, so the form holds only
* what can be read back (name, phone, email, consent) and says what saving
* clears. Unticking consent is a withdrawal the platform timestamps.
*/
export function CustomerDialog({
customer,
onClose,
onChanged,
}: {
customer: Customer;
onClose: () => void;
/** Something about this customer changed; the list should re-read. */
onChanged: () => void;
}) {
const {user} = useSession();
const canErase = hasRole(user?.role, 'manager');
const photo = useCustomerPhoto(customer.id);
const [fullName, setFullName] = useState(customer.fullName);
const [phone, setPhone] = useState(customer.phone);
const [email, setEmail] = useState(customer.email);
const [consent, setConsent] = useState(customer.hasConsent);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ok: boolean; text: string} | null>(null);
const [buying, setBuying] = useState(false);
const [erasing, setErasing] = useState(false);
const hasContact = fullName.trim() !== '' || phone.trim() !== '' || email.trim() !== '';
async function save() {
setSaving(true);
setMessage(null);
const res = await customerRepository.saveProfile(customer.id, {
fullName,
phone,
email,
consent,
});
setSaving(false);
if (!res.ok) {
setMessage({ok: false, text: res.message ?? 'Could not save these details.'});
return;
}
setMessage({ok: true, text: 'Saved.'});
onChanged();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={560}
aria-label={`Customer ${customer.label}`}
>
<VStack gap={4} width="100%">
<DialogHeader
title={customer.label}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<HStack gap={4} vAlign="start">
{photo.status === 'success' && photo.data.available && photo.data.url ? (
<Image
src={photo.data.url}
alt={`Latest photo of ${customer.label}`}
width={96}
height={96}
unoptimized
style={{borderRadius: 8, objectFit: 'cover'}}
/>
) : null}
<VStack gap={1}>
{customer.ref ? (
<Text size="sm" color="secondary" className="font-mono">
{customer.ref}
</Text>
) : null}
<Text size="sm">
{customer.visitCount} {customer.visitCount === 1 ? 'visit' : 'visits'} · last seen{' '}
<Timestamp value={customer.lastSeenAt} format="relative" />
</Text>
<Text size="xsm" color="secondary">
First seen <Timestamp value={customer.firstSeenAt} format="date" />
</Text>
{photo.status === 'success' && !photo.data.available && photo.data.reason ? (
<Text size="xsm" color="secondary">
{photo.data.reason}
</Text>
) : null}
</VStack>
</HStack>
<Divider />
<Heading level={4}>Details</Heading>
<TextInput label="Name" value={fullName} onChange={setFullName} />
<HStack gap={3} wrap="wrap">
<TextInput label="Phone" value={phone} onChange={setPhone} isOptional />
<TextInput label="Email" value={email} onChange={setEmail} isOptional />
</HStack>
<CheckboxInput
label="They agreed to be recognised"
value={consent}
onChange={(v) => setConsent(v === true)}
/>
<Text size="xsm" color="secondary">
Saving replaces their whole profile: any gender, date of birth or notes recorded
elsewhere are cleared.
</Text>
{message ? (
<Banner status={message.ok ? 'success' : 'error'} title={message.text} />
) : null}
<Divider />
<Heading level={4}>Visits</Heading>
<CustomerHistory customerId={customer.id} />
<HStack gap={2} hAlign="between" wrap="wrap">
{canErase ? (
<Button variant="destructive" onClick={() => setErasing(true)} label="Erase…" />
) : null}
<HStack gap={2} wrap="wrap">
<Button variant="secondary" onClick={() => setBuying(true)} label="Record purchase" />
<Button
onClick={() => void save()}
isDisabled={!hasContact || saving}
isLoading={saving}
label="Save details"
/>
</HStack>
</HStack>
</VStack>
{buying ? (
<RecordPurchaseDialog
customer={customer}
onClose={() => setBuying(false)}
onRecorded={() => {
setBuying(false);
setMessage({ok: true, text: 'Purchase recorded.'});
}}
/>
) : null}
{erasing ? (
<EraseCustomerDialog
customer={customer}
onClose={() => setErasing(false)}
onErased={() => {
setErasing(false);
onChanged();
onClose();
}}
/>
) : null}
</Dialog>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import {useEffect, 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 {TextInput} from '@astryxdesign/core/TextInput';
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 {useCustomerSearch} from '@/features/customers/hooks/useCustomers';
import {CustomerDialog} from '@/features/customers/components/CustomerDialog';
import type {Customer} from '@/features/customers/types/customer';
/** Long enough that a word typed at speed is one request, short enough to feel live. */
const SEARCH_DEBOUNCE_MS = 300;
/**
* Find a customer by name, phone, email or number (`42` or `V-42`).
*
* The term is debounced before it becomes the request: useResource keys on the
* URL, so without it every keystroke would be a round trip to the platform.
* With no term the list is the most recently seen — the useful default for a
* person who has just walked in.
*/
export function CustomerDirectory() {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
const [open, setOpen] = useState<Customer | null>(null);
const customers = useCustomerSearch(query);
useEffect(() => {
const t = setTimeout(() => setQuery(input), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [input]);
const columns: TableColumn<Customer>[] = [
{
key: 'label',
header: 'Customer',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.label}
</Text>
<Text size="sm" color="secondary">
{/* An unnamed customer's label already IS their number ("Visitor 42"). */}
{[row.fullName ? row.ref : null, row.phone || null].filter(Boolean).join(' · ') || '—'}
</Text>
</VStack>
),
},
{
key: 'visitCount',
header: 'Visits',
width: pixel(90),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.visitCount}
</Text>
),
},
{
key: 'lastSeenAt',
header: 'Last seen',
width: proportional(1),
renderCell: (row) => <Timestamp value={row.lastSeenAt} format="relative" />,
},
{
key: 'hasConsent',
header: 'Consent',
width: pixel(110),
renderCell: (row) =>
row.hasConsent ? <Badge variant="success" label="Given" /> : <Text size="sm">—</Text>,
},
{
key: 'actions',
header: '',
align: 'center',
width: pixel(100),
renderCell: (row) => (
<HStack hAlign="center">
<Button size="sm" variant="secondary" onClick={() => setOpen(row)} label="Open" />
</HStack>
),
},
];
return (
<VStack gap={4}>
<TextInput
label="Search customers"
isLabelHidden
value={input}
onChange={setInput}
placeholder="Name, phone, email or number (V-42)"
hasClear
/>
<PanelCard
title={query ? `Results for “${query}”` : 'Recently seen'}
resource={customers}
loading={<SkeletonRows count={6} height={52} />}
empty={
<EmptyPanel
icon="visitors"
title={query ? 'No customer matches that' : 'No customers yet'}
description={
query
? 'Try part of a name, a phone number, or their customer number.'
: 'Customers appear here once a camera has recognised them.'
}
/>
}
>
{(rows) => (
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="label" />
)}
</PanelCard>
{open ? (
<CustomerDialog
key={open.id}
customer={open}
onClose={() => setOpen(null)}
onChanged={customers.refetch}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {Divider} from '@astryxdesign/core/Divider';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useCustomerHistory} from '@/features/customers/hooks/useCustomers';
/** Their visits, newest first — the one that enrolled them is marked. */
export function CustomerHistory({customerId}: {customerId: string}) {
const history = useCustomerHistory(customerId);
return (
<AsyncBoundary
resource={history}
loading={<SkeletonRows count={4} height={28} />}
empty={
<EmptyPanel
icon="visitors"
title="No visits recorded"
description="Visits appear here once a camera recognises them."
/>
}
>
{(visits) => (
<VStack gap={2}>
{visits.map((v, i) => (
<VStack key={v.id} gap={2}>
{i > 0 ? <Divider /> : null}
<HStack gap={2} vAlign="center" hAlign="between">
<VStack gap={0}>
<Timestamp value={v.occurredAt} format="date_time" />
<Text size="xsm" color="secondary">
{v.siteName}
</Text>
</VStack>
{v.isNewVisitor ? <Badge variant="neutral" label="First visit" /> : null}
</HStack>
</VStack>
))}
</VStack>
)}
</AsyncBoundary>
);
}

View File

@@ -0,0 +1,88 @@
'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 {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {customerRepository} from '@/features/customers/repositories/customerRepository';
import type {Customer} from '@/features/customers/types/customer';
/**
* Erase a customer — the right-to-be-forgotten path. Manager and above.
*
* Irreversible, so it asks for the customer number to be typed. What it does
* is said plainly: the face and photo are destroyed; the visits stay as
* anonymous footfall; the consent record stays, revoked.
*
* A failure is shown and never softened. The platform's 502 means the photo
* could not be deleted and NOTHING was erased — telling the merchant otherwise
* would leave them believing data is gone that is still there.
*/
export function EraseCustomerDialog({
customer,
onClose,
onErased,
}: {
customer: Customer;
onClose: () => void;
onErased: () => void;
}) {
const expected = customer.ref ?? customer.id;
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setBusy(true);
setError(null);
const res = await customerRepository.erase(customer.id);
setBusy(false);
if (!res.ok) {
setError(res.message ?? 'Nothing was erased. Please try again.');
return;
}
onErased();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="required"
width={460}
aria-label="Erase customer"
>
<VStack gap={4} width="100%">
<DialogHeader
title={`Erase ${customer.label}?`}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<Banner
status="error"
title="This cannot be undone"
description="Their face template and photo are deleted permanently, and their name and contact details are removed. Their visits stay as anonymous footfall, and the record of their consent stays, marked withdrawn. The cameras will not re-enrol them as a new customer."
/>
{error ? <Banner status="error" title={error} /> : null}
<TextInput
label="Type the customer number to confirm"
value={confirm}
onChange={setConfirm}
placeholder={expected}
description={`Exactly: ${expected}`}
/>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
variant="destructive"
onClick={() => void submit()}
isDisabled={confirm.trim() !== expected || busy}
isLoading={busy}
label="Erase customer"
/>
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,126 @@
'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 {NumberInput} from '@astryxdesign/core/NumberInput';
import {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {Text} from '@astryxdesign/core/Text';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {customerRepository} from '@/features/customers/repositories/customerRepository';
import type {Customer} from '@/features/customers/types/customer';
const LAST_SEEN = '__last_seen__';
/**
* Link a sale to this customer, so the conversion report can say who bought.
*
* Lighter than the till's itemised sale on the Commerce page: an amount, and
* optionally what they bought. Left on "where they were last seen", the
* platform books it at that shop — what "somebody on the floor just sold them
* something" means. Items are a comma-separated list of names, because that is
* what the platform stores.
*/
export function RecordPurchaseDialog({
customer,
onClose,
onRecorded,
}: {
customer: Customer;
onClose: () => void;
onRecorded: () => void;
}) {
const {stores} = useWorkspace();
const [amount, setAmount] = useState<number | null>(null);
const [site, setSite] = useState(LAST_SEEN);
const [items, setItems] = useState('');
const [notes, setNotes] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const shopOptions = [
{value: LAST_SEEN, label: 'Where they were last seen'},
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
];
async function submit() {
if (amount === null || amount <= 0) {
setError('Enter the amount they paid.');
return;
}
setBusy(true);
setError(null);
const res = await customerRepository.recordPurchase({
visitorId: customer.id,
site: site === LAST_SEEN ? undefined : site,
amount,
currency: 'INR',
items: items
.split(',')
.map((i) => i.trim())
.filter(Boolean),
notes: notes.trim(),
});
setBusy(false);
if (!res.ok) {
setError(res.message ?? 'Could not record that purchase.');
return;
}
onRecorded();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="required"
width={440}
aria-label="Record a purchase"
>
<VStack gap={4} width="100%">
<DialogHeader
title={`Record a purchase — ${customer.label}`}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<NumberInput
label="Amount (₹)"
value={amount}
onChange={setAmount}
hasClear
min={0}
/>
<Selector
label="Shop"
value={site}
onChange={(v) => setSite(v)}
options={shopOptions}
/>
<TextInput
label="Items"
value={items}
onChange={setItems}
isOptional
placeholder="Silk saree, Blouse"
description="Separate items with commas."
/>
<TextInput label="Notes" value={notes} onChange={setNotes} isOptional />
<Text size="xsm" color="secondary">
For an itemised bill with enquiries, use New sale on the Commerce page.
</Text>
{error ? <Banner status="error" title={error} /> : null}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
onClick={() => void submit()}
isDisabled={busy}
isLoading={busy}
label="Record purchase"
/>
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import {useResource} from '@/shared/hooks/useResource';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {customerRepository} from '@/features/customers/repositories/customerRepository';
import type {Resource} from '@/shared/hooks/useResource';
import type {
Customer,
CustomerPhoto,
CustomerVisit,
} from '@/features/customers/types/customer';
/**
* Customer search. An empty term lists the most recently seen — the useful
* default for somebody who has just walked in.
*
* Session-gated like every other hook here, so nothing is requested from a
* tab that has signed out.
*/
export function useCustomerSearch(q: string): Resource<Customer[]> {
const {isAuthenticated} = useSession();
return useResource(isAuthenticated ? customerRepository.search(q) : null);
}
export function useCustomerHistory(id: string): Resource<CustomerVisit[]> {
return useResource(customerRepository.history(id));
}
/** A missing photo is `available: false`, never 'empty' or an error. */
export function useCustomerPhoto(id: string): Resource<CustomerPhoto> {
return useResource(customerRepository.photo(id), {isEmpty: () => false});
}

View File

@@ -0,0 +1,46 @@
import {
deleteJson,
postJson,
putJson,
type Endpoint,
} from '@/shared/services/httpClient';
import type {
Customer,
CustomerPhoto,
CustomerVisit,
ProfileDraft,
PurchaseDraft,
} from '@/features/customers/types/customer';
/**
* TRANSPORT ONLY — customers, their history and photo, and the writes.
*
* The reads are Endpoints so useResource keys on the URL: a new search term or
* a different customer refetches on its own. `id` may be the uuid or `V-42`.
*/
export const customerRepository = {
search: (q: string): Endpoint<Customer[]> => ({
path: '/api/visitors',
params: q.trim() ? {q: q.trim()} : {},
}),
history: (id: string): Endpoint<CustomerVisit[]> => ({
path: `/api/visitors/${encodeURIComponent(id)}/history`,
params: {},
}),
/** Audited upstream on every read — fetch it once per screen. */
photo: (id: string): Endpoint<CustomerPhoto> => ({
path: `/api/visitors/${encodeURIComponent(id)}/image`,
params: {},
}),
/** Whole-object replace; see ProfileDraft for why it is these four fields. */
saveProfile: (id: string, draft: ProfileDraft) =>
putJson<null>(`/api/visitors/${encodeURIComponent(id)}/profile`, draft),
recordPurchase: (draft: PurchaseDraft) => postJson<null>('/api/purchases', draft),
/** Irreversible erasure. Manager and above. */
erase: (id: string) => deleteJson<null>(`/api/visitors/${encodeURIComponent(id)}`),
};

View File

@@ -0,0 +1,110 @@
import type {
ApiImageRef,
ApiProfileInput,
ApiPurchaseInput,
ApiVisitor,
ApiVisitorHistoryEntry,
} from '@/services/api/types';
import type {
Customer,
CustomerPhoto,
CustomerVisit,
} from '@/features/customers/types/customer';
/**
* Platform shapes → what the customer screens consume, and request bodies →
* what the platform accepts.
*
* Kept out of the route files so the list, the history, the save and the
* purchase routes agree on one mapping, the way `mapTeam` does for the team.
*/
export function toCustomer(v: ApiVisitor): Customer {
return {
id: v.id,
ref: v.ref || null,
// Upstream `label` is the name given at ENROLMENT ("Visitor 42") and is
// never changed by naming them — the chosen name is `full_name`. So the
// name wins when there is one, as it does in the platform's own console.
label: v.full_name || v.label || v.ref || 'Customer',
fullName: v.full_name ?? '',
phone: v.phone ?? '',
email: v.email ?? '',
visitCount: v.visit_count ?? 0,
firstSeenAt: v.first_seen_at,
lastSeenAt: v.last_seen_at,
hasProfile: v.has_profile === true,
hasConsent: v.has_consent === true,
};
}
export function toCustomerVisit(h: ApiVisitorHistoryEntry): CustomerVisit {
return {
id: h.id,
occurredAt: h.occurred_at,
siteName: h.site,
cameraId: h.camera_id,
isNewVisitor: h.is_new_visitor === true,
};
}
/**
* A relative URL is served by the platform and needs the session's token,
* which an <img> cannot send — so it goes through /api/faces. A presigned
* object-storage link carries its own signature and is used as it is.
*/
export function toCustomerPhoto(img: ApiImageRef): CustomerPhoto {
if (!img.available || !img.url) {
return {available: false, url: null, reason: img.reason ?? null};
}
return {
available: true,
url: img.url.startsWith('http')
? img.url
: `/api/faces?src=${encodeURIComponent(img.url)}`,
reason: null,
};
}
function text(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
/**
* The save is a WHOLE-OBJECT replace upstream. Gender, date of birth and notes
* are sent empty because this console cannot read them back to preserve them —
* see ProfileDraft — and the form says so before anybody saves.
*/
export function toProfileInput(body: Record<string, unknown>): ApiProfileInput {
return {
full_name: text(body.fullName),
phone: text(body.phone),
email: text(body.email),
gender: '',
date_of_birth: '',
notes: '',
consent: body.consent === true,
};
}
/**
* A request body → the platform's `PurchaseInput`.
*
* `items` upstream is a list of item NAMES; anything that is not a non-empty
* string is dropped rather than sent, because a number there fails the
* platform's JSON decode for the whole request.
*/
export function toPurchaseInput(body: Record<string, unknown>): ApiPurchaseInput {
const items = Array.isArray(body.items)
? body.items.map(text).filter((i) => i !== '')
: [];
const site = text(body.site) || text(body.siteId);
return {
visitor_id: text(body.visitorId),
site_id: site || undefined,
amount: Number(body.amount),
currency: text(body.currency).toUpperCase() || 'INR',
items: items.length > 0 ? items : undefined,
source: 'console',
notes: text(body.notes) || undefined,
};
}

View File

@@ -0,0 +1,74 @@
/**
* A customer, as the console consumes it — `GET /api/visitors`.
*
* `label` is what to show: it reads "Visitor 42" until somebody names them,
* then the name. `fullName` stays empty until then, which is how the screen
* tells an unnamed regular from a named one without guessing from the label.
*
* `ref` ("V-42") is per tenant and immutable — safe to show, to search by and
* to type into a till. It is null only on a deployment that predates numbering.
*/
export interface Customer extends Record<string, unknown> {
id: string;
ref: string | null;
label: string;
fullName: string;
phone: string;
email: string;
visitCount: number;
firstSeenAt: string;
lastSeenAt: string;
hasProfile: boolean;
/** Recorded agreement to be recognised. Withdrawn, never deleted, upstream. */
hasConsent: boolean;
}
/** One visit in a customer's history. Newest first. */
export interface CustomerVisit {
id: string;
occurredAt: string;
/** The shop's display name at the time of reading, not its slug. */
siteName: string;
cameraId: string;
/** True on exactly one row — the visit that enrolled them. */
isNewVisitor: boolean;
}
/**
* The customer's latest photo.
*
* `available: false` is DATA, not an error: images are off by default across
* the product, so most deployments legitimately have none, and `reason` says
* why. `url` is already proxied — a component never builds a platform URL.
*/
export interface CustomerPhoto {
available: boolean;
url: string | null;
reason: string | null;
}
/**
* What the profile form edits.
*
* Deliberately only what the platform lets this console READ back. The save is
* a whole-object replace, and gender, date of birth and notes are not on any
* read endpoint — a form offering them would show them blank and could only
* ever overwrite what somebody else had recorded.
*/
export interface ProfileDraft {
fullName: string;
phone: string;
email: string;
consent: boolean;
}
/** A sale linked to a customer, so the conversion report can say who bought. */
export interface PurchaseDraft {
visitorId: string;
/** Slug or uuid. Omitted = where this customer was last seen. */
site?: string;
amount: number;
currency: string;
items: string[];
notes: string;
}