fix(config): require a real API base, ship the env template, show the real team

Three configuration defects and one screen of invented people.

API base. The client defaulted to https://platform.loyaly.ai when
LOYALY_API_BASE was unset, and that host serves THIS console, not the
Behavision API - verified live: it answers /api/auth/me with the console's
own 404 HTML and a login POST with the console's own BFF envelope. So an
unset variable in production made the BFF call its own origin, which fails
looking like a broken login form rather than a misconfiguration. There is
now no remote fallback: development defaults to 127.0.0.1:8088 and
production throws, naming the variable, the way tokenStore already refuses
to run without AUTH_SECRET. A wrong host that appears to work is worse than
a startup failure that says what is missing.

The template. .env.example documented that same wrong host, and .gitignore's
`.env*` matched the template itself, so it was never committed - a fresh
clone got no template at all, for an app that cannot start in production
without AUTH_SECRET. Added `!.env.example` after the ignore rule; .env.local
and every other .env* stay ignored. The template carries placeholders only,
no values.

Team. /settings/team listed five invented people - aravind@nearle.in,
Vikram Seth, Priya Sharma - with store names no endpoint supplies and roles
that do not exist upstream, behind four controls that mutated local state
and were lost on refresh. A merchant could not tell any of it from the real
thing. It now reads GET /api/team, which the platform already serves and
scopes by session, and renders what actually comes back: name, email, role,
whether the account is still active, and last sign-in (or "Never", which is
a fact worth seeing).

The route used to map each row through toAuthUser, which reads client_name -
a field GET /api/team does not send - so organisation was undefined on every
row while active, last_login_at and created_at were discarded. ApiTeamMember
now describes that payload properly and ApiUser is left to authentication.

The screen is READ-ONLY on purpose. Accounts are born from invitations, and
that flow already exists in the platform's own web app: a manager mints a
code, the holder redeems it and chooses their own password. A second way to
create a login does not belong here, least of all on the screen that lists
them. Role changes and deactivation are supported upstream by
PATCH /api/team/{id} and are deliberately not wired: deactivating revokes
every session that person holds immediately, so it wants a confirmation step
and 409 last_owner handling, neither of which belongs in a change whose
purpose is removing invented data.

types.ts also gains the Sales/Floor/Customer interfaces. They are inert here
- nothing imports them yet - and land with this commit so the screens that
consume them arrive as one reviewable change.

Verified against the live local platform: two tenants, correct member lists
for each, and no cross-tenant leakage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
2026-09-17 12:05:41 +05:30
parent 697b0d9ef2
commit 759f3b79fd
8 changed files with 353 additions and 291 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript.
#
# local dev http://127.0.0.1:8088
# production https://mcp.loyaly.ai
#
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
# the variable there makes the BFF call its own origin, which fails in a way
# that looks like a broken login form rather than a misconfiguration.
#
# There is no fallback: production refuses to start without this set.
LOYALY_API_BASE=http://127.0.0.1:8088
# Signs the session cookie and encrypts the platform token bundle.
# Required in production — the app refuses to start signing sessions with the
# development key. Generate with: openssl rand -base64 48
AUTH_SECRET=
# Browser → this app's own BFF routes. Same origin, so normally left empty.
NEXT_PUBLIC_API_BASE=

5
.gitignore vendored
View File

@@ -33,6 +33,11 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# ...except the template, which carries no secret and is the only record of
# which variables the app needs. `.env*` was swallowing it too, so a fresh
# clone got no guidance at all — while AUTH_SECRET and LOYALY_API_BASE are both
# mandatory in production and the app refuses to start without them.
!.env.example
# vercel
.vercel

View File

@@ -1,26 +1,43 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {ApiUser} from '@/services/api/types';
import type {ApiTeamMember} from '@/services/api/types';
import type {UserRole} from '@/features/auth/types/auth';
import type {TeamMember} from '@/features/team/types/team';
export const dynamic = 'force-dynamic';
/** GET /api/team — console accounts for this company. */
function toMember(u: ApiUser): TeamMember {
const user = toAuthUser(u);
/**
* GET /api/team — console accounts for this company.
*
* The tenant is NOT a parameter. The platform takes it from the session and
* scopes the query itself, so this route has nothing to filter by and must not
* pretend otherwise — a client-side tenant filter is a check an attacker skips.
*
* ── What this used to drop ───────────────────────────────────────────────
* It ran each row through `toAuthUser`, which reads `client_name` — a field
* `GET /api/team` does not send. So `organisation` was `undefined` on every
* row, while `active`, `last_login_at` and `created_at` were discarded. The
* one field the team screen needs — who still has access — never arrived.
*/
function toMember(m: ApiTeamMember): TeamMember {
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
organisation: user.organisation,
id: m.id,
// Falls back to the address rather than rendering a blank cell: somebody
// invited but not yet named still has to be identifiable.
name: m.full_name || m.email,
email: m.email,
role: m.role as UserRole,
active: m.active,
// Null rather than '' — "has never signed in" and "signed in at an unknown
// time" are different facts, and the screen says so.
lastLoginAt: m.last_login_at || null,
createdAt: m.created_at,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (users) =>
users.map(toMember),
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
members.map(toMember),
);
}

View File

@@ -1,300 +1,151 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Avatar} from '@astryxdesign/core/Avatar';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
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 {useTeam} from '@/features/team/hooks/useTeam';
import type {TeamMember} from '@/features/team/types/team';
export interface StaffUser extends Record<string, unknown> {
/**
* Who has access to this console.
*
* ── What this replaced ───────────────────────────────────────────────────
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
* held in `useState`, with store names (`Indiranagar Flagship`,
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
* `Marketing`, `Support`) that do not exist upstream, and four controls that
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
* local state and were lost on refresh. A merchant could not tell any of it
* from the real thing.
*
* ── Why this screen is read-only ─────────────────────────────────────────
* Accounts are created by INVITATION, and that flow already exists in the
* platform's own web app: a manager mints a code, the holder redeems it and
* chooses their own password. Adding a second way to create an account here
* would be a second place a login can be born, on the one surface where that
* matters most. So this lists the team and does not mint anyone.
*
* Role changes and deactivation are supported upstream by
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
* revokes every session that person holds immediately, so it wants a
* confirmation step and `409 last_owner` handling, neither of which belongs in
* a change whose purpose is removing invented data.
*/
interface TeamRow extends Record<string, unknown> {
id: string;
name: string;
email: string;
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support';
storeName: string;
status: 'active' | 'suspended' | 'invited';
role: string;
status: string;
lastLogin: string;
}
const INITIAL_STAFF: StaffUser[] = [
/**
* Read off the payload, never inferred. The platform sends a boolean; there is
* no "invited" or "suspended" state on this endpoint, so the screen does not
* invent one.
*/
function toRow(m: TeamMember): TeamRow {
return {
id: m.id,
name: m.name,
email: m.email,
role: m.role,
status: m.active ? 'Active' : 'Inactive',
// "Never" is a fact worth showing: an account created and never used is
// exactly the row a manager wants to notice.
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
};
}
const COLUMNS: TableColumn<TeamRow>[] = [
{
id: 'usr-1',
name: 'Aravind',
email: 'aravind@nearle.in',
role: 'Owner',
storeName: 'All stores',
status: 'active',
lastLogin: 'Just now',
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
id: 'usr-2',
name: 'Vikram Seth',
email: 'vikram@nearle.in',
role: 'Manager',
storeName: 'Indiranagar Flagship',
status: 'active',
lastLogin: 'Today, 14:20',
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
},
{
id: 'usr-3',
name: 'Priya Sharma',
email: 'priya@nearle.in',
role: 'Cashier',
storeName: 'Koramangala 80ft',
status: 'active',
lastLogin: 'Today, 09:15',
},
{
id: 'usr-4',
name: 'Rahul Verma',
email: 'rahul@nearle.in',
role: 'Marketing',
storeName: 'All stores',
status: 'invited',
lastLogin: 'Pending accept',
},
{
id: 'usr-5',
name: 'Deepa Nair',
email: 'deepa@nearle.in',
role: 'Cashier',
storeName: 'Whitefield Main',
status: 'suspended',
lastLogin: '3 days ago',
key: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
];
export function TeamManagement() {
const toast = useToast();
const [staffList, setStaffList] = useState<StaffUser[]>(INITIAL_STAFF);
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState<'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support'>('Manager');
const [storeName, setStoreName] = useState('Indiranagar Flagship');
const handleAddStaff = () => {
if (!name.trim() || !email.trim()) {
toast({type: 'error', body: 'Name and Email are required'});
return;
}
const newUser: StaffUser = {
id: `usr-${Date.now()}`,
name,
email,
role,
storeName,
status: 'invited',
lastLogin: 'Pending accept',
};
setStaffList((prev) => [newUser, ...prev]);
setName('');
setEmail('');
setIsAdding(false);
toast({body: `Invitation sent to ${email}`});
};
const handleToggleStatus = (id: string) => {
setStaffList((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const nextStatus = s.status === 'suspended' ? 'active' : 'suspended';
toast({body: `${s.name} status updated to ${nextStatus}`});
return {...s, status: nextStatus};
}),
);
};
const handleResetPassword = (emailStr: string) => {
toast({body: `Password reset link sent to ${emailStr}`});
};
const handleRemove = (id: string, nameStr: string) => {
setStaffList((prev) => prev.filter((s) => s.id !== id));
toast({body: `${nameStr} removed from team`});
};
const columns: TableColumn<StaffUser>[] = [
{
key: 'name',
header: 'Staff Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={3} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => (
<Badge
variant={row.role === 'Owner' ? 'info' : 'neutral'}
label={row.role}
/>
),
},
{
key: 'storeName',
header: 'Assigned Store',
width: proportional(1.5),
renderCell: (row) => <Text size="sm">{row.storeName}</Text>,
},
{
key: 'status',
header: 'Status',
width: pixel(110),
renderCell: (row) => {
const v =
row.status === 'active'
? 'success'
: row.status === 'suspended'
? 'error'
: 'warning';
return <Badge variant={v} label={row.status} />;
},
},
{
key: 'lastLogin',
header: 'Last Login',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastLogin}
</Text>
),
},
{
key: 'actions',
header: 'Actions',
align: 'center',
width: pixel(160),
renderCell: (row) =>
row.role === 'Owner' ? (
<HStack hAlign="center">
<Text size="sm" color="secondary">
Primary Owner
</Text>
</HStack>
) : (
<HStack hAlign="center">
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}}
menuWidth={180}
items={[
{
label: row.status === 'suspended' ? 'Reactivate staff' : 'Suspend staff',
onClick: () => handleToggleStatus(row.id),
},
{
label: 'Reset password',
onClick: () => handleResetPassword(row.email),
},
{type: 'divider'},
{
label: 'Remove from team',
onClick: () => handleRemove(row.id, row.name),
},
]}
/>
</HStack>
),
},
];
const team = useTeam();
return (
<VStack gap={5}>
<StaticPanel
title="Team Directory & Staff Access"
subtitle="Manage store assignments, role permissions, password resets and account statuses."
actions={
<Button
variant="primary"
size="sm"
label={isAdding ? 'Cancel' : 'Add Staff Member'}
onClick={() => setIsAdding(!isAdding)}
/>
}
>
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
resource={team}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="staff"
title="No team members yet"
description="People appear here once they accept an invitation."
/>
}
>
{(members) => (
<VStack gap={4}>
{isAdding ? (
<VStack gap={3} padding={4}>
<Heading level={3}>Invite New Staff Member</Heading>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Full Name"
value={name}
onChange={setName}
placeholder="e.g. Ananya Rao"
/>
<TextInput
label="Work Email"
value={email}
onChange={setEmail}
placeholder="ananya@nearle.in"
/>
<Selector
label="Role"
value={role}
onChange={(v) =>
setRole(v as 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support')
}
options={['Manager', 'Cashier', 'Marketing', 'Support']}
/>
<Selector
label="Assigned Store"
value={storeName}
onChange={(v) => setStoreName(v)}
options={[
'All stores',
'Indiranagar Flagship',
'Koramangala 80ft',
'Whitefield Main',
'Jayanagar 4th Block',
]}
/>
<Button
variant="primary"
size="sm"
label="Send Invite"
onClick={handleAddStaff}
/>
</HStack>
</VStack>
) : null}
<ResponsiveTable
columns={columns}
data={staffList}
data={members.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['role', 'status']}
columns={COLUMNS}
/>
{/* Says where accounts come from, because the button that used to
claim to create one has gone. Without this the screen reads as
missing a feature rather than pointing at the right place. */}
<Text size="xsm" color="secondary">
New members are added by invitation, and choose their own password
when they redeem it. Roles and access are managed on the platform.
</Text>
</VStack>
</StaticPanel>
</VStack>
)}
</PanelCard>
);
}

View File

@@ -6,11 +6,21 @@ import type {UserRole} from '@/features/auth/types/auth';
* Not a shop-floor employee. `active: false` means signed out immediately and
* unable to sign back in; reactivating restores the account but not their old
* sessions.
*
* `organisation` was removed: it mapped from `client_name`, which
* `GET /api/team` does not send, so it was `undefined` on every row. Every
* member of this list belongs to the caller's own company by definition — the
* backend scopes the query by the session's tenant — so the field said nothing
* even when it was populated.
*/
export interface TeamMember {
id: string;
name: string;
email: string;
role: UserRole;
organisation: string;
/** false = signed out immediately and unable to sign back in. */
active: boolean;
/** Absent for somebody who has never signed in — that is data, not a gap. */
lastLoginAt: string | null;
createdAt: string;
}

View File

@@ -24,11 +24,40 @@ import type {ApiRole} from './types';
/**
* Server-side only — deliberately NOT NEXT_PUBLIC. Publishing the platform
* host would let a browser bypass the BFF, which is the whole point of it.
*
* ── Why there is no remote fallback ──────────────────────────────────────
* This used to default to `https://platform.loyaly.ai`, which is NOT the
* Behavision API — that host serves this very console. Measured: it answers
* `GET /api/auth/me` with the console's own 404 HTML page, and a login POST
* with the console's own `{error:{code,message}}` envelope rather than the
* platform's flat `{error,message}`. So an unset variable did not fail; it
* quietly pointed the BFF at its own origin, and every upstream call became a
* request the console made to itself.
*
* A wrong host that *works* is worse than a startup failure, so production now
* refuses to run without the variable — the same stance `tokenStore.ts` takes
* on AUTH_SECRET, and for the same reason. Development falls back to the local
* backend, which is the only host a dev machine can usefully mean.
*
* local http://127.0.0.1:8088
* production https://mcp.loyaly.ai
*/
const BASE = (process.env.LOYALY_API_BASE ?? 'https://platform.loyaly.ai').replace(
/\/+$/,
'',
);
const DEV_API_BASE = 'http://127.0.0.1:8088';
function resolveBase(): string {
const configured = process.env.LOYALY_API_BASE?.trim();
if (configured) return configured.replace(/\/+$/, '');
if (process.env.NODE_ENV === 'production') {
throw new Error(
'LOYALY_API_BASE is required in production — refusing to guess the ' +
'Loyaly platform host. Set it to https://mcp.loyaly.ai.',
);
}
return DEV_API_BASE;
}
const BASE = resolveBase();
export {BASE as UPSTREAM_BASE};

View File

@@ -1,6 +1,6 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiRole, ApiUser} from './types';
import type {ApiRole, ApiTeamMember} from './types';
/**
* The people with console accounts.
@@ -17,14 +17,19 @@ import type {ApiRole, ApiUser} from './types';
*/
export const teamApi = {
list: (accessToken: string) =>
upstreamRequest<ApiUser[]>({path: '/api/team', accessToken}),
upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
update: (
accessToken: string,
id: string,
patch: {role?: ApiRole; active?: boolean},
) =>
upstreamRequest<ApiUser>({
// Returns the UPDATED member, not an ApiUser — the handler writes back the
// same TeamMember shape `list` returns. Mis-typed as ApiUser, which has no
// `active`, so a caller reading the result could not see the change it had
// just made. Nothing calls this yet; correcting it now keeps the file
// honest for whoever wires the role/deactivate controls.
upstreamRequest<ApiTeamMember>({
path: `/api/team/${encodeURIComponent(id)}`,
method: 'PATCH',
body: patch,

View File

@@ -6,8 +6,11 @@
* would prefer it. Where the UI wants a different shape, the adapter in the
* BFF route does the translation; nothing here bends to suit a screen.
*
* Reference: platform.loyaly.ai. Times are RFC 3339 UTC unless a field says
* otherwise. Report bucket labels are the documented exception — see Bucket.
* Reference: the Behavision platform — mcp.loyaly.ai in production, and its
* own API.md. (Not platform.loyaly.ai, which serves this console rather than
* the API; naming it here is what invited that mistake into apiClient.)
* Times are RFC 3339 UTC unless a field says otherwise. Report bucket labels
* are the documented exception — see Bucket.
*/
// ---------------------------------------------------------------------------
@@ -33,6 +36,29 @@ export interface ApiUser {
client_name: string;
}
/**
* One person on `GET /api/team`.
*
* NOT `ApiUser`. The team list was typed as `ApiUser[]`, which claims
* `client_id` and `client_name` — neither of which the platform sends here, so
* `organisation` mapped to `undefined` on every row. It also meant `active`,
* `last_login_at` and `created_at` were dropped on the floor: the one field the
* team screen needs to show who still has access never reached the console.
*
* `ApiUser` remains correct for auth, where login genuinely does return the
* signed-in user's company.
*/
export interface ApiTeamMember {
id: string;
email: string;
full_name: string;
role: ApiRole;
/** false = signed out immediately and unable to sign back in. */
active: boolean;
last_login_at?: string;
created_at: string;
}
/** What login, refresh and register all return. */
export interface ApiTokenBundle {
access_token: string;
@@ -316,3 +342,101 @@ export interface ApiAssistantAnswer {
/** Tool names, so a wrong answer is traceable rather than mysterious. */
used?: string[];
}
// ---------------------------------------------------------------------------
// Sales (the merchant application's domain — LOYALY.md)
// ---------------------------------------------------------------------------
/**
* One line of a sale. `intent` is the load-bearing field: an enquiry keeps the
* price it was quoted at and contributes NOTHING to the bill, which is why
* `billable_paise` ships separately rather than being re-derived per client.
*/
export interface ApiSaleLine {
product_id?: string;
product_name: string;
price_paise: number;
intent: 'purchased' | 'enquired';
billable_paise: number;
}
/** Money is integer PAISE on this path, never a float. */
export interface ApiSale {
id: string;
invoice_no?: string;
site_id: string;
site_slug?: string;
visit_id?: string;
visitor_id?: string;
visitor_ref?: string;
customer_label?: string;
staff_id?: string;
staff_name?: string;
terminal_id?: string;
total_paise: number;
currency: string;
status: string;
lines?: ApiSaleLine[];
/** Counts, so the history list need not carry every line of every sale. */
purchased_lines?: number;
enquiry_lines?: number;
client_created_at?: string;
server_created_at: string;
}
export interface ApiDashboardSummary {
date: string;
from: string;
to: string;
timezone: string;
total_revenue_paise: number;
currency: string;
sales_count: number;
enquiries_count: number;
active_customer_count: number;
visitors_count: number;
}
// ---------------------------------------------------------------------------
// The shop floor (LOYALY.md §6/§7/§20)
// ---------------------------------------------------------------------------
/**
* One person the shop is dealing with right now.
*
* `visitor_id` ABSENT is the load-bearing case: the cameras saw a face and
* matched nothing, and that is exactly when the app offers to name them.
* It must stay distinguishable from a known customer who has no name yet.
*/
export interface ApiFloorVisit {
visit_id: string;
site_id: string;
site_slug?: string;
detected_at: string;
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
visitor_id?: string;
visitor_ref?: string;
label?: string;
phone?: string;
previous_visits: number;
attended_by?: string;
attended_by_name?: string;
attended_by_me: boolean;
attended_at?: string;
image?: {available: boolean; url?: string | null; reason?: string};
}
export interface ApiCustomerCreated {
id: string;
ref: string;
label: string;
full_name?: string;
phone?: string;
}
/** §16 — "processed" for a new sale, "already_processed" for a replay. */
export interface ApiSaleResult {
status: 'processed' | 'already_processed';
sale_id: string;
sale?: ApiSale;
}