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