diff --git a/src/api/deliverySlots.ts b/src/api/deliverySlots.ts new file mode 100644 index 0000000..aeeffa6 --- /dev/null +++ b/src/api/deliverySlots.ts @@ -0,0 +1,101 @@ +/** + * When a branch delivers. + * + * A branch offers at most three windows a day — morning, afternoon, evening — + * and a shopper picks one at checkout. The window is a PREFERENCE, not a + * promise: every order is accepted and no window ever fills up. + * + * ── A branch with no windows is not broken ────────────────────────────────── + * + * Every tenant trading today has none, and all of them keep taking orders. + * An empty list means "order without a window", never "this shop is closed". + * Nothing here may treat it as an error state. + * + * ── The console edits; it does not decide what is open ────────────────────── + * + * Whether a window is still open today is a clock comparison, and the server + * owns it — see deliverySlotService.go. This module reads what a branch has + * configured and writes it back. The filtered, dated list a shopper sees is an + * app concern and is not fetched here. + */ + +import { api, WEB } from './client'; + +/** The three, in the order a shopper reads them. */ +export const SLOT_KEYS = ['morning', 'afternoon', 'evening'] as const; +export type SlotKey = (typeof SLOT_KEYS)[number]; + +export interface DeliverySlot { + deliveryslotid?: number; + tenantid?: number; + locationid?: number; + slotkey: SlotKey; + /** What the shopper reads. Blank falls back to the capitalised key. */ + name: string; + /** "HH:MM", 24-hour, in the shop's own local time. */ + starttime: string; + /** + * Also the cut-off. A window takes orders right up to the moment it ends — + * there is deliberately no separate cut-off to configure. + */ + endtime: string; + status: 'active' | 'inactive'; +} + +/** + * What a branch starts with when nobody has set anything. + * + * Seeded rather than blank so onboarding asks a shopkeeper to CONFIRM rather + * than to invent: three empty time fields is a form most people abandon, and a + * tenant that abandons it has a shop that cannot offer windows at all. + */ +export const DEFAULT_SLOTS: DeliverySlot[] = [ + { slotkey: 'morning', name: 'Morning', starttime: '08:00', endtime: '10:00', status: 'active' }, + { slotkey: 'afternoon', name: 'Afternoon', starttime: '12:00', endtime: '15:00', status: 'active' }, + { slotkey: 'evening', name: 'Evening', starttime: '17:00', endtime: '20:00', status: 'active' }, +]; + +export const deliverySlotsApi = { + /** Everything this branch has configured, active or not. */ + list: (tenantid: number, locationid: number) => + api.get<{ details: DeliverySlot[] }>(`${WEB}/deliveryslots`, { tenantid, locationid }), + + /** + * All three together, never one at a time. + * + * They are edited as a set on one screen, and sending them together is what + * lets the server reject the whole edit when one row is wrong instead of + * applying half of it — a shop with two new windows and one old one, and + * nothing on screen saying which took, is worse than a shop with none. + */ + save: (tenantid: number, locationid: number, slots: DeliverySlot[]) => + api.put(`${WEB}/deliveryslots`, { tenantid, locationid, slots }), +}; + +/** + * Is this set fit to send? + * + * Mirrors the server's rules so the shopkeeper hears about a mistake while + * their hands are still on it, rather than as a 409 after pressing save. The + * server re-checks all of it — this is courtesy, not security. + */ +export function slotProblems(slots: DeliverySlot[]): string[] { + const problems: string[] = []; + + for (const slot of slots) { + const label = slot.name.trim() || slot.slotkey; + + if (!/^\d{2}:\d{2}$/.test(slot.starttime) || !/^\d{2}:\d{2}$/.test(slot.endtime)) { + problems.push(`${label} needs a start and end time.`); + continue; + } + // An end at or before its start never passes the server's "still running" + // test, so the window would simply never appear to a shopper, with nothing + // saying why. + if (slot.endtime <= slot.starttime) { + problems.push(`${label} ends at or before it starts (${slot.starttime}–${slot.endtime}).`); + } + } + + return problems; +} diff --git a/src/api/types.ts b/src/api/types.ts index 7a5a950..c24b6da 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -685,6 +685,19 @@ export interface OrderRow { * filters the whole list away — the drop address is the real test. */ deliverytype?: string; + + /** + * The delivery window the customer chose, and the day it falls on. + * + * Absent on every order placed before windows shipped, and on every order + * from a branch that has set none — which is most of them. Treat absence as + * "no window was asked for", never as a problem with the order. + * + * Distinct from `deliverytime` above, which is a timestamp of what happened. + * These two say what was asked for. + */ + deliveryslotid?: number; + deliveryslotdate?: string; } /** diff --git a/src/features/store-admin/DeliverySlotsCard.tsx b/src/features/store-admin/DeliverySlotsCard.tsx new file mode 100644 index 0000000..6f7efc4 --- /dev/null +++ b/src/features/store-admin/DeliverySlotsCard.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { errorMessage } from '@/api/client'; +import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots'; +import { DeliverySlotsEditor } from './DeliverySlotsEditor'; +import { DrawerButton } from './drawerKit'; + +/** + * A branch's delivery windows, on the shop profile. + * + * Owns its own fetch and save rather than joining the page's one big record: + * the windows live in their own table with their own endpoint, and folding them + * into the profile's save would mean a failed window edit rolling back a + * perfectly good change of phone number. + * + * ── Editing never moves an order already placed ───────────────────────────── + * + * The server upserts by (tenant, branch, key) and keeps slot ids stable, so an + * order that chose this morning still points at this morning. Changing the + * hours changes what FUTURE shoppers are offered, not what past ones agreed to. + * Worth knowing before anyone decides to make this a delete-and-recreate. + */ +export function DeliverySlotsCard({ + tenantid, + locationid, +}: { + tenantid: number; + locationid: number; +}) { + const query = useQuery({ + queryKey: ['deliveryslots', tenantid, locationid], + queryFn: () => deliverySlotsApi.list(tenantid, locationid), + enabled: tenantid > 0, + staleTime: 60_000, + }); + + /* + The working copy. + + Seeded from the server once it answers, and from the defaults when the + branch has none — which is every branch until somebody sets them. Local so + a half-finished edit is not thrown away by a background refetch. + */ + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!query.data) return; + const fromServer = query.data.details ?? []; + setDraft(fromServer.length > 0 ? fromServer : DEFAULT_SLOTS); + }, [query.data]); + + const save = useMutation({ + mutationFn: (slots: DeliverySlot[]) => deliverySlotsApi.save(tenantid, locationid, slots), + onMutate: () => { + setError(null); + setSaved(false); + }, + onSuccess: () => { + setSaved(true); + void query.refetch(); + }, + // A write that fails silently would leave a shopkeeper believing their + // delivery hours had changed when they had not — and the people who find + // out are customers. + onError: (cause) => setError(errorMessage(cause)), + }); + + if (query.isLoading || !draft) { + return

Loading delivery windows…

; + } + + const problems = slotProblems(draft); + const hasNone = (query.data?.details ?? []).length === 0; + + return ( +
+ { + setDraft(next); + setSaved(false); + }} + isDisabled={save.isPending} + {...(hasNone + ? { + // Said plainly, because the difference matters: this branch is + // taking orders right now with no window chosen, and will keep + // doing so until these are saved. + intro: + 'This branch has no delivery windows yet, so customers order without choosing a time. Set them below to start offering a choice.', + } + : {})} + /> + +
+ 0} + onClick={() => save.mutate(draft)} + /> + {saved ? ( + Saved. + ) : null} + {error ? ( + {error} + ) : null} +
+
+ ); +} diff --git a/src/features/store-admin/DeliverySlotsEditor.tsx b/src/features/store-admin/DeliverySlotsEditor.tsx new file mode 100644 index 0000000..02d2ec8 --- /dev/null +++ b/src/features/store-admin/DeliverySlotsEditor.tsx @@ -0,0 +1,161 @@ +import { Switch } from '@astryxdesign/core/Switch'; +import { DEFAULT_SLOTS, slotProblems, type DeliverySlot, type SlotKey } from '@/api/deliverySlots'; +import { Note } from './drawerKit'; + +/** + * The three delivery windows a branch offers, as an editable block. + * + * Used in two places that look the same to a shopkeeper and are quite different + * underneath: onboarding, where the windows are part of a form that creates a + * branch, and the shop profile, where they are saved on their own. This + * component owns neither the saving nor the fetching — it is handed a value and + * reports changes, so both callers can decide what "save" means for them. + * + * ── Three fixed rows, not a list ──────────────────────────────────────────── + * + * There is no add or remove. A shop has a morning, an afternoon and an evening, + * and the app has exactly three places to show them. Building this as a generic + * schedule editor would offer a shopkeeper a fourth window that nothing can + * render, and the cost of that discovery lands on a customer who picked it. + * + * ── Why each row has an on/off ────────────────────────────────────────────── + * + * A shop that does not do evenings needs a way to say so that is not deleting + * the row. Orders already placed against a window still have to resolve to + * something with a name, so a window is switched off and kept, never removed. + */ +export function DeliverySlotsEditor({ + slots, + onChange, + isDisabled, + /** Shown above the rows. Onboarding and the profile screen say different things. */ + intro, +}: { + slots: DeliverySlot[]; + onChange: (slots: DeliverySlot[]) => void; + isDisabled?: boolean; + intro?: string; +}) { + const problems = slotProblems(slots); + + function update(key: SlotKey, patch: Partial) { + onChange(slots.map((slot) => (slot.slotkey === key ? { ...slot, ...patch } : slot))); + } + + // Ordered by the constant, not by whatever order the server returned, so the + // rows do not reshuffle between a fresh branch and a saved one. + const ordered = DEFAULT_SLOTS.map( + (fallback) => slots.find((slot) => slot.slotkey === fallback.slotkey) ?? fallback, + ); + + return ( +
+ + {intro ?? + 'Customers choose one of these when they order. A window takes orders right up until it ends — after that it disappears for the day and only the later windows are offered.'} + + +
+ {ordered.map((slot) => ( + update(slot.slotkey, patch)} + /> + ))} +
+ + {/* Mirrors the server's own checks so a mistake is caught while the + shopkeeper's hands are still on it, rather than as a 409 after save. */} + {problems.length > 0 ? ( +
    + {problems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) : null} +
+ ); +} + +function SlotRow({ + slot, + onChange, + isDisabled, +}: { + slot: DeliverySlot; + onChange: (patch: Partial) => void; + isDisabled: boolean; +}) { + const isOff = slot.status !== 'active'; + + return ( +
+ onChange({ name: event.target.value })} + placeholder={slot.slotkey} + disabled={isDisabled} + aria-label={`Name for the ${slot.slotkey} window`} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} + /> + + onChange({ starttime: event.target.value })} + disabled={isDisabled || isOff} + aria-label={`Start of the ${slot.slotkey} window`} + style={{ ...inputStyle, width: 104 }} + /> + to + onChange({ endtime: event.target.value })} + disabled={isDisabled || isOff} + aria-label={`End of the ${slot.slotkey} window — also when it stops taking orders`} + style={{ ...inputStyle, width: 104 }} + /> + + {/* Label hidden: the row's name field says which window this is, and + repeating "Offer the morning window" three times down a block of three + is the same sentence three times. */} + onChange({ status: on ? 'active' : 'inactive' })} + isDisabled={isDisabled} + size="sm" + /> +
+ ); +} + +const inputStyle: React.CSSProperties = { + padding: '7px 9px', + fontSize: 13, + border: '1px solid var(--color-line, #d7dce5)', + borderRadius: 7, + background: 'var(--color-surface, #fff)', + color: 'var(--color-ink-1)', +}; diff --git a/src/features/store-admin/OrderDetailDrawer.tsx b/src/features/store-admin/OrderDetailDrawer.tsx index b058b9c..44f1132 100644 --- a/src/features/store-admin/OrderDetailDrawer.tsx +++ b/src/features/store-admin/OrderDetailDrawer.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react'; +import { ArrowDown, Bike, Check, Clock, MapPin, Phone, X } from 'lucide-react'; import type { DeliveryRow, OrderItem, OrderRow } from '@/api/types'; import { orderStage, type Stage } from './orderProgress'; import { useDeliveryMoves } from './DeliveryProgress'; @@ -284,6 +284,23 @@ function Sheet({ ) : null} + {/* The window the customer asked for. + + Shown only when one was chosen: most orders have none, and an empty + "Delivery window —" row on every one of them would be noise that + hides the orders where it matters. */} + {'deliveryslotdate' in row && (row as { deliveryslotdate?: string }).deliveryslotdate ? ( +
+ + } + /> + +
+ ) : null} + {row.ordernotes || job?.notes ? (
diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx index 33a1682..9339241 100644 --- a/src/features/store-admin/pages/OnboardBranchPage.tsx +++ b/src/features/store-admin/pages/OnboardBranchPage.tsx @@ -1,6 +1,8 @@ import { useMemo, useState, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots'; +import { DeliverySlotsEditor } from '../DeliverySlotsEditor'; import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; @@ -77,6 +79,17 @@ export function OnboardBranchPage() { const defaultTenantId = branchScope?.tenantid ? String(branchScope.tenantid) : ''; + /* + The branch's three delivery windows. + + Held apart from `form`, whose fields are all strings bound to inputs, and + seeded with the defaults so a shopkeeper CONFIRMS hours rather than invents + them — three empty time fields is a form most people skip, and a branch that + skips it cannot offer a window at all. + */ + const [slots, setSlots] = useState(DEFAULT_SLOTS); + const [slotWarning, setSlotWarning] = useState(null); + const [form, setForm] = useState(() => ({ ...EMPTY, tenantid: defaultTenantId, @@ -150,8 +163,32 @@ export function OnboardBranchPage() { const mutation = useMutation({ mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body), - onSuccess: async () => { + onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all }); + + /* + The windows, written AFTER the branch, because they need its id. + + Deliberately not fatal. The branch exists and can trade the moment this + returns; failing to write its windows leaves it ordering without one, + which is the ordinary state of every branch on the platform today. So + this warns and moves on rather than making a working branch look like a + failed one — the windows can be set from the shop profile at any time. + */ + const newLocationId = Number(result?.branch?.locationid ?? 0); + if (newLocationId > 0 && slotProblems(slots).length === 0) { + try { + await deliverySlotsApi.save( + Number(form.tenantid || defaultTenantId), + newLocationId, + slots, + ); + } catch (cause) { + setSlotWarning( + `The branch was created, but its delivery windows were not saved (${errorMessage(cause)}). You can set them from the store profile.`, + ); + } + } }, onError: (cause) => setError(errorMessage(cause)), }); @@ -495,9 +532,51 @@ export function OnboardBranchPage() { step={5} /> + + {/* Separate from the open/close hours above, which say when the + SHOP is open. These say when it delivers, and a shopper picks + one of them at checkout. */} +
+ + Delivery windows + + +
+ + {/* The branch was created but its windows were not. + A warning, not an error: the branch is live and trading, and this + is recoverable from the store profile. Styled apart from the error + alert below so the two are not read as the same severity. */} + {slotWarning ? ( + + {slotWarning} + + ) : null} + {/* Error Alert */} {error ? ( + {/* First on the tab, because it is the only thing here a merchant + can change. Everything below it is set by Nearle and shown so + they can see what it is, not so they can edit it. */} + } title="Delivery windows" span="full"> + + + } title="Store configuration" span="full">