152 lines
8.8 KiB
TypeScript
152 lines
8.8 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
|
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
|
import prisma from "../db.server";
|
|
import { localParts } from "../engine/time";
|
|
import { METHODS } from "../engine/types";
|
|
import { adminContext, formBool, formInt, formList, formMoney, formString } from "../lib/admin.server";
|
|
import { METHOD_LABEL, money, niceDate } from "../lib/format";
|
|
import { bumpConfig } from "../services/config.server";
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const { cfg, features } = await adminContext(request);
|
|
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
|
const counters = await prisma.capacityCounter.findMany({
|
|
where: { shopId: cfg.shop.id, slot: "*", date: { in: cfg.peakDays.flatMap((p) => p.dates) } },
|
|
});
|
|
const waitlist = await prisma.waitlistEntry.findMany({ where: { shopId: cfg.shop.id, date: { gte: today } }, orderBy: { createdAt: "asc" }, take: 200 });
|
|
return {
|
|
peaks: cfg.peakDays.map((p) => ({
|
|
...p,
|
|
booked: p.dates.map((d) => ({ d, n: counters.filter((c) => c.date === d && (!p.methods?.length || p.methods.includes(c.method as "pickup"))).reduce((s, c) => s + c.count, 0) })),
|
|
})),
|
|
waitlist: waitlist.map((w) => ({ ...w, createdAt: w.createdAt.toISOString(), offeredAt: w.offeredAt?.toISOString() ?? null })),
|
|
locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })),
|
|
allowed: features.peakDays,
|
|
currency: cfg.shop.currency,
|
|
};
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { cfg, features } = await adminContext(request);
|
|
if (!features.peakDays) return { ok: false, message: "Rush control is on the Growth plan" };
|
|
const fd = await request.formData();
|
|
const intent = formString(fd, "intent");
|
|
if (intent.startsWith("delete:")) {
|
|
await prisma.peakDay.deleteMany({ where: { id: intent.split(":")[1], shopId: cfg.shop.id } });
|
|
await bumpConfig(cfg.shop.id, cfg.shop.domain);
|
|
return { ok: true, message: "Peak day removed" };
|
|
}
|
|
const dates = formList(fd, "dates").filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d));
|
|
if (!dates.length) return { ok: false, message: "Add at least one date (YYYY-MM-DD)" };
|
|
const data = {
|
|
name: formString(fd, "name") || "Peak day",
|
|
dates,
|
|
methods: METHODS.filter((m) => formBool(fd, `m_${m}`)),
|
|
locationIds: cfg.locations.filter((l) => formBool(fd, `loc_${l.id}`)).map((l) => l.id),
|
|
dailyCapacity: formInt(fd, "dailyCapacity"),
|
|
slotCapacity: formInt(fd, "slotCapacity"),
|
|
extraPrepMinutes: Math.round((Number(formString(fd, "extraPrepHours")) || 0) * 60),
|
|
cutoffDaysBefore: formInt(fd, "cutoffDays"),
|
|
cutoffTime: formString(fd, "cutoffTime") || null,
|
|
surcharge: formMoney(fd, "surcharge") ?? 0,
|
|
waitlist: formBool(fd, "waitlist") && features.waitlist,
|
|
message: formString(fd, "message") || null,
|
|
};
|
|
const id = formString(fd, "id");
|
|
if (id) await prisma.peakDay.updateMany({ where: { id, shopId: cfg.shop.id }, data });
|
|
else await prisma.peakDay.create({ data: { ...data, shopId: cfg.shop.id } });
|
|
await bumpConfig(cfg.shop.id, cfg.shop.domain);
|
|
return { ok: true, message: `${data.name} saved` };
|
|
};
|
|
|
|
type PeakView = ReturnType<typeof useLoaderData<typeof loader>>["peaks"][number];
|
|
|
|
export default function Peak() {
|
|
const { peaks, waitlist, locations, allowed, currency } = useLoaderData<typeof loader>();
|
|
const result = useActionData<typeof action>();
|
|
const busy = useNavigation().state !== "idle";
|
|
return (
|
|
<s-page heading="Rush control">
|
|
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
|
|
{!allowed && <s-banner tone="info" heading="Growth plan feature">Separate limits, earlier cutoffs and waitlists for festival days. <s-link href="/app/settings#plan">See plans</s-link></s-banner>}
|
|
<s-section>
|
|
<s-paragraph>
|
|
On festival days demand jumps and mistakes are expensive. Set a separate order limit, close orders earlier, add a peak-handling fee, and let customers join a waitlist instead of leaving when you're full. When an order is cancelled, the first person waiting is offered the place for a limited time.
|
|
</s-paragraph>
|
|
</s-section>
|
|
{peaks.map((p) => (
|
|
<s-section key={p.id} heading={p.name}>
|
|
<s-stack gap="base">
|
|
<s-stack direction="inline" gap="base">
|
|
{p.booked.map((b) => (
|
|
<s-badge key={b.d} tone={p.dailyCapacity != null && b.n >= p.dailyCapacity ? "critical" : "info"}>
|
|
{`${niceDate(b.d)}: ${b.n}${p.dailyCapacity != null ? ` / ${p.dailyCapacity}` : ""} booked`}
|
|
</s-badge>
|
|
))}
|
|
</s-stack>
|
|
<PeakForm p={p} locations={locations} currency={currency} busy={busy} />
|
|
<Form method="post"><input type="hidden" name="intent" value={`delete:${p.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Delete {p.name}</s-button></Form>
|
|
</s-stack>
|
|
</s-section>
|
|
))}
|
|
<s-section heading="Add a peak day">
|
|
<PeakForm locations={locations} currency={currency} busy={busy} />
|
|
</s-section>
|
|
<s-section heading="Waitlist" padding="none">
|
|
<s-table>
|
|
<s-table-header-row>
|
|
<s-table-header listSlot="primary">Customer</s-table-header>
|
|
<s-table-header>Wants</s-table-header>
|
|
<s-table-header>Joined</s-table-header>
|
|
<s-table-header>Status</s-table-header>
|
|
</s-table-header-row>
|
|
<s-table-body>
|
|
{waitlist.map((w) => (
|
|
<s-table-row key={w.id}>
|
|
<s-table-cell>{w.name ?? w.email ?? w.phone}</s-table-cell>
|
|
<s-table-cell>{`${niceDate(w.date)} · ${METHOD_LABEL[w.method]} · ${w.slot === "*" ? "any time" : w.slot}`}</s-table-cell>
|
|
<s-table-cell>{new Date(w.createdAt).toLocaleString("en-CA")}</s-table-cell>
|
|
<s-table-cell><s-badge tone={w.offeredAt ? "success" : "warning"}>{w.offeredAt ? "Offered a place" : "Waiting"}</s-badge></s-table-cell>
|
|
</s-table-row>
|
|
))}
|
|
</s-table-body>
|
|
</s-table>
|
|
{waitlist.length === 0 && <s-box padding="base"><s-paragraph>Nobody is waiting.</s-paragraph></s-box>}
|
|
</s-section>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
function PeakForm({ p, locations, currency, busy }: { p?: PeakView; locations: { id: string; name: string }[]; currency: string; busy: boolean }) {
|
|
return (
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="save" />
|
|
{p && <input type="hidden" name="id" value={p.id} />}
|
|
<s-stack gap="base">
|
|
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base" alignItems="end">
|
|
<s-text-field name="name" label="Name" defaultValue={p?.name ?? ""} placeholder="Diwali" required />
|
|
<s-text-field name="dates" label="Dates" defaultValue={p?.dates.join(", ") ?? ""} placeholder="2026-11-07, 2026-11-08" required />
|
|
<s-number-field name="dailyCapacity" label="Orders per day" defaultValue={p?.dailyCapacity != null ? String(p.dailyCapacity) : ""} min={0} />
|
|
<s-number-field name="slotCapacity" label="Orders per slot" defaultValue={p?.slotCapacity != null ? String(p.slotCapacity) : ""} min={0} />
|
|
<s-number-field name="extraPrepHours" label="Extra preparation (hours)" defaultValue={p ? String((p.extraPrepMinutes ?? 0) / 60) : "0"} min={0} step={0.5} />
|
|
<s-number-field name="cutoffDays" label="Close orders days before" defaultValue={p?.cutoff ? String(p.cutoff.daysBefore) : ""} min={0} />
|
|
<s-text-field name="cutoffTime" label="…at" defaultValue={p?.cutoff?.time ?? "18:00"} />
|
|
<s-money-field name="surcharge" label={`Peak fee (${currency})`} defaultValue={p?.surcharge ? (p.surcharge / 100).toFixed(2) : ""} />
|
|
</s-grid>
|
|
<s-text-field name="message" label="Banner customers see" defaultValue={p?.message ?? ""} placeholder="Diwali orders close Friday at 6 PM" />
|
|
<s-stack direction="inline" gap="base">
|
|
<s-text>Methods (none = all):</s-text>
|
|
{METHODS.map((m) => <s-checkbox key={m} name={`m_${m}`} label={METHOD_LABEL[m]} defaultChecked={!!p?.methods?.includes(m)} />)}
|
|
</s-stack>
|
|
<s-stack direction="inline" gap="base">
|
|
<s-text>Locations (none = all):</s-text>
|
|
{locations.map((l) => <s-checkbox key={l.id} name={`loc_${l.id}`} label={l.name} defaultChecked={!!p?.locationIds?.includes(l.id)} />)}
|
|
</s-stack>
|
|
<s-checkbox name="waitlist" label="When full, offer a waitlist" defaultChecked={p?.waitlist ?? true} />
|
|
{p?.surcharge ? <s-text color="subdued">Current peak fee {money(p.surcharge, currency)}</s-text> : null}
|
|
<s-button type="submit" variant={p ? "secondary" : "primary"} loading={busy}>{p ? "Save changes" : "Add peak day"}</s-button>
|
|
</s-stack>
|
|
</Form>
|
|
);
|
|
}
|