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[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"); 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 = {}; 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(); const result = useActionData(); const busy = useNavigation().state !== "idle"; return ( Locations {result && {result.message}}
Not linked {shopifyLocations.map((s) => ( {s.name} ))} Save location
{METHODS.map((m) => ( ))} {loc.exceptions.length === 0 && No closures yet. Add holidays here; they override the weekly hours.} {loc.exceptions.map((e, i) => (
{e.closed ? "Closed" : rangesToText(e.hours)} {niceDate(e.date, true)} {e.methods?.map((x) => METHOD_LABEL[x]).join(", ") || "All methods"}{e.note ? ` · ${e.note}` : ""} Remove
))}
Closed Special hours Applies to (none ticked = all): {METHODS.map((x) => ( ))} Add
The first matching zone sets the fee. Being inside a zone doesn't make delivery free unless you set a free-delivery threshold. {loc.zones.map((z, i) => ( {i + 1}. {z.name} {[z.postalPatterns?.length ? `Postal: ${z.postalPatterns.join(", ")}` : "", z.radiusKm != null ? `Within ${z.radiusKm} km` : "", z.drivingKm != null ? `Driving ≤ ${z.drivingKm} km` : ""].filter(Boolean).join(" · ")} Fee {money(z.fee, currency)} {z.freeOver != null ? ` · free over ${money(z.freeOver, currency)}` : ""} {z.minOrder != null ? ` · minimum order ${money(z.minOrder, currency)}` : ""}
Move up
Remove
))}
Add zone
); } 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 (
Date and time (even slots) Date and time (my own slots) Date only Clock hours Opening hours only Day Opening hours Own slots (if chosen) {DAYS.map((d, i) => ( {d} ))} Hours like 10:00-13:00, 14:00-18:00. Own slots can add a surcharge (+3.00) and their own limit (x4). Save {METHOD_LABEL[method].toLowerCase()}
); }