onboarding with delivery slot
This commit is contained in:
101
src/api/deliverySlots.ts
Normal file
101
src/api/deliverySlots.ts
Normal file
@@ -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<unknown>(`${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;
|
||||
}
|
||||
@@ -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<DeliverySlot[]>(DEFAULT_SLOTS);
|
||||
const [slotWarning, setSlotWarning] = useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const categories = useAppCategories();
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
</HStack>
|
||||
) : 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. */}
|
||||
<Card padding={0}>
|
||||
<VStack gap={2} padding={3}>
|
||||
<Text type="large" weight="semibold">
|
||||
Delivery windows
|
||||
</Text>
|
||||
<DeliverySlotsEditor
|
||||
slots={slots}
|
||||
onChange={setSlots}
|
||||
isDisabled={mutation.isPending}
|
||||
intro="Confirm with the store when they deliver. Customers pick one of these when ordering, and a window takes orders right up until it ends. The store can change them later themselves."
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{slotWarning ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={1.5}
|
||||
style={{
|
||||
background: 'var(--color-warn-muted, #fdf6e3)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--color-warn, #8a6d1f)',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
{slotWarning}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{/* Sticky Bottom Actions */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
161
src/features/store-admin/DeliverySlotsEditor.tsx
Normal file
161
src/features/store-admin/DeliverySlotsEditor.tsx
Normal file
@@ -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<DeliverySlot>) {
|
||||
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 (
|
||||
<div>
|
||||
<Note>
|
||||
{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.'}
|
||||
</Note>
|
||||
|
||||
<div style={{ display: 'grid', gap: 2, marginTop: 12 }}>
|
||||
{ordered.map((slot) => (
|
||||
<SlotRow
|
||||
key={slot.slotkey}
|
||||
slot={slot}
|
||||
isDisabled={Boolean(isDisabled)}
|
||||
onChange={(patch) => update(slot.slotkey, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<ul
|
||||
style={{
|
||||
margin: '10px 0 0',
|
||||
paddingLeft: 18,
|
||||
fontSize: 11.5,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
{problems.map((problem) => (
|
||||
<li key={problem}>{problem}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotRow({
|
||||
slot,
|
||||
onChange,
|
||||
isDisabled,
|
||||
}: {
|
||||
slot: DeliverySlot;
|
||||
onChange: (patch: Partial<DeliverySlot>) => void;
|
||||
isDisabled: boolean;
|
||||
}) {
|
||||
const isOff = slot.status !== 'active';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '11px 0',
|
||||
borderTop: '1px solid var(--color-line, #e0e4ea)',
|
||||
// A window that is off stays legible but stops competing for attention.
|
||||
opacity: isOff ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={slot.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
placeholder={slot.slotkey}
|
||||
disabled={isDisabled}
|
||||
aria-label={`Name for the ${slot.slotkey} window`}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="time"
|
||||
value={slot.starttime}
|
||||
onChange={(event) => onChange({ starttime: event.target.value })}
|
||||
disabled={isDisabled || isOff}
|
||||
aria-label={`Start of the ${slot.slotkey} window`}
|
||||
style={{ ...inputStyle, width: 104 }}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-ink-4)', fontSize: 12 }}>to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.endtime}
|
||||
onChange={(event) => 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. */}
|
||||
<Switch
|
||||
label={`Offer the ${slot.slotkey} window`}
|
||||
isLabelHidden
|
||||
value={!isOff}
|
||||
onChange={(on: boolean) => onChange({ status: on ? 'active' : 'inactive' })}
|
||||
isDisabled={isDisabled}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
Reference in New Issue
Block a user