delivery slot creation
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;
|
||||||
|
}
|
||||||
@@ -685,6 +685,19 @@ export interface OrderRow {
|
|||||||
* filters the whole list away — the drop address is the real test.
|
* filters the whole list away — the drop address is the real test.
|
||||||
*/
|
*/
|
||||||
deliverytype?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
113
src/features/store-admin/DeliverySlotsCard.tsx
Normal file
113
src/features/store-admin/DeliverySlotsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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)',
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 type { DeliveryRow, OrderItem, OrderRow } from '@/api/types';
|
||||||
import { orderStage, type Stage } from './orderProgress';
|
import { orderStage, type Stage } from './orderProgress';
|
||||||
import { useDeliveryMoves } from './DeliveryProgress';
|
import { useDeliveryMoves } from './DeliveryProgress';
|
||||||
@@ -284,6 +284,23 @@ function Sheet({
|
|||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : 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 ? (
|
{row.ordernotes || job?.notes ? (
|
||||||
<Section title="Notes">
|
<Section title="Notes">
|
||||||
<DrawerCard tone="subtle">
|
<DrawerCard tone="subtle">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMemo, useState, type FormEvent } from 'react';
|
import { useMemo, useState, type FormEvent } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { Button } from '@astryxdesign/core/Button';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
@@ -77,6 +79,17 @@ export function OnboardBranchPage() {
|
|||||||
|
|
||||||
const defaultTenantId = branchScope?.tenantid ? String(branchScope.tenantid) : '';
|
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>(() => ({
|
const [form, setForm] = useState<FormState>(() => ({
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
tenantid: defaultTenantId,
|
tenantid: defaultTenantId,
|
||||||
@@ -150,8 +163,32 @@ export function OnboardBranchPage() {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
|
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
|
||||||
onSuccess: async () => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
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)),
|
onError: (cause) => setError(errorMessage(cause)),
|
||||||
});
|
});
|
||||||
@@ -495,9 +532,51 @@ export function OnboardBranchPage() {
|
|||||||
step={5}
|
step={5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</VStack>
|
||||||
</Card>
|
</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 Alert */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<HStack
|
<HStack
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { DeliverySlotsCard } from '../DeliverySlotsCard';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Phone,
|
Phone,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Clock,
|
||||||
Sliders,
|
Sliders,
|
||||||
Store,
|
Store,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
@@ -603,6 +605,16 @@ export function ShopProfilePage() {
|
|||||||
trades under, whether it is approved — because knowing what is set
|
trades under, whether it is approved — because knowing what is set
|
||||||
and who sets it is more useful than a page of disabled inputs. */
|
and who sets it is more useful than a page of disabled inputs. */
|
||||||
<div className="sp-cards">
|
<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">
|
<ProfileCard icon={<Sliders size={15} />} title="Store configuration" span="full">
|
||||||
<Row label="App location" value={text('applocation')} />
|
<Row label="App location" value={text('applocation')} />
|
||||||
<Row
|
<Row
|
||||||
|
|||||||
Reference in New Issue
Block a user