- storefront applied maxLocations to the first locations by priority, even switched-off ones, so a Starter shop whose enabled location wasn't first got no methods - switching on a location beyond the plan limit now explains why instead of being ignored - widget shows a message (en/fr) when no method is available and clears aria-busy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
19 KiB
TypeScript
344 lines
19 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
||
import { randomUUID } from "node:crypto";
|
||
import prisma from "../db.server";
|
||
import type { DateException, DeliveryZone, LocationConfig, Method, MethodConfig, SlotTemplate } from "../engine/types";
|
||
import { METHODS } from "../engine/types";
|
||
import {
|
||
adminContext,
|
||
formBool,
|
||
formInt,
|
||
formList,
|
||
formMoney,
|
||
formString,
|
||
parseRanges,
|
||
} from "../lib/admin.server";
|
||
import { METHOD_LABEL, money, niceDate, rangesToText } from "../lib/format";
|
||
import { bumpConfig, toLocationConfig } from "../services/config.server";
|
||
import { ExceptionSchema, MethodConfigSchema, ZoneSchema } from "../services/settings";
|
||
|
||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const { cfg, admin, features } = await adminContext(request);
|
||
const row = await prisma.location.findFirst({ where: { id: params.id, shopId: cfg.shop.id } });
|
||
if (!row) throw new Response("Not found", { status: 404 });
|
||
const res = await admin.graphql(`#graphql
|
||
query { locations(first: 50) { nodes { id name } } }`);
|
||
const shopifyLocations = ((await res.json()) as { data: { locations: { nodes: { id: string; name: string }[] } } }).data.locations.nodes;
|
||
return { loc: toLocationConfig(row), shopifyLocations, currency: cfg.shop.currency, drivingAllowed: features.drivingDistance };
|
||
};
|
||
|
||
/** "10:00-13:00 +3.00 x4, 13:00-16:00" → explicit slot templates. */
|
||
function parseSlots(s: string): SlotTemplate[] {
|
||
if (!s.trim()) return [];
|
||
return s.split(",").map((part) => {
|
||
const p = part.trim();
|
||
const [range] = parseRanges(p.split(/\s+/)[0]);
|
||
const sur = p.match(/\+\s*(\d+(?:\.\d{1,2})?)/);
|
||
const cap = p.match(/x\s*(\d+)/i);
|
||
return { ...range, surcharge: sur ? Math.round(Number(sur[1]) * 100) : undefined, capacity: cap ? Number(cap[1]) : undefined };
|
||
});
|
||
}
|
||
const slotsToText = (list?: SlotTemplate[]) =>
|
||
(list ?? [])
|
||
.map((s) => `${s.start}-${s.end}${s.surcharge ? ` +${(s.surcharge / 100).toFixed(2)}` : ""}${s.capacity != null ? ` x${s.capacity}` : ""}`)
|
||
.join(", ");
|
||
|
||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||
const { cfg, features } = await adminContext(request);
|
||
const row = await prisma.location.findFirst({ where: { id: params.id, shopId: cfg.shop.id } });
|
||
if (!row) throw new Response("Not found", { status: 404 });
|
||
const loc = toLocationConfig(row);
|
||
const fd = await request.formData();
|
||
const intent = formString(fd, "intent");
|
||
const save = async (data: Parameters<typeof prisma.location.update>[0]["data"]) => {
|
||
await prisma.location.update({ where: { id: row.id }, data });
|
||
await bumpConfig(cfg.shop.id, cfg.shop.domain);
|
||
};
|
||
try {
|
||
if (intent === "basics") {
|
||
const lat = formString(fd, "lat");
|
||
const lng = formString(fd, "lng");
|
||
if (formBool(fd, "enabled") && !row.enabled) {
|
||
const on = cfg.locations.filter((l) => l.enabled && l.id !== row.id).length;
|
||
if (on >= features.maxLocations)
|
||
return { ok: false, message: `Your plan takes bookings at ${features.maxLocations} location${features.maxLocations === 1 ? "" : "s"}. Switch another location off, or upgrade in Settings.` };
|
||
}
|
||
await save({
|
||
name: formString(fd, "name") || "Location",
|
||
address: formString(fd, "address"),
|
||
timezone: formString(fd, "timezone") || cfg.shop.timezone,
|
||
lat: lat ? Number(lat) : null,
|
||
lng: lng ? Number(lng) : null,
|
||
priority: formInt(fd, "priority") ?? 1,
|
||
enabled: formBool(fd, "enabled"),
|
||
pickupInstructions: formString(fd, "pickupInstructions") || null,
|
||
shopifyLocationId: formString(fd, "shopifyLocationId") || null,
|
||
});
|
||
return { ok: true, message: "Location saved" };
|
||
}
|
||
if (intent.startsWith("method:")) {
|
||
const m = intent.slice(7) as Method;
|
||
const weeklyHours: MethodConfig["weeklyHours"] = {};
|
||
const slots: NonNullable<MethodConfig["slots"]> = {};
|
||
for (let d = 0; d < 7; d++) {
|
||
const h = parseRanges(formString(fd, `hours_${d}`));
|
||
if (h.length) weeklyHours[d as 0] = h;
|
||
const s = parseSlots(formString(fd, `slots_${d}`));
|
||
if (s.length) slots[d as 0] = s;
|
||
}
|
||
const cfgM: MethodConfig = {
|
||
enabled: formBool(fd, "enabled"),
|
||
weeklyHours,
|
||
slotMode: formString(fd, "slotMode") as MethodConfig["slotMode"],
|
||
slotMinutes: formInt(fd, "slotMinutes") ?? undefined,
|
||
slots,
|
||
prepMinutes: Math.round((Number(formString(fd, "prepHours")) || 0) * 60),
|
||
prepMode: formString(fd, "prepMode") === "operating" ? "operating" : "elapsed",
|
||
sameDayCutoff: formString(fd, "sameDayCutoff") || null,
|
||
nextDayCutoff: formString(fd, "nextDayCutoff") || null,
|
||
horizonDays: formInt(fd, "horizonDays") ?? 30,
|
||
dailyCapacity: formInt(fd, "dailyCapacity"),
|
||
slotCapacity: formInt(fd, "slotCapacity"),
|
||
};
|
||
const parsed = MethodConfigSchema.safeParse(cfgM);
|
||
if (!parsed.success) return { ok: false, message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
|
||
await save({ methods: { ...loc.methods, [m]: cfgM } as object });
|
||
return { ok: true, message: `${METHOD_LABEL[m]} settings saved` };
|
||
}
|
||
if (intent === "add-exception") {
|
||
const closed = formString(fd, "kind") === "closed";
|
||
const ex: DateException = {
|
||
date: formString(fd, "date"),
|
||
closed: closed || undefined,
|
||
hours: closed ? undefined : parseRanges(formString(fd, "hours")),
|
||
methods: METHODS.filter((m) => formBool(fd, `ex_${m}`)),
|
||
note: formString(fd, "note") || undefined,
|
||
};
|
||
if (!ex.methods?.length) delete ex.methods;
|
||
const p = ExceptionSchema.safeParse(ex);
|
||
if (!p.success || (!closed && !ex.hours?.length)) return { ok: false, message: "Enter a date, and hours unless the location is closed" };
|
||
const list = [...loc.exceptions, ex].sort((a, b) => (a.date < b.date ? -1 : 1));
|
||
await save({ exceptions: list as object[] });
|
||
return { ok: true, message: `Added ${closed ? "closure" : "special hours"} for ${ex.date}` };
|
||
}
|
||
if (intent.startsWith("del-exception:")) {
|
||
const i = Number(intent.split(":")[1]);
|
||
await save({ exceptions: loc.exceptions.filter((_, n) => n !== i) as object[] });
|
||
return { ok: true, message: "Removed" };
|
||
}
|
||
if (intent === "add-zone") {
|
||
const driving = formInt(fd, "drivingKm");
|
||
if (driving && !features.drivingDistance) return { ok: false, message: "Driving-distance zones are on the Pro plan" };
|
||
const z: DeliveryZone = {
|
||
id: randomUUID().slice(0, 8),
|
||
name: formString(fd, "zname") || "Zone",
|
||
postalPatterns: formList(fd, "postal"),
|
||
radiusKm: formInt(fd, "radiusKm"),
|
||
drivingKm: driving,
|
||
fee: formMoney(fd, "fee") ?? 0,
|
||
freeOver: formMoney(fd, "freeOver"),
|
||
minOrder: formMoney(fd, "minOrder"),
|
||
};
|
||
const p = ZoneSchema.safeParse(z);
|
||
if (!p.success || (!z.postalPatterns?.length && z.radiusKm == null && z.drivingKm == null))
|
||
return { ok: false, message: "Give the zone postal codes, a radius or a driving distance" };
|
||
await save({ zones: [...loc.zones, z] as object[] });
|
||
return { ok: true, message: `Zone "${z.name}" added` };
|
||
}
|
||
if (intent.startsWith("del-zone:")) {
|
||
const id = intent.split(":")[1];
|
||
await save({ zones: loc.zones.filter((z) => z.id !== id) as object[] });
|
||
return { ok: true, message: "Zone removed" };
|
||
}
|
||
if (intent.startsWith("move-zone:")) {
|
||
const [, id, dir] = intent.split(":");
|
||
const zs = [...loc.zones];
|
||
const i = zs.findIndex((z) => z.id === id);
|
||
const j = dir === "up" ? i - 1 : i + 1;
|
||
if (i >= 0 && j >= 0 && j < zs.length) [zs[i], zs[j]] = [zs[j], zs[i]];
|
||
await save({ zones: zs as object[] });
|
||
return { ok: true, message: "Zone order updated" };
|
||
}
|
||
} catch (e) {
|
||
return { ok: false, message: (e as Error).message };
|
||
}
|
||
return { ok: false, message: "Unknown action" };
|
||
};
|
||
|
||
export default function LocationEdit() {
|
||
const { loc, shopifyLocations, currency, drivingAllowed } = useLoaderData<typeof loader>();
|
||
const result = useActionData<typeof action>();
|
||
const busy = useNavigation().state !== "idle";
|
||
return (
|
||
<s-page heading={loc.name} inlineSize="large">
|
||
<s-link slot="breadcrumb-actions" href="/app/locations">Locations</s-link>
|
||
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
|
||
|
||
<s-section heading="Location">
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="basics" />
|
||
<s-stack gap="base">
|
||
<s-switch name="enabled" label="Taking bookings" defaultChecked={loc.enabled} />
|
||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
|
||
<s-text-field name="name" label="Name customers see" defaultValue={loc.name} required />
|
||
<s-text-field name="address" label="Address" defaultValue={loc.address} />
|
||
<s-select name="shopifyLocationId" label="Shopify location (for inventory)" value={loc.shopifyLocationId ?? ""}>
|
||
<s-option value="">Not linked</s-option>
|
||
{shopifyLocations.map((s) => (
|
||
<s-option key={s.id} value={s.id}>{s.name}</s-option>
|
||
))}
|
||
</s-select>
|
||
<s-text-field name="timezone" label="Time zone" defaultValue={loc.timezone} details="IANA name, e.g. America/Toronto" />
|
||
<s-text-field name="lat" label="Latitude" defaultValue={loc.lat != null ? String(loc.lat) : ""} details="Needed for radius zones and run sheets" />
|
||
<s-text-field name="lng" label="Longitude" defaultValue={loc.lng != null ? String(loc.lng) : ""} />
|
||
<s-number-field name="priority" label="Priority" defaultValue={String(loc.priority)} min={1} details="1 is offered first" />
|
||
</s-grid>
|
||
<s-text-area name="pickupInstructions" label="Pickup instructions" defaultValue={loc.pickupInstructions ?? ""} rows={2} />
|
||
<s-button type="submit" variant="primary" loading={busy}>Save location</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
</s-section>
|
||
|
||
{METHODS.map((m) => (
|
||
<MethodSection key={m} method={m} loc={loc} busy={busy} />
|
||
))}
|
||
|
||
<s-section heading="Closures and special hours">
|
||
<s-stack gap="base">
|
||
{loc.exceptions.length === 0 && <s-paragraph>No closures yet. Add holidays here; they override the weekly hours.</s-paragraph>}
|
||
{loc.exceptions.map((e, i) => (
|
||
<Form method="post" key={`${e.date}-${i}`}>
|
||
<input type="hidden" name="intent" value={`del-exception:${i}`} />
|
||
<s-stack direction="inline" gap="base" alignItems="center">
|
||
<s-badge tone={e.closed ? "critical" : "info"}>{e.closed ? "Closed" : rangesToText(e.hours)}</s-badge>
|
||
<s-text>{niceDate(e.date, true)}</s-text>
|
||
<s-text color="subdued">{e.methods?.map((x) => METHOD_LABEL[x]).join(", ") || "All methods"}{e.note ? ` · ${e.note}` : ""}</s-text>
|
||
<s-button type="submit" variant="tertiary" tone="critical">Remove</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
))}
|
||
<s-divider />
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="add-exception" />
|
||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(170px, 1fr))" gap="base" alignItems="end">
|
||
<s-date-field name="date" label="Date" required />
|
||
<s-select name="kind" label="Type">
|
||
<s-option value="closed">Closed</s-option>
|
||
<s-option value="hours">Special hours</s-option>
|
||
</s-select>
|
||
<s-text-field name="hours" label="Special hours" placeholder="09:00-15:00" />
|
||
<s-text-field name="note" label="Note for customers" placeholder="Diwali" />
|
||
</s-grid>
|
||
<s-stack direction="inline" gap="base" alignItems="center">
|
||
<s-text color="subdued">Applies to (none ticked = all):</s-text>
|
||
{METHODS.map((x) => (
|
||
<s-checkbox key={x} name={`ex_${x}`} label={METHOD_LABEL[x]} />
|
||
))}
|
||
<s-button type="submit">Add</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
</s-stack>
|
||
</s-section>
|
||
|
||
<s-section heading="Delivery zones">
|
||
<s-stack gap="base">
|
||
<s-paragraph>The first matching zone sets the fee. Being inside a zone doesn't make delivery free unless you set a free-delivery threshold.</s-paragraph>
|
||
{loc.zones.map((z, i) => (
|
||
<s-box key={z.id} padding="base" border="base" borderRadius="base">
|
||
<s-stack direction="inline" gap="base" alignItems="center" justifyContent="space-between">
|
||
<s-stack gap="small-300">
|
||
<s-text type="strong">{i + 1}. {z.name}</s-text>
|
||
<s-text color="subdued">
|
||
{[z.postalPatterns?.length ? `Postal: ${z.postalPatterns.join(", ")}` : "", z.radiusKm != null ? `Within ${z.radiusKm} km` : "", z.drivingKm != null ? `Driving ≤ ${z.drivingKm} km` : ""].filter(Boolean).join(" · ")}
|
||
</s-text>
|
||
<s-text>
|
||
Fee {money(z.fee, currency)}
|
||
{z.freeOver != null ? ` · free over ${money(z.freeOver, currency)}` : ""}
|
||
{z.minOrder != null ? ` · minimum order ${money(z.minOrder, currency)}` : ""}
|
||
</s-text>
|
||
</s-stack>
|
||
<s-stack direction="inline" gap="small-200">
|
||
<Form method="post"><input type="hidden" name="intent" value={`move-zone:${z.id}:up`} /><s-button type="submit" variant="tertiary" disabled={i === 0}>Move up</s-button></Form>
|
||
<Form method="post"><input type="hidden" name="intent" value={`del-zone:${z.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Remove</s-button></Form>
|
||
</s-stack>
|
||
</s-stack>
|
||
</s-box>
|
||
))}
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="add-zone" />
|
||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(170px, 1fr))" gap="base" alignItems="end">
|
||
<s-text-field name="zname" label="Zone name" placeholder="Kitchener–Waterloo" required />
|
||
<s-text-field name="postal" label="Postal codes" placeholder="N2H*, N2G*, N2J..N2M" details="Prefix with *, ranges with .." />
|
||
<s-number-field name="radiusKm" label="Or radius (km)" min={0} />
|
||
<s-number-field name="drivingKm" label="Or driving distance (km)" min={0} disabled={!drivingAllowed} details={drivingAllowed ? undefined : "Pro plan"} />
|
||
<s-money-field name="fee" label="Fee" />
|
||
<s-money-field name="freeOver" label="Free over" />
|
||
<s-money-field name="minOrder" label="Minimum order" />
|
||
</s-grid>
|
||
<s-box paddingBlockStart="base"><s-button type="submit">Add zone</s-button></s-box>
|
||
</Form>
|
||
</s-stack>
|
||
</s-section>
|
||
</s-page>
|
||
);
|
||
}
|
||
|
||
function MethodSection({ method, loc, busy }: { method: Method; loc: LocationConfig; busy: boolean }) {
|
||
const c: MethodConfig = loc.methods[method] ?? {
|
||
enabled: false, weeklyHours: {}, slotMode: method === "shipping" ? "dateOnly" : "generated", slotMinutes: 60, prepMinutes: 120,
|
||
prepMode: "elapsed", horizonDays: 30,
|
||
};
|
||
return (
|
||
<s-section heading={METHOD_LABEL[method]}>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value={`method:${method}`} />
|
||
<s-stack gap="base">
|
||
<s-switch name="enabled" label={`Offer ${METHOD_LABEL[method].toLowerCase()} from this location`} defaultChecked={c.enabled} />
|
||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(170px, 1fr))" gap="base">
|
||
<s-select name="slotMode" label="Customers choose" value={c.slotMode}>
|
||
<s-option value="generated">Date and time (even slots)</s-option>
|
||
<s-option value="explicit">Date and time (my own slots)</s-option>
|
||
<s-option value="dateOnly">Date only</s-option>
|
||
</s-select>
|
||
<s-number-field name="slotMinutes" label="Slot length (minutes)" defaultValue={String(c.slotMinutes ?? 60)} min={15} step={15} />
|
||
<s-number-field name="prepHours" label="Preparation time (hours)" defaultValue={String(c.prepMinutes / 60)} min={0} step={0.5} />
|
||
<s-select name="prepMode" label="Count preparation in" value={c.prepMode}>
|
||
<s-option value="elapsed">Clock hours</s-option>
|
||
<s-option value="operating">Opening hours only</s-option>
|
||
</s-select>
|
||
<s-text-field name="sameDayCutoff" label="Same-day cutoff" defaultValue={c.sameDayCutoff ?? ""} placeholder="14:00" details="Blank = no cutoff" />
|
||
<s-text-field name="nextDayCutoff" label="Next-day cutoff" defaultValue={c.nextDayCutoff ?? ""} placeholder="20:00" />
|
||
<s-number-field name="horizonDays" label="Bookable days ahead" defaultValue={String(c.horizonDays)} min={1} max={365} />
|
||
<s-number-field name="slotCapacity" label="Orders per slot" defaultValue={c.slotCapacity != null ? String(c.slotCapacity) : ""} min={0} details="Blank = unlimited" />
|
||
<s-number-field name="dailyCapacity" label="Orders per day" defaultValue={c.dailyCapacity != null ? String(c.dailyCapacity) : ""} min={0} />
|
||
</s-grid>
|
||
<s-table>
|
||
<s-table-header-row>
|
||
<s-table-header>Day</s-table-header>
|
||
<s-table-header>Opening hours</s-table-header>
|
||
<s-table-header>Own slots (if chosen)</s-table-header>
|
||
</s-table-header-row>
|
||
<s-table-body>
|
||
{DAYS.map((d, i) => (
|
||
<s-table-row key={d}>
|
||
<s-table-cell>{d}</s-table-cell>
|
||
<s-table-cell>
|
||
<s-text-field label={`${d} hours`} labelAccessibilityVisibility="exclusive" name={`hours_${i}`} defaultValue={rangesToText(c.weeklyHours[i as 0])} placeholder="Closed" />
|
||
</s-table-cell>
|
||
<s-table-cell>
|
||
<s-text-field label={`${d} slots`} labelAccessibilityVisibility="exclusive" name={`slots_${i}`} defaultValue={slotsToText(c.slots?.[i as 0])} placeholder="10:00-13:00, 16:00-19:00 +3.00 x4" />
|
||
</s-table-cell>
|
||
</s-table-row>
|
||
))}
|
||
</s-table-body>
|
||
</s-table>
|
||
<s-text color="subdued">Hours like 10:00-13:00, 14:00-18:00. Own slots can add a surcharge (+3.00) and their own limit (x4).</s-text>
|
||
<s-button type="submit" variant="primary" loading={busy}>Save {METHOD_LABEL[method].toLowerCase()}</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
</s-section>
|
||
);
|
||
}
|