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/features/nearle-admin/pages/OnboardTenantPage.tsx b/src/features/nearle-admin/pages/OnboardTenantPage.tsx index f64caa1..89cdd41 100644 --- a/src/features/nearle-admin/pages/OnboardTenantPage.tsx +++ b/src/features/nearle-admin/pages/OnboardTenantPage.tsx @@ -1,6 +1,8 @@ import { 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 '@/features/store-admin/DeliverySlotsEditor'; import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; import { HStack } from '@astryxdesign/core/HStack'; @@ -61,6 +63,16 @@ const EMPTY: FormState = { export function OnboardTenantPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + /* + The primary branch's three delivery windows. + + Onboarding creates a tenant AND its first branch, so this is the first and + most natural moment to ask when that branch delivers. Seeded with defaults + so the answer is a confirmation rather than an invention. + */ + const [slots, setSlots] = useState(DEFAULT_SLOTS); + const [slotWarning, setSlotWarning] = useState(null); + const [form, setForm] = useState(EMPTY); const categories = useAppCategories(); const [error, setError] = useState(null); @@ -71,8 +83,29 @@ export function OnboardTenantPage() { const mutation = useMutation({ mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body), - onSuccess: async () => { + onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all }); + + /* + The windows, against the PRIMARY BRANCH this call just created. + + Not fatal if it fails. The tenant and its branch exist and can trade, + and a branch with no windows simply takes orders without one — the state + every shop on the platform is in today. Warning beats rolling back a + successful onboarding, and the merchant can set these from their own + store profile. + */ + const tenantId = Number(result?.tenant?.tenantid ?? 0); + const locationId = Number(result?.tenant?.locationid ?? 0); + if (tenantId > 0 && locationId > 0 && slotProblems(slots).length === 0) { + try { + await deliverySlotsApi.save(tenantId, locationId, slots); + } catch (cause) { + setSlotWarning( + `The store was created, but its delivery windows were not saved (${errorMessage(cause)}). They can be set from the store profile.`, + ); + } + } }, onError: (cause) => setError(errorMessage(cause)), }); @@ -408,6 +441,43 @@ export function OnboardTenantPage() { ) : null} + {/* When this store delivers. + + Asked at onboarding because this flow creates the tenant's first + branch, and the windows belong to a branch. A store onboarded + without them trades exactly as every store does today — customers + order without choosing a time — so this is an opportunity, not a + requirement. */} + + + + Delivery windows + + + + + + {slotWarning ? ( + + {slotWarning} + + ) : null} + {/* Sticky Bottom Actions */}
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)', +};