datewise/app/routes/app.locations.$id.tsx
metatroncubeswdev aacd9a8e06 fix: plan location limit counts switched-on locations; widget empty state
- 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>
2026-10-15 05:26:03 -04:00

344 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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