delivery slot creation

This commit is contained in:
2026-10-06 17:37:02 +05:30
parent 990dd0dc23
commit 56e8a95c6a
7 changed files with 498 additions and 2 deletions

View File

@@ -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<DeliverySlot[] | null>(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(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 <p className="sp-empty">Loading delivery windows…</p>;
}
const problems = slotProblems(draft);
const hasNone = (query.data?.details ?? []).length === 0;
return (
<div>
<DeliverySlotsEditor
slots={draft}
onChange={(next) => {
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.',
}
: {})}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save delivery windows'}
variant="primary"
isDisabled={save.isPending || problems.length > 0}
onClick={() => save.mutate(draft)}
/>
{saved ? (
<span style={{ fontSize: 12, color: 'var(--color-ok, #2f855a)' }}>Saved.</span>
) : null}
{error ? (
<span style={{ fontSize: 12, color: 'var(--color-error, #d64545)' }}>{error}</span>
) : null}
</div>
</div>
);
}

View 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)',
};

View File

@@ -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({
</Section>
) : 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 ? (
<Section title="Delivery window">
<DrawerCard tone="subtle">
<Row
label="Requested"
value={(row as { deliveryslotdate?: string }).deliveryslotdate}
icon={<Clock size={14} />}
/>
</DrawerCard>
</Section>
) : null}
{row.ordernotes || job?.notes ? (
<Section title="Notes">
<DrawerCard tone="subtle">

View File

@@ -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<DeliverySlot[]>(DEFAULT_SLOTS);
const [slotWarning, setSlotWarning] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(() => ({
...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}
/>
</div>
{/* 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. */}
<div style={{ marginTop: 16 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Delivery windows
</span>
<DeliverySlotsEditor
slots={slots}
onChange={setSlots}
isDisabled={mutation.isPending}
intro="Customers choose one of these when they order from this branch. A window takes orders right up until it ends. You can change them later from the store profile."
/>
</div>
</VStack>
</Card>
{/* 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 ? (
<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}
{/* Error Alert */}
{error ? (
<HStack

View File

@@ -41,6 +41,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DeliverySlotsCard } from '../DeliverySlotsCard';
import { Button } from '@astryxdesign/core/Button';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Text } from '@astryxdesign/core/Text';
@@ -55,6 +56,7 @@ import {
Pencil,
Phone,
ShieldCheck,
Clock,
Sliders,
Store,
TriangleAlert,
@@ -603,6 +605,16 @@ export function ShopProfilePage() {
trades under, whether it is approved — because knowing what is set
and who sets it is more useful than a page of disabled inputs. */
<div className="sp-cards">
{/* 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. */}
<ProfileCard icon={<Clock size={15} />} title="Delivery windows" span="full">
<DeliverySlotsCard
tenantid={Number(record['tenantid'] ?? 0)}
locationid={Number(record['locationid'] ?? 0)}
/>
</ProfileCard>
<ProfileCard icon={<Sliders size={15} />} title="Store configuration" span="full">
<Row label="App location" value={text('applocation')} />
<Row