live update
This commit is contained in:
@@ -13,7 +13,7 @@ import type { FiestaEnvelope } from './types';
|
||||
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
||||
* surprises. In production the deployed host is set by VITE_API_BASE.
|
||||
*/
|
||||
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||
export const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||
|
||||
/** Every console route lives under this prefix. */
|
||||
export const WEB = '/live/api/v1/web';
|
||||
|
||||
235
src/components/SheetDropzone.tsx
Normal file
235
src/components/SheetDropzone.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { FileSpreadsheet, Trash2, Upload } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The workbook dropzone.
|
||||
*
|
||||
* Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and
|
||||
* a bare arrow — correct, and completely mute about what it wants or whether it
|
||||
* is even available. This one says all three things: what it takes, whether it
|
||||
* can take it right now, and what it is holding.
|
||||
*
|
||||
* Three states, and each looks different at a glance rather than on reading:
|
||||
*
|
||||
* - **Waiting** — a tinted panel with a brand-lit icon and an explicit
|
||||
* "Choose file" affordance, because a dropzone that only accepts a drag is
|
||||
* unusable to anyone on a laptop with the file already in a dialog.
|
||||
* - **Dragging** — the brand colour comes up and the panel lifts. A dropzone
|
||||
* that does not visibly react is one people drop next to.
|
||||
* - **Filled** — the file itself, with its size and a way to swap it. The old
|
||||
* control kept saying "Drop an .xlsx here" after you already had.
|
||||
*
|
||||
* It stays a real `<input type="file">` under the surface, so the keyboard, the
|
||||
* screen reader and the OS file dialog all behave as they should — the div is
|
||||
* decoration over an input, not a replacement for one.
|
||||
*/
|
||||
export interface SheetDropzoneProps {
|
||||
file: File | null;
|
||||
onFile: (file: File | null) => void;
|
||||
/** Comma-separated extensions, passed straight to the input. */
|
||||
accept?: string;
|
||||
/** Why the control is unavailable. Present means disabled. */
|
||||
blockedReason?: string;
|
||||
}
|
||||
|
||||
const ACCEPTED = ['.xlsx', '.xls', '.csv'];
|
||||
|
||||
export function SheetDropzone({
|
||||
file,
|
||||
onFile,
|
||||
accept = ACCEPTED.join(','),
|
||||
blockedReason,
|
||||
}: SheetDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isDisabled = Boolean(blockedReason);
|
||||
|
||||
function open() {
|
||||
if (!isDisabled) inputRef.current?.click();
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (isDisabled) return;
|
||||
const dropped = event.dataTransfer.files?.[0];
|
||||
if (dropped) onFile(dropped);
|
||||
}
|
||||
|
||||
/* ── Filled ───────────────────────────────────────────────────────────── */
|
||||
|
||||
if (file) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
flex: 'none',
|
||||
borderRadius: 11,
|
||||
background: 'color-mix(in oklab, var(--color-brand) 12%, transparent)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
<FileSpreadsheet size={19} />
|
||||
</span>
|
||||
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-1)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||
{formatSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={open} style={ghostButtonStyle}>
|
||||
Replace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFile(null)}
|
||||
aria-label="Remove file"
|
||||
style={{ ...ghostButtonStyle, padding: '7px 9px' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Waiting / dragging ───────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={open}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isDisabled) setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-disabled={isDisabled}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 13,
|
||||
border: `1.5px dashed ${
|
||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||
}`,
|
||||
background: isDragging
|
||||
? 'color-mix(in oklab, var(--color-brand) 8%, transparent)'
|
||||
: 'color-mix(in oklab, var(--color-brand) 3%, transparent)',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.55 : 1,
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
transition: 'background .16s ease, border-color .16s ease, transform .16s ease',
|
||||
transform: isDragging ? 'scale(1.004)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
background: isDragging
|
||||
? 'var(--color-brand)'
|
||||
: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
|
||||
color: isDragging ? '#fff' : 'var(--color-brand)',
|
||||
transition: 'background .16s ease, color .16s ease',
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
{isDisabled
|
||||
? blockedReason
|
||||
: isDragging
|
||||
? 'Drop it here'
|
||||
: 'Drop your workbook, or click to browse'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
|
||||
{ACCEPTED.join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ghostButtonStyle: React.CSSProperties = {
|
||||
flex: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '7px 12px',
|
||||
borderRadius: 9,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface)',
|
||||
color: 'var(--color-ink-2)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${Math.round(kb)} kB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
@@ -12,6 +11,7 @@ import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-r
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
import { downloadTemplate, parseProductSheet, type ParsedSheet } from './parseProductSheet';
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
@@ -148,7 +148,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
return (
|
||||
<Card padding={4} elevation="low">
|
||||
<Card padding={4} variant="transparent">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
@@ -197,7 +197,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
@@ -212,17 +212,18 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
label="Product workbook"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
accept=".xlsx,.xls,.csv"
|
||||
mode="dropzone"
|
||||
placeholder="Drop an .xlsx or .csv here"
|
||||
description="Required columns: productname, productsku, categoryid, retailprice, productcost. The catalogue's category names do not map to a tenant's own ids, so categoryid must be in the file."
|
||||
isDisabled={!hasTarget}
|
||||
<SheetDropzone
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
|
||||
/>
|
||||
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
||||
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
||||
the file.
|
||||
</Text>
|
||||
|
||||
{parseError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{parseError}
|
||||
@@ -232,7 +233,7 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="What is in the file"
|
||||
|
||||
@@ -120,7 +120,7 @@ export function GlobalCataloguePage() {
|
||||
<VStack gap={2}>
|
||||
{/* Only here. An upload is written against one merchant and one
|
||||
outlet, and the endpoint refuses a row without both. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={2}>
|
||||
<SectionHeader
|
||||
title="Uploading for"
|
||||
|
||||
@@ -468,8 +468,8 @@ export function TillDrawer({
|
||||
isNew
|
||||
? cashierOnly
|
||||
? 'Add a cashier'
|
||||
: 'Add a till account'
|
||||
: (row.full_name ?? 'Edit till account')
|
||||
: 'Add a terminal account'
|
||||
: (row.full_name ?? 'Edit terminal account')
|
||||
}
|
||||
subtitle={
|
||||
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { Check, ChevronDown, Monitor, Store, Users } from 'lucide-react';
|
||||
import { AppShell, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { BranchScopeProvider, useBranchScope } from './BranchScope';
|
||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||
|
||||
/**
|
||||
* Store Admin — the merchant workspace.
|
||||
@@ -42,7 +43,7 @@ const MANAGE: readonly MenuEntry[] = [
|
||||
to: '/admin/users',
|
||||
label: 'Users & access',
|
||||
icon: <Users size={13} />,
|
||||
note: 'Store and till accounts',
|
||||
note: 'Store and terminal accounts',
|
||||
},
|
||||
{
|
||||
to: '/admin/terminals',
|
||||
@@ -55,6 +56,7 @@ const MANAGE: readonly MenuEntry[] = [
|
||||
export function StoreAdminShell() {
|
||||
return (
|
||||
<BranchScopeProvider>
|
||||
<LiveWatch />
|
||||
<AppShell
|
||||
nav={NAV}
|
||||
home="/admin/console"
|
||||
@@ -66,6 +68,23 @@ export function StoreAdminShell() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the live stream for the branch currently in scope.
|
||||
*
|
||||
* A component rather than a hook call in the shell body, because it has to sit
|
||||
* INSIDE `BranchScopeProvider` to read the selection — and it renders nothing,
|
||||
* so where it sits in the tree costs nothing either.
|
||||
*
|
||||
* Under "All branches" `current` is undefined and no stream opens. That is
|
||||
* deliberate: the stream is per-outlet, and a six-branch tenant would otherwise
|
||||
* hold six connections open for a screen that is already polling.
|
||||
*/
|
||||
function LiveWatch() {
|
||||
const { tenantid, current } = useBranchScope();
|
||||
useLiveEvents(tenantid, current?.locationid);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch selector, in the header.
|
||||
*
|
||||
|
||||
@@ -987,7 +987,10 @@ function StatusTab({
|
||||
padding: '0 11px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isActive ? 'transparent' : 'var(--color-line)'}`,
|
||||
background: isActive ? 'var(--color-brand)' : 'var(--color-surface)',
|
||||
// Transparent when idle, not white. A row of white chips on a tinted
|
||||
// page reads as six raised slabs; only the selected one should carry a
|
||||
// fill, and the hairline is enough to keep the rest legible as chips.
|
||||
background: isActive ? 'var(--color-brand)' : 'transparent',
|
||||
color: isActive ? '#fff' : 'var(--color-ink-2)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 500,
|
||||
|
||||
@@ -111,7 +111,7 @@ export function UsersPage() {
|
||||
description="Who can sign in to the console, and who can open a till."
|
||||
actions={
|
||||
<Button
|
||||
label={group === 'store' ? 'Add person' : 'Add till account'}
|
||||
label={group === 'store' ? 'Add person' : 'Add terminal account'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
@@ -137,7 +137,7 @@ export function UsersPage() {
|
||||
}}
|
||||
/>
|
||||
<TabButton
|
||||
label="Till accounts"
|
||||
label="Terminal accounts"
|
||||
icon={<Monitor size={14} />}
|
||||
badge={tillRows.length || undefined}
|
||||
isActive={group === 'till'}
|
||||
@@ -314,12 +314,12 @@ function TillTable({
|
||||
showBranch: boolean;
|
||||
onEdit: (row: PosUser, locationid: number) => void;
|
||||
}) {
|
||||
if (isLoading) return <Loading what="till accounts" />;
|
||||
if (isLoading) return <Loading what="terminal accounts" />;
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Monitor size={22} />}
|
||||
title="No till accounts"
|
||||
title="No terminal accounts"
|
||||
body="Create a Supervisor first — they open the till on the device, and the terminal appears on the Console board once it reports in."
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/component
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTenantLocations } from '@/queries/hooks';
|
||||
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { useLiveEvents } from '@/queries/useLiveEvents';
|
||||
|
||||
/**
|
||||
* Store user — one branch, and only that branch.
|
||||
@@ -100,6 +101,7 @@ export function StoreUserShell() {
|
||||
|
||||
return (
|
||||
<BranchScopeProvider pin={locationid}>
|
||||
<LiveWatch />
|
||||
<AppShell
|
||||
nav={NAV}
|
||||
home="/store/console"
|
||||
@@ -203,3 +205,15 @@ function NoStore() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the live stream for this shop's outlet.
|
||||
*
|
||||
* Simpler than the Store Admin's: the branch is pinned, so there is always
|
||||
* exactly one outlet to watch and exactly one connection to hold.
|
||||
*/
|
||||
function LiveWatch() {
|
||||
const { tenantid, current } = useBranchScope();
|
||||
useLiveEvents(tenantid, current?.locationid);
|
||||
return null;
|
||||
}
|
||||
|
||||
109
src/queries/useLiveEvents.ts
Normal file
109
src/queries/useLiveEvents.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The live stream.
|
||||
*
|
||||
* The console reads through TanStack Query on a 30-second poll. That is the
|
||||
* floor, not the ceiling: a bill rung at 10:00:01 waits until 10:00:30 to
|
||||
* appear. This connects to the backend's event stream and, when a till actually
|
||||
* sells something, invalidates the queries that just went stale — so the screen
|
||||
* updates in about a second instead of averaging fifteen.
|
||||
*
|
||||
* ── What it does NOT do ──────────────────────────────────────────────────────
|
||||
*
|
||||
* It never writes data into the cache. An event says "outlet 1185 sold
|
||||
* something, products 42 and 77 were involved" and nothing more; the numbers
|
||||
* still come from the API, because the API is the only thing that knows the
|
||||
* total after the backend's locks, dedup and rejections have had their say.
|
||||
* Painting figures straight from a broker message would put totals on screen
|
||||
* that the database never agreed to.
|
||||
*
|
||||
* It also does not replace the poll. Every hook keeps its `refetchInterval`. If
|
||||
* this connection drops, the broker is down, or MQTT_URL is unset on the
|
||||
* server, the console behaves exactly as it did before — slower, never wrong.
|
||||
* That is the whole reason this is safe to add.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { API_BASE } from '@/api/client';
|
||||
import { queryKeys } from './keys';
|
||||
|
||||
/** Mirrors `messaging.LiveEvent` on the backend. */
|
||||
interface LiveEvent {
|
||||
type: 'sale' | 'customer' | 'terminal';
|
||||
locationid: number;
|
||||
productids?: number[];
|
||||
terminalid?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch one outlet.
|
||||
*
|
||||
* Pass `undefined` for either id and nothing connects — which is what the Store
|
||||
* Admin's "All branches" view does, since a stream is per-outlet and opening
|
||||
* one per branch would mean six connections for a screen that is already
|
||||
* polling. Under All branches the poll remains the only refresh, deliberately.
|
||||
*/
|
||||
export function useLiveEvents(tenantid: number | undefined, locationid: number | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantid || !locationid) return;
|
||||
|
||||
const url = `${API_BASE}/live/api/v1/web/live/events?tenantid=${tenantid}&locationid=${locationid}`;
|
||||
const source = new EventSource(url);
|
||||
|
||||
/**
|
||||
* A sale moved stock and added a bill.
|
||||
*
|
||||
* Invalidated by prefix rather than by exact key: the product list is keyed
|
||||
* by page and the statement by its filters, and this has no idea which page
|
||||
* or filter the operator is looking at. `invalidateQueries` with a prefix
|
||||
* only refetches what is actually mounted, so the cost is the queries on
|
||||
* screen — not every page ever visited.
|
||||
*/
|
||||
const onSale = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.products.all });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posSales(locationid) });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...queryKeys.insights.all, 'pos-bills', locationid],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...queryKeys.insights.all, 'pos-summary', locationid],
|
||||
});
|
||||
};
|
||||
|
||||
/** A heartbeat — the presence board, and nothing else. */
|
||||
const onTerminal = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posHealth(locationid) });
|
||||
};
|
||||
|
||||
const onCustomer = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.customers.all });
|
||||
};
|
||||
|
||||
source.addEventListener('sale', onSale);
|
||||
source.addEventListener('terminal', onTerminal);
|
||||
source.addEventListener('customer', onCustomer);
|
||||
|
||||
/**
|
||||
* Errors are left alone on purpose.
|
||||
*
|
||||
* `EventSource` reconnects by itself, honouring the `retry:` the server
|
||||
* sends, and it fires `error` on every one of those attempts. Logging here
|
||||
* fills the console with noise during an ordinary backend deploy, and
|
||||
* surfacing it to the operator would be reporting a failure that costs them
|
||||
* nothing — the poll is still running underneath.
|
||||
*/
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('sale', onSale);
|
||||
source.removeEventListener('terminal', onTerminal);
|
||||
source.removeEventListener('customer', onCustomer);
|
||||
source.close();
|
||||
};
|
||||
}, [tenantid, locationid, queryClient]);
|
||||
}
|
||||
|
||||
export type { LiveEvent };
|
||||
Reference in New Issue
Block a user