chore: initial Datewise app (rebranded from SlotSure prototype)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-13 20:06:49 -04:00
commit e56182e0e3
105 changed files with 20537 additions and 0 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# Filled in automatically by `shopify app dev`; set by hand in production.
SHOPIFY_API_KEY=
SHOPIFY_API_SECRET=
SHOPIFY_APP_URL=https://your-app.example.com
SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations
# PostgreSQL 14+ (row locks are required for capacity holds; SQLite is not supported)
DATABASE_URL=postgresql://user:password@localhost:5432/datewise
# 32 random bytes, base64: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
APP_ENCRYPTION_KEY=
# Optional: radius / driving-distance zones (Geocoding + Distance Matrix APIs)
GOOGLE_MAPS_API_KEY=
# Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend.
RESEND_API_KEY=
EMAIL_FROM_DOMAIN=mail.your-app.example.com

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules
/build
/.react-router
.env
.env.*
!.env.example
.shopify
extensions/*/dist
extensions/*/node_modules
*.log
.DS_Store

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
# One image, two processes: the web app (default) and the worker (`npm run worker`).
FROM node:22-alpine
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm i --no-save tsx && npm cache clean --force
COPY . .
RUN npx prisma generate && npm run build
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && npm run start"]

129
README.md Normal file
View File

@ -0,0 +1,129 @@
# Datewise: delivery & pickup scheduling for Shopify
A Shopify app (built for public App Store listing) that lets shoppers choose **local delivery, store pickup or shipping**, then a **location, date and time slot**. The merchant controls hours, preparation time, cutoffs, holidays, capacity, product rules, delivery zones and fees.
It's modelled on the scope of *DS Delivery Date-Time* (see the spec in `docs/`), and aims at the pain points merchants actually lose money on:
| Pain point | What Datewise does |
|---|---|
| **Overbooking.** Two shoppers take the last slot, or the cart shows a slot that is gone by checkout. | Real capacity **holds**: picking a slot reserves it for N minutes (row-locked in Postgres). Late orders are re-reserved or sent to a **review queue**, never silently overbooked. |
| **Festival rush** (Diwali, Mother's Day, Valentine's) | **Rush control**: separate per-day/per-slot limits, earlier cutoff ("closes 2 days before at 6 PM"), extra prep, peak fee, and a **waitlist** that offers freed places automatically. |
| **Perishables promised on dates they can't be fresh** | **Fresh-stock windows**: tie bookable dates to stock arrival dates + shelf life; the calendar tells customers when the next fresh batch is ready. |
| **"Can I change my delivery time?" emails** | **Customer self-reschedule** on the order status page, within the merchant's policy (up to X hours before, max N changes), with the same rule checks as checkout. |
| **No-shows and missed deliveries** | **Reminders** by email, SMS (Twilio) or WhatsApp (Meta Cloud API), re-planned automatically on every change. |
| **Driver chaos** | **Daily run sheet**: stops grouped by promised window, ordered nearest-next, Google Maps route links, CSV. |
| **Buy now / Shop Pay skipping the cart widget** | **Checkout guard**, a Cart & Checkout Validation Function (works on **every plan, including Basic**): blocks checkout if a required time is missing or past, or if the fee was tampered with. |
| **"Why can't the customer pick Friday?"** | **Diagnostics** screen that runs the exact checkout engine and names the rule, closure, cutoff or capacity limit responsible. |
---
## Architecture
```mermaid
flowchart LR
subgraph Storefront
W[Theme app block + embed<br/>datewise.js] -->|/apps/datewise/* app proxy, HMAC-signed| P
G[Checkout guard<br/>validation function]
O[Order status block<br/>customer account extension] -->|session token| C
end
subgraph App server [React Router app]
P[proxy.* routes] --> S[storefront.server]
C[api.customer.booking] --> B[bookings.server]
A[Admin UI routes<br/>Polaris web components] --> B
H[webhooks.orders.*] --> OR[orders.server]
S & B & OR --> E[[engine/ <br/>pure TS, tested]]
S & B & OR --> R[(reservations.server<br/>row-locked counters)]
end
R --> DB[(PostgreSQL)]
K[worker.ts<br/>sweep holds · reminders · reconcile] --> DB
K --> N[Resend · Twilio · WhatsApp]
```
* **`app/engine/`** is the scheduling brain: hours, closures, cutoffs, prep (clock or opening-hours), DST-safe time zones, product rules, freshness, peak days, capacity, zones and fees, location ranking, reminders, run sheets. It's pure TypeScript with no I/O, and the clickable prototype runs this same code in the browser.
* **`app/services/reservations.server.ts`** holds the capacity contract. Every location/method/date/slot has a counter row. Mutations lock the rows they touch in sorted order (`SELECT … FOR UPDATE`), so there are no races and no deadlocks.
* **Selection → order**: the widget writes a `_datewise` cart attribute (token, method, location, date, slot, quoted fee, rule version), plus readable attributes for staff ("Local delivery date: Sat, Nov 7"). The `orders/create` webhook converts the hold into a booking, tags the order, writes a `datewise.booking` metafield and plans reminders.
* **Fees on every plan**: carrier-calculated rates aren't available on Basic, so fees are added as a hidden *Delivery & handling* product line (one variant per amount, created on demand). The checkout guard and the webhook both check that the line matches the quote.
## Repository layout
```
app/engine/ scheduling engine (+ __tests__, 43 tests)
app/services/ reservations, storefront API, orders, bookings, reminders, waitlist, fees, catalog, maps, messaging
app/routes/app.* merchant admin (Today, Bookings, Locations & hours, Product rules, Fresh stock, Rush control, Run sheet, Diagnostics, Settings)
app/routes/proxy.* storefront API behind the Shopify app proxy
app/routes/api.customer.* customer-account API (self-reschedule)
app/routes/webhooks.* orders/create, cancelled, fulfilled, app/uninstalled, GDPR
app/jobs/worker.ts background worker
extensions/datewise-widget theme app extension (cart block + app embed for drawers), en/fr
extensions/checkout-guard validation function (JS) + tests
extensions/order-reschedule customer account UI extension (Preact)
prisma/ schema + SQL migration
prototype/ the clickable demo (runs the real engine)
tests/e2e/ browser test: real services + Postgres, mock theme + Admin API
```
## Getting started (developer)
Prerequisites: Node 20+, PostgreSQL 14+, a Shopify Partner account, the Shopify CLI, and a development store.
```bash
npm install
cp .env.example .env # set DATABASE_URL and APP_ENCRYPTION_KEY
npx prisma generate
npx prisma migrate deploy
npm run config:link # links shopify.app.toml to your Partner app
npm run dev # shopify app dev: tunnel, install on dev store, live extensions
npm run worker # in a second terminal
```
Then, in the dev store:
1. **Locations & hours.** Locations are imported switched off; review hours and turn one on.
2. **Theme editor.** Add the *Delivery date & time* block to the cart page, and switch on the *Datewise* app embed (needed for cart drawers, waitlist links and the checkout-button guard).
3. **Settings → Checkout guard → Turn on.**
4. Set `APP_URL` in `extensions/order-reschedule/src/config.js` (customer self-reschedule needs **new customer accounts**).
## Deploying
* Web: `npm run build && npm run start` (or the Dockerfile). Worker: the same image with `npm run worker`. Run at least one worker, or holds only expire lazily and reminders won't send.
* Any Node host with Postgres works (Fly.io, Render, Railway, a VPS). Shared PHP-style hosting won't run it.
* `npm run deploy` publishes the extensions and app config.
### Permissions (each is used by a shipped feature)
| Scope | Why |
|---|---|
| `read_products`, `write_products`, `write_publications` | rules by collection/tag/type; create and publish the hidden fee product |
| `read_orders`, `write_orders` | order webhooks, reconciliation, tag orders and write the booking metafield |
| `read_customers` | verify that the customer rescheduling actually owns the order |
| `read_locations`, `read_inventory` | link Shopify locations; stock-aware location choice |
| `write_validations` | switch the checkout guard on and off |
Customer names, emails, phones and addresses are **protected customer data**. Request Level 2 access in the Partner Dashboard before review.
## What is enforced where (Basic plan and up)
| Rule | Cart widget | Checkout guard | Order webhook |
|---|---|---|---|
| Closed days, cutoffs, prep, freshness, product rules | ✅ hides/blocks | — | ✅ re-checked on reschedule |
| Time required | ✅ blocks button | ✅ blocks checkout | ✅ review queue |
| Date in the past | ✅ | ✅ | — |
| Capacity | ✅ hold | ❌ functions have no network access | ✅ re-reserve or review |
| Fee matches quote | ✅ | ✅ | ✅ flags mismatch |
Checkout-page pickers (Plus-only UI extension targets) and carrier-calculated rates are intentionally **not** in v1.
## Tests
```bash
npm test # engine + checkout guard (DB tests skip without TEST_DATABASE_URL)
TEST_DATABASE_URL=postgresql://… npm test # + Postgres: concurrency, idempotency, reschedule, webhooks
npm run e2e:server & npm run e2e # real browser drives the widget against the real services
```
Covered: cutoff boundaries (13:59 vs 14:00), overnight/weekend operating-hours prep, both DST transitions, closures vs special openings, method-specific exceptions, mixed carts and conflicting rules, freshness windows, peak cutoffs/capacity/waitlist, 25 concurrent shoppers for 3 places, duplicate and concurrent webhooks, expired holds at order time, atomic reschedules (including opposite-direction swaps), cancellation, stale-hold sweeps, postal patterns and ranges, radius zones, maps outages (never accept unverified addresses), split inventory, reminders, run-sheet ordering, CSV escaping, fee tampering, keyboard navigation.
## Not yet verified in a live store
This code builds, typechecks and passes its tests, but it hasn't been run inside a real Shopify store yet. Before launch, run the spec's acceptance list (`docs/`) on a development store, especially:
* theme compatibility of the drawer injection (selectors for Dawn and its family are built in; others may need the *Cart drawer selector* setting),
* the customer-account extension and checkout guard on real checkouts (Shop Pay, Apple Pay, Buy now),
* WhatsApp template approval and Twilio sender setup,
* App Store requirements: privacy policy, listing, screencast, Built for Shopify performance checks.

32
app/db.server.ts Normal file
View File

@ -0,0 +1,32 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
declare global {
// eslint-disable-next-line no-var
var prismaGlobal: PrismaClient | undefined;
}
/**
* Rust-free Prisma client over node-postgres (no native engine binary needed,
* which also makes shared/VPS hosting simpler).
*/
export function createPrisma(url = process.env.DATABASE_URL) {
if (!url) throw new Error("DATABASE_URL is not set");
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
}
function instance(): PrismaClient {
if (!global.prismaGlobal) global.prismaGlobal = createPrisma();
return global.prismaGlobal;
}
/** Lazily created so importing this module never needs DATABASE_URL (tests, build). */
const prisma = new Proxy({} as PrismaClient, {
get(_t, prop) {
const db = instance();
const v = Reflect.get(db, prop);
return typeof v === "function" ? v.bind(db) : v;
},
});
export default prisma;

View File

@ -0,0 +1,487 @@
import { describe, expect, it } from "vitest";
import {
MemoryReservationStore,
addOperatingMinutes,
buildRunSheet,
canCustomerReschedule,
capsFor,
computeAvailability,
evaluateRules,
localParts,
matchZone,
normalizePostal,
planReminders,
postalMatches,
quoteFee,
rankLocations,
toCsv,
validateSelection,
zonedToUtc,
type AvailabilityInput,
type Booking,
type Cart,
type LocationConfig,
type ProductRule,
} from "../index";
import { TZ, at, diwali, kitchener, marigold, pakku, rules, saree, settings, waterloo } from "./fixtures";
const base = (over: Partial<AvailabilityInput> = {}): AvailabilityInput => ({
location: kitchener(),
method: "pickup",
cart: { items: [pakku] },
rules,
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"), // Wednesday
...over,
});
const day = (r: ReturnType<typeof computeAvailability>, d: string) => r.dates.find((x) => x.date === d)!;
describe("time zones", () => {
it("round-trips local times", () => {
const ms = zonedToUtc("2026-10-14", "10:30", TZ);
expect(new Date(ms).toISOString()).toBe("2026-10-14T14:30:00.000Z");
expect(localParts(ms, TZ)).toMatchObject({ date: "2026-10-14", time: "10:30", weekday: 3 });
});
it("keeps local slot labels across the fall-back DST change", () => {
const r = computeAvailability(base({ method: "delivery", now: at("2026-10-30", "08:00") }));
const sat = day(r, "2026-10-31").slots[0];
const sun = day(r, "2026-11-01").slots[0];
expect(sat.start).toBe("10:00");
expect(sun.start).toBe("10:00");
expect(sat.startUtc).toBe("2026-10-31T14:00:00.000Z"); // EDT
expect(sun.startUtc).toBe("2026-11-01T15:00:00.000Z"); // EST
});
it("resolves the spring-forward gap forward", () => {
const ms = zonedToUtc("2027-03-14", "02:30", TZ);
expect(localParts(ms, TZ).time).toBe("03:30");
});
});
describe("weekly hours, closures and special openings", () => {
it("never offers closed weekdays", () => {
const r = computeAvailability(base());
const sunday = day(r, "2026-10-18");
expect(sunday.available).toBe(false);
expect(sunday.reasons[0].code).toBe("CLOSED_WEEKLY");
});
it("date exceptions override the weekly schedule", () => {
const loc = kitchener();
loc.exceptions = [
{ date: "2026-10-16", closed: true, note: "Staff training" },
{ date: "2026-10-18", hours: [{ start: "12:00", end: "15:00" }], methods: ["pickup"], note: "Special Sunday opening" },
];
const r = computeAvailability(base({ location: loc }));
expect(day(r, "2026-10-16").reasons[0]).toMatchObject({ code: "CLOSED_EXCEPTION", message: "Staff training" });
const sun = day(r, "2026-10-18");
expect(sun.available).toBe(true);
expect(sun.slots.map((s) => s.id)).toEqual(["12:00-13:00", "13:00-14:00", "14:00-15:00"]);
});
it("a method-specific special opening doesn't change other methods' slots", () => {
const loc = kitchener();
loc.exceptions = [{ date: "2026-10-18", hours: [{ start: "09:00", end: "15:00" }], methods: ["pickup"] }];
const r = computeAvailability(base({ location: loc, method: "delivery" }));
expect(day(r, "2026-10-18").slots.map((s) => s.id)).toEqual(["10:00-13:00", "13:00-16:00", "16:00-19:00"]);
});
it("respects the booking horizon", () => {
const r = computeAvailability(base());
expect(r.dates.length).toBe(30);
});
});
describe("cutoffs and preparation time", () => {
it("blocks same-day exactly at the cutoff, allows one minute before", () => {
const before = computeAvailability(base({ now: at("2026-10-14", "13:59") }));
const atCut = computeAvailability(base({ now: at("2026-10-14", "14:00") }));
expect(day(before, "2026-10-14").available).toBe(true);
expect(day(atCut, "2026-10-14").reasons[0].code).toBe("PAST_CUTOFF");
// a later slot with spare capacity does not override the cutoff
expect(day(atCut, "2026-10-14").slots.length).toBe(0);
});
it("prep time removes slots that start too soon", () => {
const r = computeAvailability(base({ now: at("2026-10-14", "10:30") })); // 60 min prep → ready 11:30
const today = day(r, "2026-10-14");
expect(today.slots.find((s) => s.id === "11:00-12:00")!.reasons[0].code).toBe("PREP_TIME");
expect(today.slots.find((s) => s.id === "12:00-13:00")!.available).toBe(true);
expect(today.slots.find((s) => s.id === "10:00-11:00")!.reasons[0].code).toBe("SLOT_IN_PAST");
});
it("operating-hours prep skips closed time overnight and Sunday", () => {
const loc = kitchener();
// Saturday 17:00 + 3 operating hours: 1h Sat, closed Sun, 2h Mon → Mon 12:00
const ready = addOperatingMinutes(at("2026-10-17", "17:00"), 180, loc, "pickup");
expect(localParts(ready, TZ)).toMatchObject({ date: "2026-10-19", time: "12:00" });
});
it("date-only shipping requires readiness before closing", () => {
const loc = kitchener();
loc.methods.shipping!.prepMinutes = 120;
const r = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:00") }));
expect(r.dateOnly).toBe(true);
expect(day(r, "2026-10-14").available).toBe(true); // ready 15:00 < close 18:00
const late = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:59"), cart: { items: [pakku] } }));
expect(day(late, "2026-10-14").available).toBe(true);
loc.methods.shipping!.prepMinutes = 300;
const tooMuch = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:30") }));
expect(day(tooMuch, "2026-10-14").reasons[0].code).toBe("PREP_TIME");
});
});
describe("product rules and mixed carts", () => {
it("prohibition wins over allowance", () => {
const extra: ProductRule = {
id: "allow_all",
name: "Garlands any method",
enabled: true,
priority: 0,
target: { type: "product", values: ["p_marigold"] },
action: { type: "allowOnlyMethods", methods: ["shipping", "pickup", "delivery"] },
};
const c = evaluateRules({ items: [marigold] }, [extra, ...rules]);
expect(c.allowedMethods.has("shipping")).toBe(false);
const r = computeAvailability(base({ method: "shipping", cart: { items: [marigold, pakku] }, rules: [extra, ...rules] }));
expect(r.blockers[0]).toMatchObject({ code: "METHOD_PROHIBITED_BY_PRODUCT", ruleId: "r_fresh_noship" });
});
it("mixed cart uses the longest prep and names the item that drives it", () => {
const c = evaluateRules({ items: [marigold, saree, pakku] }, rules, "longest");
expect(c.extraPrepMinutes).toBe(1440);
expect(c.prepDriver).toMatchObject({ itemTitle: "Silk Saree", ruleId: "r_saree_prep" });
const sum = evaluateRules({ items: [marigold, saree] }, rules, "sum");
expect(sum.extraPrepMinutes).toBe(1680);
});
it("restrictLocations intersects and explains", () => {
const r: ProductRule = {
id: "only_wat",
name: "Gold jewellery at Waterloo",
enabled: true,
priority: 1,
target: { type: "vendor", values: ["clicks to cart"] },
action: { type: "restrictLocations", locationIds: ["wat"] },
message: "Jewellery pickup is at our Waterloo store",
};
const res = computeAvailability(base({ rules: [r] }));
expect(res.blockers[0]).toMatchObject({ code: "LOCATION_PROHIBITED_BY_PRODUCT", message: "Jewellery pickup is at our Waterloo store" });
});
it("weekday blocks apply", () => {
const r: ProductRule = { id: "nofri", name: "No Friday", enabled: true, priority: 1, target: { type: "product", values: ["p_pakku"] }, action: { type: "blockWeekdays", weekdays: [5] } };
const res = computeAvailability(base({ rules: [r] }));
expect(day(res, "2026-10-16").reasons[0].ruleId).toBe("nofri");
});
it("disabled rules are ignored", () => {
const off = rules.map((r) => ({ ...r, enabled: false }));
expect(evaluateRules({ items: [marigold] }, off).allowedMethods.has("shipping")).toBe(true);
});
});
describe("perishable freshness windows", () => {
const fresh: ProductRule = {
id: "fresh_arrival",
name: "Marigold air shipment",
enabled: true,
priority: 1,
target: { type: "product", values: ["p_marigold"] },
action: { type: "freshness", arrivals: [{ date: "2026-10-15" }, { date: "2026-10-29" }], minDaysAfterArrival: 1, shelfLifeDays: 4 },
};
it("only offers dates inside a freshness window and points to the next batch", () => {
const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [fresh] }));
expect(day(r, "2026-10-15").reasons[0]).toMatchObject({ code: "NOT_FRESH_YET" });
expect(day(r, "2026-10-15").reasons[0].message).toContain("2026-10-16");
expect(day(r, "2026-10-16").available).toBe(true);
expect(day(r, "2026-10-19").available).toBe(true);
expect(day(r, "2026-10-20").reasons[0].message).toContain("2026-10-30");
expect(day(r, "2026-10-30").available).toBe(true);
});
it("location-specific arrivals", () => {
const f: ProductRule = { ...fresh, action: { ...(fresh.action as any), arrivals: [{ date: "2026-10-15", locationIds: ["wat"] }] } };
const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [f] }));
expect(day(r, "2026-10-16").reasons[0].code).toBe("NO_STOCK_ARRIVAL");
});
});
describe("peak days (festival rush control)", () => {
const now = at("2026-11-05", "09:00"); // Thursday
it("applies peak capacity, surcharge and banner", () => {
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now }));
const d = day(r, "2026-11-08");
expect(d.peak?.name).toBe("Diwali");
expect(d.surcharge).toBe(500);
expect(d.remaining).toBe(30);
});
it("closes early at the peak cutoff", () => {
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now: at("2026-11-06", "18:00") }));
expect(day(r, "2026-11-08").reasons[0].code).toBe("PEAK_CUTOFF");
// a normal day right after is unaffected
expect(day(r, "2026-11-10").available).toBe(true);
});
it("offers a waitlist when a peak slot is full", () => {
const usage = { "kit|delivery|2026-11-08": { day: 30, slots: { "10:00-13:00": 10 } } };
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now, usage }));
const d = day(r, "2026-11-08");
expect(d.available).toBe(false);
expect(d.waitlist).toBe(true);
expect(d.reasons[0].code).toBe("DAY_FULL");
});
});
describe("capacity, holds and bookings", () => {
const sel = { locationId: "kit", method: "delivery" as const, date: "2026-10-15", slot: "10:00-13:00" };
const caps = { dayCap: 12, slotCap: 1 };
const now = at("2026-10-14", "09:00");
it("only one of two customers gets the last place", () => {
const s = new MemoryReservationStore();
const a = s.hold("tokA", sel, caps, now, 10);
const b = s.hold("tokB", sel, caps, now, 10);
expect(a.ok).toBe(true);
expect(b.ok).toBe(false);
});
it("expired holds return capacity", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
expect(s.hold("tokB", sel, caps, now + 11 * 60000, 10).ok).toBe(true);
});
it("moving a hold with the same token doesn't double count", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
expect(s.hold("tokA", sel, caps, now, 10).ok).toBe(true);
});
it("duplicate order webhooks don't duplicate bookings", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
const one = s.confirm("order1", "tokA", null, caps, now + 60000, { timezone: TZ });
const two = s.confirm("order1", "tokA", null, caps, now + 120000, { timezone: TZ });
expect(one.ok && !one.duplicate).toBe(true);
expect(two.ok && two.duplicate).toBe(true);
expect(s.bookings.size).toBe(1);
});
it("late order with expired hold is re-reserved if space, flagged if full", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
const late = now + 20 * 60000;
const ok = s.confirm("o1", "tokA", null, caps, late, { timezone: TZ });
expect(ok.ok && ok.reReserved).toBe(true);
s.hold("tokB", sel, { dayCap: 12, slotCap: 2 }, late, 10); // someone else takes 2nd place
s.hold("tokC", { ...sel, slot: "13:00-16:00" }, caps, late, 10);
const s2 = new MemoryReservationStore();
s2.hold("x", sel, caps, now, 10);
s2.hold("y", sel, caps, now + 11 * 60000, 10); // y takes the place after x expired
const flagged = s2.confirm("o2", "x", null, caps, now + 12 * 60000, { timezone: TZ });
expect(flagged.ok).toBe(false);
expect(flagged.booking.status).toBe("needs_review");
});
it("orders without scheduling data land in the exception queue", () => {
const s = new MemoryReservationStore();
const r = s.confirm("o9", null, null, caps, now, { timezone: TZ });
expect(r.ok).toBe(false);
expect(r.booking.status).toBe("needs_review");
});
it("reschedule is atomic and failure preserves the original", () => {
const s = new MemoryReservationStore();
s.hold("a", sel, caps, now, 10);
const { booking } = s.confirm("o1", "a", null, caps, now, { timezone: TZ }) as any;
s.hold("b", { ...sel, slot: "13:00-16:00" }, caps, now, 10); // other slot held by someone
const fail = s.reschedule(booking.id, { ...sel, slot: "13:00-16:00" }, caps, now, "merchant:asha");
expect(fail.ok).toBe(false);
expect(s.bookings.get(booking.id)!.slotStart).toBe("10:00");
const ok = s.reschedule(booking.id, { ...sel, slot: "16:00-19:00" }, caps, now, "merchant:asha");
expect(ok.ok).toBe(true);
expect(s.usage(now)["kit|delivery|2026-10-15"].slots["10:00-13:00"]).toBeUndefined();
expect(s.audit.at(-1)).toMatchObject({ actor: "merchant:asha", action: "rescheduled" });
});
it("capsFor picks peak overrides", () => {
expect(capsFor(kitchener(), "delivery", "2026-11-08", "10:00-13:00", [diwali])).toEqual({ dayCap: 30, slotCap: 10 });
expect(capsFor(kitchener(), "delivery", "2026-11-09", "10:00-13:00", [diwali])).toEqual({ dayCap: 12, slotCap: 5 });
});
it("validateSelection rejects a full slot using live usage", () => {
const s = new MemoryReservationStore();
for (let i = 0; i < 5; i++) s.hold(`t${i}`, sel, { dayCap: 12, slotCap: 5 }, now, 10);
const v = validateSelection(base({ method: "delivery", usage: s.usage(now) }), "2026-10-15", "10:00-13:00");
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reasons[0].code).toBe("SLOT_FULL");
});
it("waitlist is FIFO", () => {
const s = new MemoryReservationStore();
s.joinWaitlist(sel, { email: "a@x.com" }, now);
s.joinWaitlist(sel, { email: "b@x.com" }, now + 1);
expect(s.nextWaitlisted(sel)!.contact.email).toBe("a@x.com");
});
});
describe("delivery zones and fees", () => {
it("normalizes postal codes", () => {
expect(normalizePostal(" n2h-5z6 ")).toBe("N2H5Z6");
expect(postalMatches("n2h 5z6", "N2H*")).toBe(true);
expect(postalMatches("N2K 1A1", "N2J..N2M")).toBe(true);
expect(postalMatches("N2N 1A1", "N2J..N2M")).toBe(false);
expect(postalMatches("N2H5Z6", "N2H")).toBe(true);
expect(postalMatches("02134-1234", "02134*")).toBe(true);
});
it("matches postal first, then radius", () => {
const loc = kitchener();
expect(matchZone(loc, { postal: "N2G 4X1" }).zone?.id).toBe("z1");
const near = matchZone(loc, { postal: "N3C 1A1", lat: 43.42, lng: -80.40 }); // ~8 km
expect(near.zone?.id).toBe("z2");
expect(near.distanceKm!).toBeLessThan(15);
const far = matchZone(loc, { postal: "L5B 1A1", lat: 43.59, lng: -79.64 }); // Mississauga
expect(far.zone).toBeNull();
expect(far.reason?.code).toBe("OUTSIDE_DELIVERY_AREA");
});
it("never accepts unverified driving-distance addresses", () => {
const loc = kitchener();
loc.zones = [{ id: "d", name: "Drive 20km", drivingKm: 20, fee: 999 }];
const m = matchZone(loc, { postal: "L5B 1A1" });
expect(m.zone).toBeNull();
expect(m.unverified).toBe(true);
expect(matchZone(loc, { postal: "x", drivingKm: { kit: 12 } }).zone?.id).toBe("d");
});
it("keeps eligibility separate from price", () => {
const z = kitchener().zones[0];
expect(quoteFee(z, 5000).fee).toBe(799);
expect(quoteFee(z, 8000)).toMatchObject({ fee: 0, waived: true });
expect(quoteFee(z, 2000).belowMinimum).toBe(true);
expect(quoteFee(z, 8000, [{ label: "Diwali", amount: 500 }]).fee).toBe(500);
});
});
describe("location ranking and inventory", () => {
const cart: Cart = { items: [pakku, { ...saree, quantity: 1 }] };
it("ranks eligible locations and explains the rest", () => {
const { ranked } = rankLocations({
locations: [waterloo(), kitchener()],
method: "pickup",
cart,
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
inventory: { kit: { v_pakku: 5, v_saree: 1 }, wat: { v_pakku: 5 } },
});
expect(ranked[0].location.id).toBe("kit");
expect(ranked[1].reasons[0].code).toBe("OUT_OF_STOCK_AT_LOCATION");
});
it("flags a split instead of silently assigning one location", () => {
const { ranked, splitRequired } = rankLocations({
locations: [waterloo(), kitchener()],
method: "pickup",
cart,
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
inventory: { kit: { v_pakku: 5 }, wat: { v_saree: 1 } },
});
expect(ranked.every((r) => !r.eligible)).toBe(true);
expect(splitRequired?.code).toBe("OUT_OF_STOCK_AT_LOCATION");
});
it("a location without inventory data (not linked) never blocks", () => {
const { ranked } = rankLocations({
locations: [kitchener()], method: "pickup", cart, rules: [], peakDays: [], usage: {}, settings,
now: at("2026-10-14", "09:00"), inventory: {},
});
expect(ranked[0].eligible).toBe(true);
});
it("delivery ranks by zone + minimum order", () => {
const { ranked } = rankLocations({
locations: [kitchener(), waterloo()],
method: "delivery",
cart: { items: [pakku] }, // $6.99 < $25 minimum at Kitchener
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
address: { postal: "N2J 2Z1" }, // in both
});
expect(ranked[0].location.id).toBe("wat");
expect(ranked[1].reasons[0].code).toBe("BELOW_MINIMUM_ORDER");
});
});
describe("customer self-reschedule and reminders", () => {
const b: Booking = {
id: "b1",
orderName: "#1042",
status: "confirmed",
method: "delivery",
locationId: "kit",
date: "2026-10-20",
slotStart: "13:00",
slotEnd: "16:00",
timezone: TZ,
customer: { name: "Priya", email: "p@example.com", phone: "+15195550100" },
rescheduleCount: 0,
};
it("allows changes until the deadline", () => {
expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "12:59")).ok).toBe(true);
expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "13:00")).ok).toBe(false);
expect(canCustomerReschedule({ ...b, rescheduleCount: 2 }, settings.reschedule, at("2026-10-15", "09:00")).ok).toBe(false);
});
it("plans reminders and renders templates", () => {
const r = planReminders(b, settings.reminders, at("2026-10-15", "09:00"));
expect(r).toHaveLength(2);
expect(r[0].body).toBe("Hi Priya, your delivery is 2026-10-20 13:00–16:00");
expect(localParts(r[0].sendAt, TZ)).toMatchObject({ date: "2026-10-19", time: "13:00" });
const noPhone = planReminders({ ...b, customer: { email: "p@example.com" } }, settings.reminders, at("2026-10-15", "09:00"));
expect(noPhone.map((x) => x.channel)).toEqual(["email"]);
});
});
describe("driver run sheet", () => {
const loc: LocationConfig = kitchener();
const mk = (id: string, slot: string, lat: number, lng: number): Booking => ({
id,
status: "confirmed",
method: "delivery",
locationId: "kit",
date: "2026-10-20",
slotStart: slot.split("-")[0],
slotEnd: slot.split("-")[1],
timezone: TZ,
address: { lat, lng, line1: id },
});
it("orders by window then nearest neighbour, with route links", () => {
const sheet = buildRunSheet(
[
mk("far-am", "10:00-13:00", 43.5, -80.55),
mk("near-am", "10:00-13:00", 43.466, -80.49),
mk("pm", "13:00-16:00", 43.45, -80.48),
{ ...mk("nogeo", "10:00-13:00", 0, 0), address: { line1: "?" } },
],
loc,
"2026-10-20",
);
expect(sheet.stops.map((s) => s.booking.id)).toEqual(["near-am", "far-am", "pm"]);
expect(sheet.unlocated.map((b) => b.id)).toEqual(["nogeo"]);
expect(sheet.mapsUrls[0]).toContain("google.com/maps/dir");
});
it("exports Excel-safe CSV", () => {
const csv = toCsv([{ a: 'He said "hi"', b: "x,y" }]);
expect(csv.startsWith("")).toBe(true);
expect(csv).toContain('"He said ""hi"""');
expect(csv).toContain('"x,y"');
});
});

View File

@ -0,0 +1,166 @@
import type { Weekday, TimeRange, LocationConfig, PeakDay, ProductRule, ShopSettings, CartItem } from "../types";
import { zonedToUtc } from "../time";
export const TZ = "America/Toronto";
/** Local Toronto wall time → epoch ms */
export const at = (date: string, time: string) => zonedToUtc(date, time, TZ);
const h = () => [{ start: "10:00", end: "18:00" }];
const monSat: Partial<Record<Weekday, TimeRange[]>> = { 1: h(), 2: h(), 3: h(), 4: h(), 5: h(), 6: h() };
const deliverySlots = [
{ start: "10:00", end: "13:00" },
{ start: "13:00", end: "16:00" },
{ start: "16:00", end: "19:00", surcharge: 300 },
];
export function kitchener(): LocationConfig {
return {
id: "kit",
name: "Kitchener",
address: "900 Guelph St, Kitchener, ON N2H 5Z6",
timezone: TZ,
lat: 43.4643,
lng: -80.4844,
enabled: true,
priority: 1,
exceptions: [],
zones: [
{ id: "z1", name: "Kitchener-Waterloo", postalPatterns: ["N2H*", "N2G*", "N2J..N2M"], fee: 799, freeOver: 7500, minOrder: 2500 },
{ id: "z2", name: "15 km radius", radiusKm: 15, fee: 1299 },
],
pickupInstructions: "Unit 7 — ring the bell",
methods: {
pickup: {
enabled: true,
weeklyHours: { ...monSat },
slotMode: "generated",
slotMinutes: 60,
prepMinutes: 60,
prepMode: "elapsed",
sameDayCutoff: "14:00",
horizonDays: 30,
slotCapacity: 4,
},
delivery: {
enabled: true,
weeklyHours: { 0: [{ start: "10:00", end: "19:00" }], 2: [{ start: "10:00", end: "19:00" }], 3: [{ start: "10:00", end: "19:00" }], 4: [{ start: "10:00", end: "19:00" }], 5: [{ start: "10:00", end: "19:00" }], 6: [{ start: "10:00", end: "19:00" }] },
slotMode: "explicit",
slots: { 0: deliverySlots, 2: deliverySlots, 3: deliverySlots, 4: deliverySlots, 5: deliverySlots, 6: deliverySlots },
prepMinutes: 120,
prepMode: "elapsed",
sameDayCutoff: "12:00",
horizonDays: 21,
slotCapacity: 5,
dailyCapacity: 12,
},
shipping: {
enabled: true,
weeklyHours: { ...monSat },
slotMode: "dateOnly",
prepMinutes: 0,
prepMode: "operating",
sameDayCutoff: "14:00",
horizonDays: 14,
},
},
};
}
export function waterloo(): LocationConfig {
const l = kitchener();
return {
...l,
id: "wat",
name: "Waterloo",
priority: 2,
lat: 43.4723,
lng: -80.5449,
zones: [{ id: "zw", name: "Waterloo", postalPatterns: ["N2L*", "N2J*"], fee: 599 }],
};
}
export const settings: ShopSettings = {
mixedPrepPolicy: "longest",
holdMinutes: 10,
weekStartsOn: 0,
reschedule: { enabled: true, minHoursBefore: 24, maxChanges: 2, allowMethodChange: false },
reminders: [
{ id: "r1", channel: "sms", minutesBefore: 24 * 60, template: "Hi {{name}}, your {{method}} is {{date}} {{time}}", enabled: true },
{ id: "r2", channel: "email", minutesBefore: 120, template: "Reminder {{order}}", enabled: true },
],
};
export const marigold: CartItem = {
productId: "p_marigold",
variantId: "v_marigold",
title: "Fresh Marigold Garland",
quantity: 2,
price: 1999,
collectionIds: ["c_flowers"],
vendor: "C2C Agricorp",
productType: "Fresh Flowers",
tags: ["perishable"],
};
export const pakku: CartItem = {
productId: "p_pakku",
variantId: "v_pakku",
title: "பாக்கு (Pākkŭ) 200g",
quantity: 1,
price: 699,
collectionIds: ["c_puja"],
vendor: "Clicks to Cart",
productType: "Puja",
};
export const saree: CartItem = {
productId: "p_saree",
variantId: "v_saree",
title: "Silk Saree",
quantity: 1,
price: 15999,
collectionIds: ["c_apparel"],
productType: "Apparel",
};
export const rules: ProductRule[] = [
{
id: "r_fresh_noship",
name: "Fresh flowers can't ship",
enabled: true,
priority: 1,
target: { type: "productType", values: ["Fresh Flowers"] },
action: { type: "prohibitMethods", methods: ["shipping"] },
message: "Fresh flowers are pickup or local delivery only",
},
{
id: "r_fresh_prep",
name: "Garland stringing time",
enabled: true,
priority: 2,
target: { type: "tag", values: ["perishable"] },
action: { type: "addPrep", minutes: 240 },
},
{
id: "r_saree_prep",
name: "Saree fall & pico",
enabled: true,
priority: 3,
target: { type: "collection", values: ["c_apparel"] },
action: { type: "addPrep", minutes: 60 * 24 },
},
];
export const diwali: PeakDay = {
id: "pk_diwali",
name: "Diwali",
dates: ["2026-11-08"],
methods: ["delivery", "pickup"],
dailyCapacity: 30,
slotCapacity: 10,
extraPrepMinutes: 60,
cutoff: { daysBefore: 2, time: "18:00" },
surcharge: 500,
waitlist: true,
message: "Diwali orders close Friday 6 PM",
};

312
app/engine/availability.ts Normal file
View File

@ -0,0 +1,312 @@
/**
* Availability: which dates and slots a specific cart can book at a specific
* location with a specific method, right now.
*
* Rule precedence (each step only runs if the previous passed):
* 1. Location / method eligibility (incl. product method & location rules)
* 2. Product date restrictions (weekday/date blocks, windows, freshness)
* 3. Holidays / closures / special openings
* 4. Cutoffs (same-day, next-day, peak-day early cutoff)
* 5. Preparation time (base + product rules + peak extra) vs slot start
* 6. Daily and slot capacity (bookings + active holds), with waitlist on peak days
*/
import type {
AvailabilityResult,
Cart,
DateResult,
LocationConfig,
Method,
MethodConfig,
PeakDay,
ProductRule,
Reason,
ShopSettings,
SlotResult,
SlotTemplate,
TimeRange,
UsageSnapshot,
} from "./types";
import { type CartConstraints, checkFreshness, evaluateRules } from "./rules";
import { addDays, fromMinutes, iso, localParts, toMinutes, weekdayOf, zonedToUtc } from "./time";
export const usageKey = (locationId: string, method: Method, date: string) => `${locationId}|${method}|${date}`;
export const slotId = (start: string, end: string) => `${start}-${end}`;
export interface AvailabilityInput {
location: LocationConfig;
method: Method;
cart: Cart;
rules: ProductRule[];
peakDays: PeakDay[];
usage: UsageSnapshot;
settings: Pick<ShopSettings, "mixedPrepPolicy">;
now: number;
constraints?: CartConstraints;
/** Limit computed days (e.g. calendar month paging). Defaults to the method horizon. */
maxDays?: number;
}
/** Hours for a date after applying closures and special openings. */
export function hoursFor(
loc: LocationConfig,
method: Method,
date: string,
): { hours: TimeRange[]; exception?: Reason; special?: boolean } {
const cfg = loc.methods[method];
const ex = loc.exceptions.find((e) => e.date === date && (!e.methods?.length || e.methods.includes(method)));
if (ex?.closed)
return { hours: [], exception: { code: "CLOSED_EXCEPTION", message: ex.note || "Closed on this date" } };
if (ex?.hours?.length) return { hours: ex.hours, special: true };
return { hours: cfg?.weeklyHours[weekdayOf(date)] ?? [] };
}
export function peakFor(peaks: PeakDay[], locationId: string, method: Method, date: string): PeakDay | null {
return (
peaks.find(
(p) =>
p.dates.includes(date) &&
(!p.locationIds?.length || p.locationIds.includes(locationId)) &&
(!p.methods?.length || p.methods.includes(method)),
) ?? null
);
}
function slotTemplates(cfg: MethodConfig, date: string, hours: TimeRange[], usingException: boolean): SlotTemplate[] {
if (!hours.length) return [];
if (cfg.slotMode === "dateOnly") return [{ start: hours[0].start, end: hours[hours.length - 1].end }];
if (cfg.slotMode === "explicit" && !usingException) {
const list = cfg.slots?.[weekdayOf(date)] ?? [];
// Only keep explicit slots that fall within opening hours.
return list.filter((s) =>
hours.some((h) => toMinutes(s.start) >= toMinutes(h.start) && toMinutes(s.end) <= toMinutes(h.end)),
);
}
const step = cfg.slotMinutes || 60;
const out: SlotTemplate[] = [];
for (const h of hours) {
for (let t = toMinutes(h.start); t + step <= toMinutes(h.end); t += step)
out.push({ start: fromMinutes(t), end: fromMinutes(t + step) });
}
return out;
}
/**
* Add preparation minutes counting only operating hours of this method at this location.
*/
export function addOperatingMinutes(
startMs: number,
minutes: number,
loc: LocationConfig,
method: Method,
): number {
if (minutes <= 0) return startMs;
let remaining = minutes * 60000;
let date = localParts(startMs, loc.timezone).date;
for (let i = 0; i < 400; i++, date = addDays(date, 1)) {
for (const h of hoursFor(loc, method, date).hours) {
const rs = zonedToUtc(date, h.start, loc.timezone);
const re = zonedToUtc(date, h.end, loc.timezone);
if (re <= startMs) continue;
const from = Math.max(rs, startMs);
const avail = re - from;
if (remaining <= avail) return from + remaining;
remaining -= avail;
}
}
return Number.POSITIVE_INFINITY;
}
export function computeAvailability(input: AvailabilityInput): AvailabilityResult {
const { location: loc, method, now, usage } = input;
const tz = loc.timezone;
const cfg = loc.methods[method];
const constraints =
input.constraints ?? evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy);
const blockers: Reason[] = [];
if (!loc.enabled) blockers.push({ code: "LOCATION_DISABLED", message: `${loc.name} isn't taking bookings right now` });
if (!cfg?.enabled) blockers.push({ code: "METHOD_DISABLED", message: `${loc.name} doesn't offer ${method}` });
if (!constraints.allowedMethods.has(method)) blockers.push(...(constraints.methodReasons.get(method) ?? []));
if (constraints.allowedLocations && !constraints.allowedLocations.has(loc.id))
blockers.push(...constraints.locationReasons);
const basePrep = (cfg?.prepMinutes ?? 0) + constraints.extraPrepMinutes;
const readyCache = new Map<number, number>();
const readyAt = (prep: number) => {
if (!readyCache.has(prep))
readyCache.set(
prep,
cfg?.prepMode === "operating" ? addOperatingMinutes(now, prep, loc, method) : now + prep * 60000,
);
return readyCache.get(prep)!;
};
const result: AvailabilityResult = {
locationId: loc.id,
method,
timezone: tz,
dateOnly: cfg?.slotMode === "dateOnly",
prepMinutes: basePrep,
earliestReadyUtc: iso(readyAt(basePrep)),
blockers,
dates: [],
};
if (blockers.length || !cfg) return result;
const nowLocal = localParts(now, tz);
const days = Math.min(cfg.horizonDays, input.maxDays ?? cfg.horizonDays);
for (let d = 0; d < days; d++) {
const date = addDays(nowLocal.date, d);
const weekday = weekdayOf(date);
const peak = peakFor(input.peakDays, loc.id, method, date);
const dr: DateResult = {
date,
weekday,
available: false,
remaining: null,
peak: peak ? { id: peak.id, name: peak.name, message: peak.message } : null,
surcharge: peak?.surcharge ?? 0,
waitlist: false,
reasons: [],
slots: [],
};
result.dates.push(dr);
// 2. product date restrictions
const wd = constraints.blockedWeekdays.get(weekday);
if (wd) dr.reasons.push(wd);
const bd = constraints.blockedDates.get(date);
if (bd) dr.reasons.push(bd);
for (const w of constraints.windows)
if ((w.from && date < w.from) || (w.to && date > w.to)) dr.reasons.push(w.reason);
const fresh = checkFreshness(constraints.freshness, date, loc.id);
if (fresh) dr.reasons.push(fresh);
if (dr.reasons.length) continue;
// 3. closures
const { hours, exception, special } = hoursFor(loc, method, date);
if (exception) {
dr.reasons.push(exception);
continue;
}
if (!hours.length) {
dr.reasons.push({ code: "CLOSED_WEEKLY", message: "Closed on this day" });
continue;
}
// 4. cutoffs
if (d === 0 && cfg.sameDayCutoff && nowLocal.minutes >= toMinutes(cfg.sameDayCutoff)) {
dr.reasons.push({ code: "PAST_CUTOFF", message: `Same-day orders close at ${cfg.sameDayCutoff}` });
continue;
}
if (d === 1 && cfg.nextDayCutoff && nowLocal.minutes >= toMinutes(cfg.nextDayCutoff)) {
dr.reasons.push({ code: "PAST_CUTOFF", message: `Next-day orders close at ${cfg.nextDayCutoff}` });
continue;
}
if (peak?.cutoff) {
const deadline = zonedToUtc(addDays(date, -peak.cutoff.daysBefore), peak.cutoff.time, tz);
if (now >= deadline) {
dr.reasons.push({
code: "PEAK_CUTOFF",
message: `${peak.name} orders closed on ${addDays(date, -peak.cutoff.daysBefore)} at ${peak.cutoff.time}`,
});
continue;
}
}
// 5 + 6. slots
const ready = readyAt(basePrep + (peak?.extraPrepMinutes ?? 0));
const key = usageKey(loc.id, method, date);
const used = usage[key] ?? { day: 0, slots: {} };
const dayCap = peak?.dailyCapacity ?? cfg.dailyCapacity ?? null;
const dayLeft = dayCap == null ? null : Math.max(0, dayCap - used.day);
dr.remaining = dayLeft;
const templates = slotTemplates(cfg, date, hours, !!special);
if (!templates.length) {
dr.reasons.push({ code: "CLOSED_WEEKLY", message: "No time slots on this day" });
continue;
}
for (const t of templates) {
const startUtc = zonedToUtc(date, t.start, tz);
const endUtc = zonedToUtc(date, t.end, tz);
const id = slotId(t.start, t.end);
const sr: SlotResult = {
id,
start: t.start,
end: t.end,
startUtc: iso(startUtc),
endUtc: iso(endUtc),
available: false,
remaining: null,
surcharge: t.surcharge ?? 0,
waitlist: false,
reasons: [],
};
dr.slots.push(sr);
if (result.dateOnly) {
// Dispatch date: the order must be ready before the location closes.
if (endUtc <= now) sr.reasons.push({ code: "SLOT_IN_PAST", message: "Closed for today" });
else if (ready > endUtc) sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" });
} else if (startUtc <= now) {
sr.reasons.push({ code: "SLOT_IN_PAST", message: "This time has passed" });
} else if (ready > startUtc) {
sr.reasons.push({ code: "PREP_TIME", message: "Not enough preparation time" });
}
if (sr.reasons.length) continue;
const slotCap = peak?.slotCapacity ?? t.capacity ?? cfg.slotCapacity ?? null;
const slotUsed = used.slots[id] ?? 0;
const slotLeft = slotCap == null ? null : Math.max(0, slotCap - slotUsed);
sr.remaining = slotLeft == null ? dayLeft : dayLeft == null ? slotLeft : Math.min(slotLeft, dayLeft);
if (dayLeft === 0) sr.reasons.push({ code: "DAY_FULL", message: "Fully booked for this day" });
else if (slotLeft === 0) sr.reasons.push({ code: "SLOT_FULL", message: "This time slot is full" });
if (sr.reasons.length) {
sr.waitlist = !!peak?.waitlist;
continue;
}
sr.available = true;
}
dr.available = dr.slots.some((s) => s.available);
dr.waitlist = !dr.available && dr.slots.some((s) => s.waitlist);
if (!dr.available) {
const seen = new Set<string>();
for (const s of dr.slots)
for (const r of s.reasons)
if (!seen.has(r.code)) {
seen.add(r.code);
dr.reasons.push(r);
}
}
}
return result;
}
export function firstAvailable(r: AvailabilityResult): { date: string; slot: SlotResult } | null {
for (const d of r.dates) {
const s = d.slots.find((x) => x.available);
if (s) return { date: d.date, slot: s };
}
return null;
}
/**
* Re-validate a specific selection (used before hold, at order creation and on reschedule).
*/
export function validateSelection(
input: AvailabilityInput,
date: string,
slot: string,
): { ok: true; slot: SlotResult; date: DateResult } | { ok: false; reasons: Reason[] } {
const r = computeAvailability(input);
if (r.blockers.length) return { ok: false, reasons: r.blockers };
const dr = r.dates.find((d) => d.date === date);
if (!dr) return { ok: false, reasons: [{ code: "BEYOND_HORIZON", message: "This date can't be booked" }] };
const sr = dr.slots.find((s) => s.id === slot);
if (!sr) return { ok: false, reasons: dr.reasons.length ? dr.reasons : [{ code: "CLOSED_WEEKLY", message: "Slot not offered" }] };
if (!sr.available) return { ok: false, reasons: sr.reasons.length ? sr.reasons : dr.reasons };
return { ok: true, slot: sr, date: dr };
}

255
app/engine/capacity.ts Normal file
View File

@ -0,0 +1,255 @@
/**
* Capacity, holds and bookings.
*
* `MemoryReservationStore` is the reference implementation of the reservation
* semantics. The production store (app/services/reservations.server.ts) implements
* the same contract on Postgres with row-level locking; both are covered by the
* same behavioural tests.
*
* Capacity unit (v1): orders, not items.
*/
import type { Booking, LocationConfig, Method, PeakDay, UsageSnapshot } from "./types";
import { peakFor, usageKey } from "./availability";
export interface Caps {
dayCap: number | null;
slotCap: number | null;
}
export function capsFor(
loc: LocationConfig,
method: Method,
date: string,
slot: string,
peaks: PeakDay[],
): Caps {
const cfg = loc.methods[method];
const peak = peakFor(peaks, loc.id, method, date);
let tplCap: number | null | undefined;
if (cfg?.slotMode === "explicit") {
const [start, end] = slot.split("-");
for (const list of Object.values(cfg.slots ?? {}))
for (const s of list ?? []) if (s.start === start && s.end === end && s.capacity != null) tplCap = s.capacity;
}
return {
dayCap: peak?.dailyCapacity ?? cfg?.dailyCapacity ?? null,
slotCap: peak?.slotCapacity ?? tplCap ?? cfg?.slotCapacity ?? null,
};
}
export interface Selection {
locationId: string;
method: Method;
date: string;
slot: string; // "HH:MM-HH:MM"
}
export interface Hold extends Selection {
token: string;
expiresAt: number;
}
export type HoldResult =
| { ok: true; hold: Hold }
| { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string };
export type ConfirmResult =
| { ok: true; booking: Booking; duplicate: boolean; reReserved?: boolean }
| { ok: false; booking: Booking; code: "NEEDS_REVIEW"; message: string };
export interface WaitlistEntry extends Selection {
id: string;
contact: { email?: string; phone?: string; name?: string };
createdAt: number;
notifiedAt?: number;
}
export class MemoryReservationStore {
holds = new Map<string, Hold>();
bookings = new Map<string, Booking>();
waitlist: WaitlistEntry[] = [];
audit: { at: number; actor: string; bookingId: string; action: string; before?: unknown; after?: unknown }[] = [];
private seq = 0;
private counts(sel: Selection, now: number, excludeToken?: string, excludeBooking?: string) {
let day = 0;
let slot = 0;
const same = (s: Selection) => s.locationId === sel.locationId && s.method === sel.method && s.date === sel.date;
for (const b of this.bookings.values()) {
// Only confirmed bookings consume capacity; flagged ones wait for a merchant decision.
if (b.id === excludeBooking || b.status !== "confirmed") continue;
const s = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
if (same(s)) {
day++;
if (s.slot === sel.slot) slot++;
}
}
for (const h of this.holds.values()) {
if (h.token === excludeToken || h.expiresAt <= now) continue;
if (same(h)) {
day++;
if (h.slot === sel.slot) slot++;
}
}
return { day, slot };
}
usage(now: number): UsageSnapshot {
const out: UsageSnapshot = {};
const add = (s: Selection) => {
const k = usageKey(s.locationId, s.method, s.date);
out[k] ??= { day: 0, slots: {} };
out[k].day++;
out[k].slots[s.slot] = (out[k].slots[s.slot] ?? 0) + 1;
};
for (const b of this.bookings.values())
if (b.status === "confirmed")
add({ locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` });
for (const h of this.holds.values()) if (h.expiresAt > now) add(h);
return out;
}
private fits(sel: Selection, caps: Caps, now: number, excludeToken?: string, excludeBooking?: string) {
const c = this.counts(sel, now, excludeToken, excludeBooking);
if (caps.dayCap != null && c.day >= caps.dayCap) return "DAY_FULL" as const;
if (caps.slotCap != null && c.slot >= caps.slotCap) return "SLOT_FULL" as const;
return null;
}
/** Atomic check-and-reserve. Re-holding with the same token moves the hold. */
hold(token: string, sel: Selection, caps: Caps, now: number, holdMinutes: number): HoldResult {
const full = this.fits(sel, caps, now, token);
if (full) return { ok: false, code: full, message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken" };
const hold: Hold = { ...sel, token, expiresAt: now + holdMinutes * 60000 };
this.holds.set(token, hold);
return { ok: true, hold };
}
release(token: string) {
this.holds.delete(token);
}
sweep(now: number): number {
let n = 0;
for (const [t, h] of this.holds) if (h.expiresAt <= now) (this.holds.delete(t), n++);
return n;
}
/**
* Confirm a hold against an order. Idempotent per orderId (duplicate webhooks are safe).
* Expired hold: re-reserve if capacity still allows, otherwise flag for review —
* never silently overbook.
*/
confirm(
orderId: string,
token: string | null,
fallbackSel: Selection | null,
caps: Caps,
now: number,
details: Partial<Booking> & { timezone: string },
): ConfirmResult {
const existing = [...this.bookings.values()].find((b) => b.orderId === orderId);
if (existing) return { ok: true, booking: existing, duplicate: true };
const hold = token ? this.holds.get(token) : undefined;
const sel = hold ?? fallbackSel;
const id = `bk_${++this.seq}`;
const base = (status: Booking["status"]): Booking => ({
...details,
id,
orderId,
status,
method: sel!.method,
locationId: sel!.locationId,
date: sel!.date,
slotStart: sel!.slot.split("-")[0],
slotEnd: sel!.slot.split("-")[1],
rescheduleCount: 0,
});
if (!sel) {
const b: Booking = { ...details, id, orderId, status: "needs_review", method: "pickup", locationId: "", date: "" };
this.bookings.set(id, b);
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Order has no scheduling data" };
}
const live = hold && hold.expiresAt > now;
if (!live) {
const full = this.fits(sel, caps, now, token ?? undefined);
if (full) {
const b = base("needs_review");
this.bookings.set(id, b);
if (token) this.holds.delete(token);
this.log(now, "system", id, "flagged", undefined, { reason: full });
return { ok: false, booking: b, code: "NEEDS_REVIEW", message: "Hold expired and the slot filled up" };
}
}
const b = base("confirmed");
this.bookings.set(id, b);
if (token) this.holds.delete(token);
this.log(now, "system", id, "confirmed");
return { ok: true, booking: b, duplicate: false, reReserved: !live };
}
/** Atomic move: validates the new slot, then releases old + takes new together. */
reschedule(
bookingId: string,
sel: Selection,
caps: Caps,
now: number,
actor: string,
): { ok: true; booking: Booking; freed: Selection } | { ok: false; message: string } {
const b = this.bookings.get(bookingId);
if (!b || b.status !== "confirmed") return { ok: false, message: "Booking can't be rescheduled" };
const full = this.fits(sel, caps, now, undefined, bookingId);
if (full) return { ok: false, message: full === "DAY_FULL" ? "That day is fully booked" : "That slot is full" };
const before = { ...b };
const freed: Selection = { locationId: b.locationId, method: b.method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
const [s, e] = sel.slot.split("-");
Object.assign(b, {
locationId: sel.locationId,
method: sel.method,
date: sel.date,
slotStart: s,
slotEnd: e,
rescheduleCount: (b.rescheduleCount ?? 0) + 1,
});
this.log(now, actor, bookingId, "rescheduled", pick(before), pick(b));
return { ok: true, booking: b, freed };
}
cancel(orderId: string, now: number, releaseCapacity = true): Booking | null {
const b = [...this.bookings.values()].find((x) => x.orderId === orderId);
if (!b || b.status === "cancelled") return b ?? null;
const before = { ...b };
b.status = releaseCapacity ? "cancelled" : b.status;
this.log(now, "system", b.id, "cancelled", pick(before), pick(b));
return b;
}
joinWaitlist(sel: Selection, contact: WaitlistEntry["contact"], now: number): WaitlistEntry {
const e: WaitlistEntry = { ...sel, id: `wl_${++this.seq}`, contact, createdAt: now };
this.waitlist.push(e);
return e;
}
/** Who to notify when capacity frees up for a selection (FIFO, one at a time). */
nextWaitlisted(sel: Selection): WaitlistEntry | null {
return (
this.waitlist.find(
(w) =>
!w.notifiedAt &&
w.locationId === sel.locationId &&
w.method === sel.method &&
w.date === sel.date &&
(w.slot === sel.slot || w.slot === "*"),
) ?? null
);
}
private log(at: number, actor: string, bookingId: string, action: string, before?: unknown, after?: unknown) {
this.audit.push({ at, actor, bookingId, action, before, after });
}
}
function pick(b: Booking) {
return { locationId: b.locationId, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, status: b.status };
}

66
app/engine/customer.ts Normal file
View File

@ -0,0 +1,66 @@
/**
* Customer-facing post-purchase features: self-reschedule eligibility and reminders.
*/
import type { Booking, ReminderPolicy, ShopSettings } from "./types";
import { zonedToUtc } from "./time";
export function slotStartMs(b: Booking): number {
return zonedToUtc(b.date, b.slotStart ?? "00:00", b.timezone);
}
export function canCustomerReschedule(
b: Booking,
policy: ShopSettings["reschedule"],
now: number,
): { ok: true; deadline: number } | { ok: false; reason: string } {
if (!policy.enabled) return { ok: false, reason: "Rescheduling isn't available for this store. Please contact us." };
if (b.status !== "confirmed") return { ok: false, reason: "This booking can't be changed." };
if ((b.rescheduleCount ?? 0) >= policy.maxChanges)
return { ok: false, reason: `You've already changed this booking ${policy.maxChanges} time(s). Please contact us.` };
const deadline = slotStartMs(b) - policy.minHoursBefore * 3600000;
if (now >= deadline)
return { ok: false, reason: `Changes are allowed up to ${policy.minHoursBefore} hours before your time slot.` };
return { ok: true, deadline };
}
export interface PlannedReminder {
policyId: string;
channel: ReminderPolicy["channel"];
sendAt: number;
body: string;
}
export function planReminders(
b: Booking,
policies: ReminderPolicy[],
now: number,
vars: Record<string, string> = {},
): PlannedReminder[] {
const start = slotStartMs(b);
return policies
.filter((p) => p.enabled && (!p.methods?.length || p.methods.includes(b.method)))
.map((p) => ({
policyId: p.id,
channel: p.channel,
sendAt: start - p.minutesBefore * 60000,
body: renderTemplate(p.template, {
name: b.customer?.name ?? "there",
order: b.orderName ?? "",
date: b.date,
time: b.slotStart && b.slotEnd ? `${b.slotStart}–${b.slotEnd}` : "",
method: b.method,
...vars,
}),
}))
.filter((r) => r.sendAt > now)
.filter((r) => {
// Respect channel requirements.
if (r.channel === "email") return !!b.customer?.email;
return !!b.customer?.phone;
});
}
/** {{var}} substitution; unknown vars render empty. */
export function renderTemplate(tpl: string, vars: Record<string, string>): string {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => vars[k] ?? "");
}

9
app/engine/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * from "./types";
export * from "./time";
export * from "./rules";
export * from "./zones";
export * from "./availability";
export * from "./capacity";
export * from "./locations";
export * from "./customer";
export * from "./runsheet";

121
app/engine/locations.ts Normal file
View File

@ -0,0 +1,121 @@
/**
* Location eligibility + ranking for a cart and method.
*
* Policy: never silently assign a whole cart to one location when its items are
* only stocked across several. Instead return SPLIT_REQUIRED with an explanation.
*/
import type { AvailabilityResult, Cart, LocationConfig, Method, PeakDay, ProductRule, Reason, ShopSettings, UsageSnapshot } from "./types";
import { computeAvailability, firstAvailable } from "./availability";
import { evaluateRules } from "./rules";
import { type CustomerAddress, type FeeQuote, type ZoneMatch, matchZone, quoteFee } from "./zones";
export type Inventory = Record<string, Record<string, number>>; // locationId → variantId → available
export interface RankedLocation {
location: LocationConfig;
eligible: boolean;
reasons: Reason[];
zone?: ZoneMatch;
fee?: FeeQuote;
distanceKm: number | null;
first: { date: string; slot: string } | null;
availability?: AvailabilityResult;
}
export interface RankInput {
locations: LocationConfig[];
method: Method;
cart: Cart;
rules: ProductRule[];
peakDays: PeakDay[];
usage: UsageSnapshot;
settings: Pick<ShopSettings, "mixedPrepPolicy">;
now: number;
address?: CustomerAddress;
inventory?: Inventory;
ranking?: "priority" | "distance" | "earliest";
}
/**
* A location with no inventory entry (not linked to a Shopify location) is "unknown" and
* never blocks. For linked locations, a missing variant means none in stock there.
*/
export function stockedAt(inv: Inventory | undefined, locId: string, cart: Cart): boolean {
if (!inv || !inv[locId]) return true;
const at = inv[locId];
return cart.items.every((i) => (at[i.variantId] ?? 0) >= i.quantity);
}
export function rankLocations(input: RankInput): { ranked: RankedLocation[]; splitRequired: Reason | null } {
const constraints = evaluateRules(input.cart, input.rules, input.settings.mixedPrepPolicy);
const subtotal = input.cart.subtotal ?? input.cart.items.reduce((s, i) => s + i.price * i.quantity, 0);
const ranked: RankedLocation[] = [];
for (const loc of input.locations) {
const r: RankedLocation = { location: loc, eligible: true, reasons: [], distanceKm: null, first: null };
ranked.push(r);
if (input.method === "delivery") {
const z = matchZone(loc, input.address ?? {});
r.zone = z;
r.distanceKm = z.distanceKm;
if (!z.zone) {
r.eligible = false;
r.reasons.push(z.reason!);
} else {
r.fee = quoteFee(z.zone, subtotal);
if (r.fee.belowMinimum) {
r.eligible = false;
r.reasons.push({
code: "BELOW_MINIMUM_ORDER",
message: `Delivery needs a minimum order of $${(r.fee.minOrder! / 100).toFixed(2)}`,
});
}
}
}
if (input.method !== "shipping" && !stockedAt(input.inventory, loc.id, input.cart)) {
r.eligible = false;
r.reasons.push({ code: "OUT_OF_STOCK_AT_LOCATION", message: `Not all items are in stock at ${loc.name}` });
}
const av = computeAvailability({ ...input, location: loc, constraints });
r.availability = av;
if (av.blockers.length) {
r.eligible = false;
r.reasons.push(...av.blockers);
}
const f = firstAvailable(av);
r.first = f ? { date: f.date, slot: f.slot.id } : null;
if (r.eligible && !f) {
r.eligible = false;
r.reasons.push({ code: "DAY_FULL", message: "No available dates in the booking window" });
}
}
const mode = input.ranking ?? "priority";
ranked.sort((a, b) => {
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
if (mode === "distance" && a.distanceKm != null && b.distanceKm != null && a.distanceKm !== b.distanceKm)
return a.distanceKm - b.distanceKm;
if (mode === "earliest" && a.first && b.first) {
const ka = `${a.first.date} ${a.first.slot}`;
const kb = `${b.first.date} ${b.first.slot}`;
if (ka !== kb) return ka < kb ? -1 : 1;
}
return a.location.priority - b.location.priority;
});
// Split detection: nobody stocks everything, but the union does.
let splitRequired: Reason | null = null;
if (input.inventory && input.method !== "shipping" && !ranked.some((r) => r.eligible)) {
const unionOk = input.cart.items.every(
(i) => input.locations.reduce((s, l) => s + (input.inventory![l.id]?.[i.variantId] ?? 0), 0) >= i.quantity,
);
const anyStocked = input.locations.some((l) => stockedAt(input.inventory, l.id, input.cart));
if (unionOk && !anyStocked)
splitRequired = {
code: "OUT_OF_STOCK_AT_LOCATION",
message:
"Your items are stocked at different locations. Choose shipping, or remove some items to schedule a single pickup or delivery.",
};
}
return { ranked, splitRequired };
}

216
app/engine/rules.ts Normal file
View File

@ -0,0 +1,216 @@
/**
* Product rules → cart constraints.
*
* Conflict policy (v1, documented in the merchant UI):
* 1. "allowOnlyMethods" rules intersect across items.
* 2. "prohibitMethods" always wins over any allowance.
* 3. "restrictLocations" intersects across items.
* 4. Prep time: each item's prep = sum of its matching addPrep rules;
* cart prep = longest item (or sum, per shop setting).
* 5. Date/weekday blocks and freshness windows are all applied (most restrictive).
* Every constraint keeps the rule id + item that caused it so the merchant
* diagnostics screen and the customer message can explain it.
*/
import type { Cart, CartItem, Method, ProductRule, Reason, Weekday } from "./types";
import { METHODS } from "./types";
import { addDays } from "./time";
export interface FreshnessConstraint {
ruleId: string;
itemTitle: string;
arrivals: { date: string; locationIds?: string[] }[];
minDaysAfterArrival: number;
shelfLifeDays: number;
message?: string;
}
export interface CartConstraints {
/** Methods the cart may use, with reasons for any removed. */
allowedMethods: Set<Method>;
methodReasons: Map<Method, Reason[]>;
/** null = no location restriction. */
allowedLocations: Set<string> | null;
locationReasons: Reason[];
extraPrepMinutes: number;
prepDriver?: { itemTitle: string; ruleId: string; minutes: number };
blockedWeekdays: Map<Weekday, Reason>;
blockedDates: Map<string, Reason>;
windows: { from?: string; to?: string; reason: Reason }[];
freshness: FreshnessConstraint[];
requiresScheduling: boolean;
matched: { ruleId: string; ruleName: string; itemTitle: string }[];
}
export function itemMatches(item: CartItem, rule: ProductRule): boolean {
const t = rule.target;
const vals = t.values.map((v) => String(v).toLowerCase());
switch (t.type) {
case "product":
return vals.includes(String(item.productId).toLowerCase());
case "variant":
return vals.includes(String(item.variantId).toLowerCase());
case "collection":
return (item.collectionIds ?? []).some((c) => vals.includes(String(c).toLowerCase()));
case "vendor":
return !!item.vendor && vals.includes(item.vendor.toLowerCase());
case "productType":
return !!item.productType && vals.includes(item.productType.toLowerCase());
case "tag":
return (item.tags ?? []).some((tag) => vals.includes(tag.toLowerCase()));
}
}
function why(rule: ProductRule, item: CartItem, fallback: string): Reason {
return { code: "BLOCKED_BY_PRODUCT", message: rule.message || fallback.replace("{item}", item.title), ruleId: rule.id };
}
export function evaluateRules(
cart: Cart,
rules: ProductRule[],
policy: "longest" | "sum" = "longest",
): CartConstraints {
const c: CartConstraints = {
allowedMethods: new Set(METHODS),
methodReasons: new Map(),
allowedLocations: null,
locationReasons: [],
extraPrepMinutes: 0,
blockedWeekdays: new Map(),
blockedDates: new Map(),
windows: [],
freshness: [],
requiresScheduling: false,
matched: [],
};
const active = rules.filter((r) => r.enabled).sort((a, b) => a.priority - b.priority);
const prohibitions: { m: Method; reason: Reason }[] = [];
let prepTotal = 0;
for (const item of cart.items) {
let itemPrep = 0;
let itemPrepRule: string | undefined;
for (const rule of active) {
if (!itemMatches(item, rule)) continue;
c.matched.push({ ruleId: rule.id, ruleName: rule.name, itemTitle: item.title });
const a = rule.action;
switch (a.type) {
case "allowOnlyMethods":
for (const m of METHODS) {
if (!a.methods.includes(m) && c.allowedMethods.has(m)) {
c.allowedMethods.delete(m);
pushReason(c.methodReasons, m, {
...why(rule, item, `{item} is only available for ${a.methods.join(" or ")}`),
code: "METHOD_PROHIBITED_BY_PRODUCT",
});
}
}
break;
case "prohibitMethods":
for (const m of a.methods)
prohibitions.push({
m,
reason: { ...why(rule, item, `{item} can't be sent by ${m}`), code: "METHOD_PROHIBITED_BY_PRODUCT" },
});
break;
case "restrictLocations": {
const next = new Set(a.locationIds);
c.allowedLocations = c.allowedLocations
? new Set([...c.allowedLocations].filter((x) => next.has(x)))
: next;
c.locationReasons.push({
...why(rule, item, `{item} is only available from selected locations`),
code: "LOCATION_PROHIBITED_BY_PRODUCT",
});
break;
}
case "addPrep":
itemPrep += a.minutes;
itemPrepRule = rule.id;
break;
case "blockWeekdays":
for (const w of a.weekdays)
if (!c.blockedWeekdays.has(w)) c.blockedWeekdays.set(w, why(rule, item, `{item} isn't available on this day`));
break;
case "blockDates":
for (const d of a.dates)
if (!c.blockedDates.has(d)) c.blockedDates.set(d, why(rule, item, `{item} isn't available on this date`));
break;
case "dateWindow":
c.windows.push({ from: a.from, to: a.to, reason: why(rule, item, `{item} is only available in a limited window`) });
break;
case "freshness":
c.freshness.push({
ruleId: rule.id,
itemTitle: item.title,
arrivals: a.arrivals,
minDaysAfterArrival: a.minDaysAfterArrival,
shelfLifeDays: a.shelfLifeDays,
message: rule.message,
});
break;
case "requireScheduling":
c.requiresScheduling = true;
break;
}
}
if (policy === "sum") prepTotal += itemPrep;
if (itemPrep > (c.prepDriver?.minutes ?? 0) && itemPrepRule)
c.prepDriver = { itemTitle: item.title, ruleId: itemPrepRule, minutes: itemPrep };
}
// Prohibitions win over allowances.
for (const { m, reason } of prohibitions) {
c.allowedMethods.delete(m);
pushReason(c.methodReasons, m, reason);
}
c.extraPrepMinutes = policy === "sum" ? prepTotal : c.prepDriver?.minutes ?? 0;
return c;
}
function pushReason(map: Map<Method, Reason[]>, m: Method, r: Reason) {
const list = map.get(m) ?? [];
if (!list.some((x) => x.ruleId === r.ruleId && x.message === r.message)) list.push(r);
map.set(m, list);
}
/**
* Freshness check for one date at one location. A date passes when, for every
* perishable item, some stock arrival makes the date fall inside
* [arrival + minDays, arrival + shelfLife].
*/
export function checkFreshness(
constraints: FreshnessConstraint[],
date: string,
locationId: string,
): Reason | null {
for (const f of constraints) {
const arrivals = f.arrivals.filter((a) => !a.locationIds?.length || a.locationIds.includes(locationId));
if (!arrivals.length)
return {
code: "NO_STOCK_ARRIVAL",
message: f.message || `${f.itemTitle}: no fresh stock is scheduled for this location yet`,
ruleId: f.ruleId,
};
const ok = arrivals.some(
(a) => date >= addDays(a.date, f.minDaysAfterArrival) && date <= addDays(a.date, f.shelfLifeDays),
);
if (!ok) {
const nextReady = arrivals
.map((a) => addDays(a.date, f.minDaysAfterArrival))
.filter((d) => d > date)
.sort()[0];
return nextReady
? {
code: "NOT_FRESH_YET",
message: f.message || `${f.itemTitle}: next fresh batch is ready from ${nextReady}`,
ruleId: f.ruleId,
}
: {
code: "PAST_FRESHNESS",
message: f.message || `${f.itemTitle}: outside its freshness window on this date`,
ruleId: f.ruleId,
};
}
}
return null;
}

98
app/engine/runsheet.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* Driver dispatch: daily run sheet, stop ordering and route links.
*
* Ordering: by slot window first (promises come first), then nearest-neighbour
* within each window starting from the previous stop. This is a pragmatic
* heuristic, not an optimal VRP solver — export to a route optimiser for large fleets.
*/
import type { Booking, LocationConfig } from "./types";
import { haversineKm } from "./zones";
export interface RunStop {
seq: number;
booking: Booking;
window: string;
legKm: number | null;
}
export interface RunSheet {
locationId: string;
date: string;
stops: RunStop[];
totalKm: number | null;
unlocated: Booking[];
mapsUrls: string[];
}
export function buildRunSheet(bookings: Booking[], loc: LocationConfig, date: string): RunSheet {
const todays = bookings.filter(
(b) => b.method === "delivery" && b.locationId === loc.id && b.date === date && b.status === "confirmed",
);
const located = todays.filter((b) => b.address?.lat != null && b.address?.lng != null);
const unlocated = todays.filter((b) => !located.includes(b));
const windows = new Map<string, Booking[]>();
for (const b of located) {
const w = `${b.slotStart}-${b.slotEnd}`;
windows.set(w, [...(windows.get(w) ?? []), b]);
}
const ordered: RunStop[] = [];
let cur = { lat: loc.lat ?? 0, lng: loc.lng ?? 0 };
let total = 0;
const hasOrigin = loc.lat != null && loc.lng != null;
for (const w of [...windows.keys()].sort()) {
const pool = [...windows.get(w)!];
while (pool.length) {
let best = 0;
let bestD = Infinity;
pool.forEach((b, i) => {
const d = haversineKm(cur.lat, cur.lng, b.address!.lat!, b.address!.lng!);
if (d < bestD) (bestD = d), (best = i);
});
const [b] = pool.splice(best, 1);
const leg = hasOrigin || ordered.length ? bestD : null;
if (leg != null) total += leg;
ordered.push({ seq: ordered.length + 1, booking: b, window: w, legKm: leg });
cur = { lat: b.address!.lat!, lng: b.address!.lng! };
}
}
return {
locationId: loc.id,
date,
stops: ordered,
totalKm: ordered.length ? Math.round(total * 10) / 10 : null,
unlocated,
mapsUrls: mapsUrls(loc, ordered),
};
}
/** Google Maps directions links, chunked to the 9-waypoint limit of the URL API. */
export function mapsUrls(loc: LocationConfig, stops: RunStop[]): string[] {
if (!stops.length) return [];
const pt = (b: Booking) => `${b.address!.lat},${b.address!.lng}`;
const origin = loc.lat != null ? `${loc.lat},${loc.lng}` : encodeURIComponent(loc.address);
const urls: string[] = [];
let from = origin;
for (let i = 0; i < stops.length; i += 10) {
const chunk = stops.slice(i, i + 10);
const dest = pt(chunk[chunk.length - 1].booking);
const way = chunk.slice(0, -1).map((s) => pt(s.booking)).join("|");
urls.push(
`https://www.google.com/maps/dir/?api=1&origin=${from}&destination=${dest}` +
(way ? `&waypoints=${encodeURIComponent(way)}` : "") +
`&travelmode=driving`,
);
from = dest;
}
return urls;
}
/** RFC 4180 CSV with a UTF-8 BOM so Excel opens accents/Tamil text correctly. */
export function toCsv(rows: Record<string, unknown>[], columns?: string[]): string {
const cols = columns ?? Object.keys(rows[0] ?? {});
const esc = (v: unknown) => {
const s = v == null ? "" : String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return "" + [cols.join(","), ...rows.map((r) => cols.map((c) => esc(r[c])).join(","))].join("\r\n");
}

108
app/engine/time.ts Normal file
View File

@ -0,0 +1,108 @@
/**
* Time-zone helpers built on Intl only (no dependencies), so the same code
* runs in Node, edge runtimes and the browser.
*/
import type { Weekday } from "./types";
const fmtCache = new Map<string, Intl.DateTimeFormat>();
function fmt(tz: string): Intl.DateTimeFormat {
let f = fmtCache.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
weekday: "short",
});
fmtCache.set(tz, f);
}
return f;
}
const WD: Record<string, Weekday> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
export interface LocalParts {
date: string; // YYYY-MM-DD
time: string; // HH:MM
minutes: number; // minutes since local midnight
weekday: Weekday;
}
export function localParts(utcMs: number, tz: string): LocalParts {
const p: Record<string, string> = {};
for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value;
const hour = p.hour === "24" ? "00" : p.hour;
return {
date: `${p.year}-${p.month}-${p.day}`,
time: `${hour}:${p.minute}`,
minutes: Number(hour) * 60 + Number(p.minute),
weekday: WD[p.weekday],
};
}
/** Offset of `tz` from UTC at instant `utcMs`, in minutes (Toronto EDT = -240). */
export function tzOffsetMinutes(utcMs: number, tz: string): number {
const p: Record<string, string> = {};
for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value;
const hour = p.hour === "24" ? 0 : Number(p.hour);
const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, hour, +p.minute, +p.second);
return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / 60000);
}
/**
* Convert a local wall-clock time in `tz` to a UTC instant.
* Non-existent times (spring-forward gap) resolve forward; ambiguous times
* (fall-back overlap) resolve to the first occurrence.
*/
export function zonedToUtc(date: string, time: string, tz: string): number {
const [y, m, d] = date.split("-").map(Number);
const [hh, mm] = time.split(":").map(Number);
const guess = Date.UTC(y, m - 1, d, hh, mm);
const off1 = tzOffsetMinutes(guess, tz);
let utc = guess - off1 * 60000;
const off2 = tzOffsetMinutes(utc, tz);
if (off2 !== off1) {
const alt = guess - off2 * 60000;
// Prefer the candidate that round-trips; otherwise (gap) take the later instant.
if (localParts(alt, tz).time === time) utc = Math.min(utc, alt);
else if (localParts(utc, tz).time !== time) utc = Math.max(utc, alt);
}
return utc;
}
export function toMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
export function fromMinutes(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number);
const t = new Date(Date.UTC(y, m - 1, d + days));
return t.toISOString().slice(0, 10);
}
export function diffDays(a: string, b: string): number {
const [y1, m1, d1] = a.split("-").map(Number);
const [y2, m2, d2] = b.split("-").map(Number);
return Math.round((Date.UTC(y1, m1 - 1, d1) - Date.UTC(y2, m2 - 1, d2)) / 86400000);
}
export function weekdayOf(date: string): Weekday {
const [y, m, d] = date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).getUTCDay() as Weekday;
}
export function iso(ms: number): string {
return new Date(ms).toISOString();
}

288
app/engine/types.ts Normal file
View File

@ -0,0 +1,288 @@
/**
* Datewise scheduling engine — shared types.
*
* Conventions
* - Calendar dates are local to the location: "YYYY-MM-DD".
* - Clock times are local wall-clock: "HH:MM" (24h).
* - Instants are UTC ISO strings or epoch milliseconds.
* - Money is integer minor units (cents) in the shop currency.
*/
export type Method = "pickup" | "delivery" | "shipping";
export const METHODS: Method[] = ["pickup", "delivery", "shipping"];
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday
export interface TimeRange {
start: string; // "HH:MM"
end: string; // "HH:MM" (exclusive)
}
export interface SlotTemplate extends TimeRange {
/** Overrides MethodConfig.slotCapacity for this slot. */
capacity?: number | null;
/** Extra fee for this slot (e.g. evening delivery), minor units. */
surcharge?: number;
}
export interface MethodConfig {
enabled: boolean;
/** Operating hours per weekday. Missing/empty weekday = closed. */
weeklyHours: Partial<Record<Weekday, TimeRange[]>>;
/**
* How slots are produced:
* - "generated": split operating hours into `slotMinutes` slots
* - "explicit": use `slots[weekday]`
* - "dateOnly": customer picks a date only (typical for shipping dispatch)
*/
slotMode: "generated" | "explicit" | "dateOnly";
slotMinutes?: number;
slots?: Partial<Record<Weekday, SlotTemplate[]>>;
/** Minimum preparation time before the slot starts. */
prepMinutes: number;
/** "elapsed" counts wall-clock time; "operating" counts only open hours. */
prepMode: "elapsed" | "operating";
/** At/after this local time, same-day service is no longer bookable. */
sameDayCutoff?: string | null;
/** At/after this local time, next-day service is no longer bookable. */
nextDayCutoff?: string | null;
/** Days ahead customers can book (inclusive of today = day 0). */
horizonDays: number;
dailyCapacity?: number | null;
slotCapacity?: number | null;
}
export interface DateException {
date: string; // YYYY-MM-DD
/** Closed for the listed methods (or all when omitted). */
closed?: boolean;
/** Replacement hours for that day (special opening). */
hours?: TimeRange[];
methods?: Method[];
note?: string;
}
export interface DeliveryZone {
id: string;
name: string;
/** Postal patterns: exact ("N2H 5Z6"), prefix ("N2H*"), bare FSA ("N2H"), or range ("N2A..N2H"). */
postalPatterns?: string[];
/** Straight-line radius in km from the location. */
radiusKm?: number | null;
/** Driving-distance limit in km (requires a maps provider). */
drivingKm?: number | null;
fee: number;
/** Order subtotal at or above which the fee is waived. */
freeOver?: number | null;
minOrder?: number | null;
}
export interface LocationConfig {
id: string;
shopifyLocationId?: string;
name: string;
address: string;
timezone: string; // IANA, e.g. "America/Toronto"
lat?: number;
lng?: number;
enabled: boolean;
priority: number; // lower = preferred
methods: Partial<Record<Method, MethodConfig>>;
exceptions: DateException[];
zones: DeliveryZone[];
pickupInstructions?: string;
}
/** Peak / festival day — the "rush control" feature. */
export interface PeakDay {
id: string;
name: string; // "Diwali", "Mother's Day"
dates: string[]; // YYYY-MM-DD
locationIds?: string[]; // all when omitted
methods?: Method[]; // all when omitted
dailyCapacity?: number | null; // replaces normal daily capacity
slotCapacity?: number | null; // replaces normal slot capacity
extraPrepMinutes?: number;
/** Earlier cutoff: bookings for the peak date close `daysBefore` days earlier at `time`. */
cutoff?: { daysBefore: number; time: string } | null;
surcharge?: number; // per order, minor units
/** When full, offer a waitlist instead of a dead end. */
waitlist?: boolean;
/** Customer-facing banner text. */
message?: string;
}
export type RuleTarget =
| { type: "product"; values: string[] }
| { type: "variant"; values: string[] }
| { type: "collection"; values: string[] }
| { type: "vendor"; values: string[] }
| { type: "productType"; values: string[] }
| { type: "tag"; values: string[] };
export type RuleAction =
| { type: "prohibitMethods"; methods: Method[] }
| { type: "allowOnlyMethods"; methods: Method[] }
| { type: "restrictLocations"; locationIds: string[] }
| { type: "addPrep"; minutes: number }
| { type: "blockWeekdays"; weekdays: Weekday[] }
| { type: "blockDates"; dates: string[] }
| { type: "dateWindow"; from?: string; to?: string }
/**
* Perishable stock: the item can only be fulfilled between
* (arrival + minDaysAfterArrival) and (arrival + shelfLifeDays) for some arrival.
*/
| {
type: "freshness";
arrivals: { date: string; locationIds?: string[] }[];
minDaysAfterArrival: number;
shelfLifeDays: number;
}
| { type: "requireScheduling" };
export interface ProductRule {
id: string;
name: string;
enabled: boolean;
priority: number; // lower runs first (only matters for display/explanations)
target: RuleTarget;
action: RuleAction;
/** Customer-facing explanation. */
message?: string;
}
export interface CartItem {
productId: string;
variantId: string;
title: string;
quantity: number;
price: number; // unit, minor units
collectionIds?: string[];
vendor?: string;
productType?: string;
tags?: string[];
grams?: number;
}
export interface Cart {
items: CartItem[];
subtotal?: number;
}
/** Bookings + active holds, keyed by location/method/date. */
export interface UsageSnapshot {
[key: string]: { day: number; slots: Record<string, number> };
}
export interface ShopSettings {
/** Mixed cart prep policy. MVP: longest wins. */
mixedPrepPolicy: "longest" | "sum";
holdMinutes: number;
weekStartsOn: Weekday;
/** Customer self-reschedule policy. */
reschedule: {
enabled: boolean;
/** Must be at least this many hours before the booked slot starts. */
minHoursBefore: number;
maxChanges: number;
/** Allow switching location/method, or only date/slot. */
allowMethodChange: boolean;
};
reminders: ReminderPolicy[];
}
export interface ReminderPolicy {
id: string;
channel: "email" | "sms" | "whatsapp";
/** Minutes before slot start. */
minutesBefore: number;
methods?: Method[];
template: string;
enabled: boolean;
}
export interface Booking {
id: string;
orderId?: string;
orderName?: string;
status: "held" | "confirmed" | "cancelled" | "fulfilled" | "needs_review";
method: Method;
locationId: string;
date: string;
slotStart?: string;
slotEnd?: string;
timezone: string;
startUtc?: string;
endUtc?: string;
customer?: { name?: string; email?: string; phone?: string };
address?: { line1?: string; city?: string; postal?: string; lat?: number; lng?: number };
items?: { title: string; quantity: number }[];
rescheduleCount?: number;
peakDayId?: string | null;
fee?: number;
ruleVersion?: string;
}
export type ReasonCode =
| "LOCATION_DISABLED"
| "METHOD_DISABLED"
| "METHOD_PROHIBITED_BY_PRODUCT"
| "LOCATION_PROHIBITED_BY_PRODUCT"
| "OUTSIDE_DELIVERY_AREA"
| "BELOW_MINIMUM_ORDER"
| "OUT_OF_STOCK_AT_LOCATION"
| "BEYOND_HORIZON"
| "CLOSED_WEEKLY"
| "CLOSED_EXCEPTION"
| "BLOCKED_BY_PRODUCT"
| "NOT_FRESH_YET"
| "PAST_FRESHNESS"
| "NO_STOCK_ARRIVAL"
| "PAST_CUTOFF"
| "PEAK_CUTOFF"
| "PREP_TIME"
| "DAY_FULL"
| "SLOT_FULL"
| "SLOT_IN_PAST";
export interface Reason {
code: ReasonCode;
message: string;
ruleId?: string;
}
export interface SlotResult {
id: string; // "HH:MM-HH:MM"
start: string;
end: string;
startUtc: string;
endUtc: string;
available: boolean;
remaining: number | null; // null = unlimited
surcharge: number;
waitlist: boolean;
reasons: Reason[];
}
export interface DateResult {
date: string;
weekday: Weekday;
available: boolean;
remaining: number | null;
peak?: { id: string; name: string; message?: string } | null;
surcharge: number;
waitlist: boolean;
reasons: Reason[];
slots: SlotResult[];
}
export interface AvailabilityResult {
locationId: string;
method: Method;
timezone: string;
dateOnly: boolean;
prepMinutes: number;
earliestReadyUtc: string;
blockers: Reason[]; // whole method/location unavailable
dates: DateResult[];
}

114
app/engine/zones.ts Normal file
View File

@ -0,0 +1,114 @@
/**
* Delivery areas and fees. Eligibility and pricing are deliberately separate:
* being inside a zone never implies free delivery.
*/
import type { DeliveryZone, LocationConfig, Reason } from "./types";
/** Uppercase, strip spaces/dashes. "n2h-5z6" → "N2H5Z6"; "02134-1234" → "021341234". */
export function normalizePostal(postal: string): string {
return (postal || "").toUpperCase().replace(/[\s-]/g, "");
}
export function postalMatches(postal: string, pattern: string): boolean {
const p = normalizePostal(postal);
const raw = pattern.trim().toUpperCase();
if (!p || !raw) return false;
// Range on a shared-length prefix: "N2A..N2H" or "10001..10010"
if (raw.includes("..")) {
const [lo, hi] = raw.split("..").map(normalizePostal);
if (!lo || lo.length !== hi.length) return false;
const head = p.slice(0, lo.length);
return head >= lo && head <= hi;
}
const pat = normalizePostal(raw);
if (pat.endsWith("*")) return p.startsWith(pat.slice(0, -1));
return p === pat || (pat.length === 3 && p.startsWith(pat)); // bare Canadian FSA "N2H"
}
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (x: number) => (x * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
export interface CustomerAddress {
postal?: string;
lat?: number;
lng?: number;
/** Pre-computed driving distance per location id, from the maps adapter. */
drivingKm?: Record<string, number | null>;
}
export interface ZoneMatch {
zone: DeliveryZone | null;
distanceKm: number | null;
reason?: Reason;
/** True when a zone needed a maps lookup that wasn't available. */
unverified?: boolean;
}
/**
* First matching zone wins (merchant orders zones, typically cheapest/closest first).
* A zone matches when ANY of its configured criteria match.
* If a driving-distance zone can't be checked (maps outage), it does NOT match —
* we never accept unverified addresses.
*/
export function matchZone(loc: LocationConfig, addr: CustomerAddress): ZoneMatch {
const radial =
loc.lat != null && loc.lng != null && addr.lat != null && addr.lng != null
? haversineKm(loc.lat, loc.lng, addr.lat, addr.lng)
: null;
let unverified = false;
for (const z of loc.zones) {
if (z.postalPatterns?.length && addr.postal && z.postalPatterns.some((pt) => postalMatches(addr.postal!, pt)))
return { zone: z, distanceKm: radial };
if (z.radiusKm != null && radial != null && radial <= z.radiusKm) return { zone: z, distanceKm: radial };
if (z.drivingKm != null) {
const d = addr.drivingKm?.[loc.id];
if (d == null) unverified = true;
else if (d <= z.drivingKm) return { zone: z, distanceKm: d };
}
}
return {
zone: null,
distanceKm: radial,
unverified,
reason: {
code: "OUTSIDE_DELIVERY_AREA",
message: unverified
? "We couldn't verify your address right now. Please try pickup or check again shortly."
: `${loc.name} doesn't deliver to this address yet`,
},
};
}
export interface FeeQuote {
fee: number;
baseFee: number;
waived: boolean;
surcharges: { label: string; amount: number }[];
belowMinimum: boolean;
minOrder: number | null;
}
export function quoteFee(
zone: DeliveryZone | null,
subtotal: number,
extras: { label: string; amount: number }[] = [],
): FeeQuote {
const base = zone?.fee ?? 0;
const waived = zone?.freeOver != null && subtotal >= zone.freeOver;
const surcharges = extras.filter((e) => e.amount > 0);
const minOrder = zone?.minOrder ?? null;
return {
baseFee: base,
waived,
surcharges,
fee: (waived ? 0 : base) + surcharges.reduce((s, e) => s + e.amount, 0),
belowMinimum: minOrder != null && subtotal < minOrder,
minOrder,
};
}

39
app/entry.server.tsx Normal file
View File

@ -0,0 +1,39 @@
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { type EntryContext, ServerRouter } from "react-router";
import { addDocumentResponseHeaders } from "./shopify.server";
export const streamTimeout = 5000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext,
) {
addDocumentResponseHeaders(request, responseHeaders);
const userAgent = request.headers.get("user-agent");
const callbackName = isbot(userAgent ?? "") ? "onAllReady" : "onShellReady";
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={reactRouterContext} url={request.url} />, {
[callbackName]: () => {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(new Response(stream, { headers: responseHeaders, status: responseStatusCode }));
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
console.error(error);
},
});
setTimeout(abort, streamTimeout + 1000);
});
}

110
app/jobs/worker.ts Normal file
View File

@ -0,0 +1,110 @@
/**
* Background worker — run as a separate process (`npm run worker`), one or more instances.
*
* Every minute:
* - release expired holds (capacity returns even if no shopper touches that date)
* - send due reminders (claimed row-by-row, safe with several workers)
* Every 15 minutes:
* - reconcile: recent orders that carry a selection but have no booking (missed/failed
* webhooks) are replayed through the same handler, so nothing is silently lost.
*/
import prisma from "../db.server";
import { handleOrderCreate, type OrderPayload } from "../services/orders.server";
import { runDueReminders } from "../services/reminders.server";
import { ReservationService } from "../services/reservations.server";
import { SELECTION_ATTR } from "../services/settings";
import shopify from "../shopify.server";
const reservations = new ReservationService(prisma);
let stopping = false;
async function tick() {
const now = new Date();
const released = await reservations.sweepExpired(now).catch((e) => (console.error("sweep", e), 0));
const sent = await runDueReminders(now).catch((e) => (console.error("reminders", e), 0));
if (released || sent) console.info(`[worker] released ${released} holds, sent ${sent} reminders`);
}
async function reconcile() {
const shops = await prisma.shop.findMany({ where: { uninstalledAt: null }, select: { id: true, domain: true } });
for (const s of shops) {
try {
const { admin } = await shopify.unauthenticated.admin(s.domain);
const since = new Date(Date.now() - 6 * 3600_000).toISOString();
const res = await admin.graphql(
`#graphql
query($q: String!) {
orders(first: 100, query: $q, sortKey: CREATED_AT, reverse: true) {
nodes { id legacyResourceId name email phone customAttributes { key value }
customer { firstName lastName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } }
shippingAddress { address1 address2 city zip latitude longitude phone firstName lastName }
lineItems(first: 100) { nodes { title quantity originalUnitPriceSet { shopMoney { amount } } product { legacyResourceId } variant { legacyResourceId } } }
}
}
}`,
{ variables: { q: `created_at:>='${since}'` } },
);
const orders = ((await res.json()) as { data: { orders: { nodes: GqlOrder[] } } }).data.orders.nodes;
const ids = orders.map((o) => o.id);
const have = new Set((await prisma.booking.findMany({ where: { shopId: s.id, orderId: { in: ids } }, select: { orderId: true } })).map((b) => b.orderId));
for (const o of orders) {
if (have.has(o.id) || !o.customAttributes.some((a) => a.key === SELECTION_ATTR)) continue;
console.warn(`[reconcile] ${s.domain} ${o.name} had no booking; replaying`);
await handleOrderCreate(s.domain, toPayload(o), admin);
}
} catch (e) {
console.error(`[reconcile] ${s.domain}`, e);
}
}
}
interface GqlOrder {
id: string;
legacyResourceId: string;
name: string;
email: string | null;
phone: string | null;
customAttributes: { key: string; value: string }[];
customer: { firstName: string | null; lastName: string | null; defaultEmailAddress: { emailAddress: string } | null; defaultPhoneNumber: { phoneNumber: string } | null } | null;
shippingAddress: { address1: string; address2: string; city: string; zip: string; latitude: number | null; longitude: number | null; phone: string | null; firstName: string; lastName: string } | null;
lineItems: { nodes: { title: string; quantity: number; originalUnitPriceSet: { shopMoney: { amount: string } }; product: { legacyResourceId: string } | null; variant: { legacyResourceId: string } | null }[] };
}
function toPayload(o: GqlOrder): OrderPayload {
const a = o.shippingAddress;
return {
id: Number(o.legacyResourceId),
admin_graphql_api_id: o.id,
name: o.name,
email: o.email,
phone: o.phone,
note_attributes: o.customAttributes.map((x) => ({ name: x.key, value: x.value })),
customer: o.customer ? { first_name: o.customer.firstName, last_name: o.customer.lastName, email: o.customer.defaultEmailAddress?.emailAddress ?? null, phone: o.customer.defaultPhoneNumber?.phoneNumber ?? null } : null,
shipping_address: a ? { address1: a.address1, address2: a.address2, city: a.city, zip: a.zip, latitude: a.latitude, longitude: a.longitude, phone: a.phone, first_name: a.firstName, last_name: a.lastName } : null,
line_items: o.lineItems.nodes.map((l) => ({
title: l.title,
quantity: l.quantity,
price: l.originalUnitPriceSet.shopMoney.amount,
product_id: l.product ? Number(l.product.legacyResourceId) : null,
variant_id: l.variant ? Number(l.variant.legacyResourceId) : null,
})),
};
}
async function main() {
console.info("[worker] started");
let n = 0;
while (!stopping) {
const started = Date.now();
await tick();
if (n++ % 15 === 0) await reconcile();
await new Promise((r) => setTimeout(r, Math.max(1000, 60_000 - (Date.now() - started))));
}
}
process.on("SIGTERM", () => (stopping = true));
process.on("SIGINT", () => (stopping = true));
main().catch((e) => {
console.error(e);
process.exit(1);
});

47
app/lib/admin.server.ts Normal file
View File

@ -0,0 +1,47 @@
import { authenticate } from "../shopify.server";
import { loadShopConfig } from "../services/config.server";
import { features } from "../services/plans";
/** Authenticate an embedded admin request and load the shop's config + plan features. */
export async function adminContext(request: Request, opts: { fresh?: boolean } = {}) {
const ctx = await authenticate.admin(request);
const cfg = await loadShopConfig(ctx.session.shop, opts);
return { ...ctx, cfg, features: features(cfg.shop.plan), actor: `merchant:${ctx.session.onlineAccessInfo?.associated_user?.email ?? "staff"}` };
}
export function formString(fd: FormData, k: string): string {
const v = fd.get(k);
return typeof v === "string" ? v.trim() : "";
}
export function formInt(fd: FormData, k: string): number | null {
const s = formString(fd, k);
if (s === "") return null;
const n = Number(s);
return Number.isFinite(n) ? Math.round(n) : null;
}
export function formMoney(fd: FormData, k: string): number | null {
const s = formString(fd, k);
if (s === "") return null;
const n = Number(s.replace(/[$,]/g, ""));
return Number.isFinite(n) ? Math.round(n * 100) : null;
}
export const formBool = (fd: FormData, k: string) => ["on", "true", "1"].includes(formString(fd, k));
export const formList = (fd: FormData, k: string) =>
formString(fd, k)
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
/** "10:00-13:00, 14:00-18:00" → ranges; throws on bad input. */
export function parseRanges(s: string): { start: string; end: string }[] {
if (!s.trim()) return [];
return s.split(",").map((part) => {
const m = part.trim().match(/^(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})$/);
if (!m) throw new Error(`"${part.trim()}" isn't a time range like 10:00-13:00`);
const pad = (h: string) => h.padStart(2, "0");
const start = `${pad(m[1])}:${m[2]}`;
const end = `${pad(m[3])}:${m[4]}`;
if (start >= end) throw new Error(`${start}–${end}: end must be after start`);
return { start, end };
});
}

View File

@ -0,0 +1,20 @@
import type { Prisma } from "@prisma/client";
/** Booking list filters shared by the list page and the CSV export. */
export function bookingWhere(shopId: string, sp: URLSearchParams, today: string): Prisma.BookingWhereInput {
const status = sp.get("status") ?? "upcoming";
const where: Prisma.BookingWhereInput = { shopId };
if (status === "review") where.OR = [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }];
else if (status === "upcoming") Object.assign(where, { status: "confirmed", date: { gte: today } });
else if (status !== "all") where.status = status;
const method = sp.get("method");
if (method && method !== "all") where.method = method;
const loc = sp.get("location");
if (loc && loc !== "all") where.locationId = loc;
const from = sp.get("from");
const to = sp.get("to");
if (from || to) where.date = { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) };
const q = sp.get("q")?.trim();
if (q) where.AND = [{ OR: [{ orderName: { contains: q, mode: "insensitive" } }, { customerName: { contains: q, mode: "insensitive" } }, { customerEmail: { contains: q, mode: "insensitive" } }] }];
return where;
}

32
app/lib/format.ts Normal file
View File

@ -0,0 +1,32 @@
/** Shared display helpers for admin screens (browser-safe). */
export const money = (cents: number, currency = "CAD") =>
new Intl.NumberFormat("en-CA", { style: "currency", currency }).format(cents / 100);
export const niceDate = (d: string | null | undefined, long = false) =>
d
? new Date(`${d}T12:00:00Z`).toLocaleDateString("en-CA", {
weekday: long ? "long" : "short",
month: long ? "long" : "short",
day: "numeric",
timeZone: "UTC",
})
: "—";
export const t12 = (hhmm?: string | null) => {
if (!hhmm) return "";
const [h, m] = hhmm.split(":").map(Number);
return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h >= 12 ? "PM" : "AM"}`;
};
export const slotLabel = (s?: string | null, e?: string | null) => (s && e ? `${t12(s)} – ${t12(e)}` : "—");
export const STATUS_TONE: Record<string, "success" | "critical" | "neutral" | "info" | "warning"> = {
confirmed: "success",
needs_review: "critical",
cancelled: "neutral",
fulfilled: "info",
};
export const METHOD_LABEL: Record<string, string> = { pickup: "Pickup", delivery: "Delivery", shipping: "Shipping" };
export const rangesToText = (r?: { start: string; end: string }[]) => (r ?? []).map((x) => `${x.start}-${x.end}`).join(", ");

47
app/lib/proxy.server.ts Normal file
View File

@ -0,0 +1,47 @@
import { ZodError } from "zod";
import { authenticate } from "../shopify.server";
/** Simple per-instance token bucket (use Redis/edge rate limiting when running several instances). */
const buckets = new Map<string, { tokens: number; at: number }>();
export function rateLimit(key: string, perMinute: number): boolean {
const now = Date.now();
const b = buckets.get(key) ?? { tokens: perMinute, at: now };
b.tokens = Math.min(perMinute, b.tokens + ((now - b.at) / 60000) * perMinute);
b.at = now;
if (b.tokens < 1) {
buckets.set(key, b);
return false;
}
b.tokens -= 1;
buckets.set(key, b);
if (buckets.size > 50000) buckets.clear();
return true;
}
/**
* Wrap an app-proxy handler: verifies Shopify's HMAC signature, rate limits,
* parses JSON and turns validation errors into 400s.
*/
export async function proxyHandler(
request: Request,
limitPerMinute: number,
fn: (ctx: { shop: string; admin?: Awaited<ReturnType<typeof authenticate.public.appProxy>>["admin"]; body: unknown; url: URL }) => Promise<unknown>,
): Promise<Response> {
const ctx = await authenticate.public.appProxy(request);
const url = new URL(request.url);
const shop = ctx.session?.shop ?? url.searchParams.get("shop") ?? "";
if (!ctx.session) return Response.json({ ok: false, message: "App not installed" }, { status: 404 });
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon";
if (!rateLimit(`${shop}|${ip}|${url.pathname}`, limitPerMinute))
return Response.json({ ok: false, message: "Too many requests. Please wait a moment." }, { status: 429 });
try {
const body = request.method === "GET" ? Object.fromEntries(url.searchParams) : await request.json().catch(() => ({}));
const out = await fn({ shop, admin: ctx.admin, body, url });
return Response.json(out, { headers: { "Cache-Control": "no-store" } });
} catch (e) {
if (e instanceof ZodError) return Response.json({ ok: false, message: "Invalid request", issues: e.issues.slice(0, 5) }, { status: 400 });
if (e instanceof Response) return e;
console.error("proxy error", e);
return Response.json({ ok: false, message: "Something went wrong. Please try again." }, { status: 500 });
}
}

20
app/root.tsx Normal file
View File

@ -0,0 +1,20 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

4
app/routes.ts Normal file
View File

@ -0,0 +1,4 @@
import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes({ ignoredRouteFiles: ["**/__tests__/**"] }) satisfies RouteConfig;

8
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,8 @@
import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
if (url.searchParams.get("shop")) throw redirect(`/app?${url.searchParams.toString()}`);
throw redirect("/auth/login");
};

View File

@ -0,0 +1,66 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import prisma from "../db.server";
import { canCustomerReschedule } from "../engine/customer";
import type { Method } from "../engine/types";
import { authenticate, unauthenticated } from "../shopify.server";
import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server";
import { loadShopConfig } from "../services/config.server";
import { rowToBooking } from "../services/reminders.server";
/**
* Customer account / order status extension API.
* The session token proves which shop and which customer is calling; we additionally
* check that the order belongs to that customer before showing or changing anything.
*/
async function load(request: Request, orderId: string) {
const { sessionToken, cors } = await authenticate.public.customerAccount(request);
// dest is the bare shop domain for customer-account tokens; tolerate a URL too.
const shop = sessionToken.dest.startsWith("http") ? new URL(sessionToken.dest).hostname : sessionToken.dest;
const cfg = await loadShopConfig(shop);
const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId } } });
// sessionToken.sub is the customer gid; verify via the Admin API that the order is theirs.
if (b) {
const { admin } = await unauthenticated.admin(shop);
const r = await admin.graphql(`#graphql
query($id: ID!) { order(id: $id) { customer { id } } }`, { variables: { id: orderId } });
const owner = ((await r.json()) as { data: { order: { customer: { id: string } | null } | null } }).data.order?.customer?.id;
if (!owner || owner !== sessionToken.sub) return { cors, cfg, b: null };
}
return { cors, cfg, b };
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
if (request.method === "OPTIONS") return (await authenticate.public.customerAccount(request)).cors(new Response(null, { status: 204 }));
const orderId = new URL(request.url).searchParams.get("orderId") ?? "";
const { cors, cfg, b } = await load(request, orderId);
if (!b) return cors(Response.json({ booking: null }));
const can = canCustomerReschedule(rowToBooking(b), cfg.settings.reschedule, Date.now());
const loc = cfg.locations.find((l) => l.id === b.locationId);
const options = can.ok ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: true }) : [];
return cors(
Response.json({
booking: { id: b.id, status: b.status, method: b.method, date: b.date, slotStart: b.slotStart, slotEnd: b.slotEnd, location: loc?.name ?? null, address: loc?.address ?? null, instructions: b.method === "pickup" ? loc?.pickupInstructions ?? null : null },
canReschedule: can.ok,
reason: can.ok ? null : can.reason,
deadline: can.ok ? new Date(can.deadline).toISOString() : null,
options: options.slice(0, 120),
}),
);
};
const Body = z.object({ orderId: z.string(), date: z.string(), slot: z.string() });
export const action = async ({ request }: ActionFunctionArgs) => {
const parsed = Body.safeParse(await request.json().catch(() => ({})));
const orderId = parsed.success ? parsed.data.orderId : "";
const { cors, cfg, b } = await load(request, orderId);
if (!parsed.success || !b) return cors(Response.json({ ok: false, message: "Booking not found" }, { status: 404 }));
const r = await rescheduleBooking({
domain: cfg.shop.domain,
bookingId: b.id,
sel: { locationId: b.locationId!, method: b.method as Method, date: parsed.data.date, slot: parsed.data.slot },
actor: "customer",
});
return cors(Response.json(r, { status: r.ok ? 200 : 409 }));
};

141
app/routes/app._index.tsx Normal file
View File

@ -0,0 +1,141 @@
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import prisma from "../db.server";
import { capsFor } from "../engine/capacity";
import { hoursFor } from "../engine/availability";
import { addDays, localParts } from "../engine/time";
import { METHODS } from "../engine/types";
import { adminContext } from "../lib/admin.server";
import { METHOD_LABEL, niceDate } from "../lib/format";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features } = await adminContext(request);
const shopId = cfg.shop.id;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const end = addDays(today, 13);
const [todayCount, weekCount, review, waiting, counters, monthCount] = await Promise.all([
prisma.booking.count({ where: { shopId, date: today, status: "confirmed" } }),
prisma.booking.count({ where: { shopId, date: { gt: today, lte: addDays(today, 7) }, status: "confirmed" } }),
prisma.booking.count({ where: { shopId, OR: [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }] } }),
prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }),
prisma.capacityCounter.findMany({ where: { shopId, slot: "*", date: { gte: today, lte: end } } }),
prisma.booking.count({ where: { shopId, createdAt: { gte: new Date(`${today.slice(0, 7)}-01T00:00:00Z`) } } }),
]);
const rows = cfg.locations
.filter((l) => l.enabled)
.flatMap((l) =>
METHODS.filter((m) => l.methods[m]?.enabled && m !== "shipping").map((m) => ({
key: `${l.id}|${m}`,
label: `${l.name} · ${METHOD_LABEL[m]}`,
days: Array.from({ length: 14 }, (_, i) => {
const date = addDays(today, i);
const open = hoursFor(l, m, date).hours.length > 0;
const cap = capsFor(l, m, date, "00:00-00:00", features.peakDays ? cfg.peakDays : []).dayCap;
const used = counters.find((c) => c.locationId === l.id && c.method === m && c.date === date)?.count ?? 0;
const peak = cfg.peakDays.find((p) => p.dates.includes(date) && (!p.locationIds?.length || p.locationIds.includes(l.id)));
return { date, open, cap, used, peak: peak?.name ?? null };
}),
})),
);
const peaks = cfg.peakDays
.flatMap((p) => p.dates.filter((d) => d >= today).map((d) => ({ name: p.name, date: d, cutoff: p.cutoff })))
.sort((a, b) => (a.date < b.date ? -1 : 1))
.slice(0, 3);
const setup = {
anyLocation: cfg.locations.some((l) => l.enabled),
feeProduct: !!cfg.shop.feeProductId,
};
return {
today, todayCount, weekCount, review, waiting, rows, peaks, setup,
plan: cfg.shop.plan, monthCount, monthlyLimit: features.monthlyBookings,
themeEditor: `https://${cfg.shop.domain}/admin/themes/current/editor?context=apps&activateAppId=${process.env.SHOPIFY_API_KEY}/datewise-embed`,
};
};
export default function Dashboard() {
const d = useLoaderData<typeof loader>();
const overLimit = d.monthlyLimit != null && d.monthCount >= d.monthlyLimit;
return (
<s-page heading={`Today, ${niceDate(d.today, true)}`}>
<s-button slot="primary-action" href="/app/bookings">View bookings</s-button>
{!d.setup.anyLocation && (
<s-banner tone="warning" heading="Finish setup to start taking bookings">
<s-paragraph>
1. Review hours and turn on a location in <s-link href="/app/locations">Locations & hours</s-link>. 2. Add the Datewise
block to your cart page and switch on the app embed in the <s-link href={d.themeEditor} target="_blank">theme editor</s-link>.
3. Turn on the checkout guard in <s-link href="/app/settings">Settings</s-link>.
</s-paragraph>
</s-banner>
)}
{overLimit && (
<s-banner tone="warning" heading="You've reached this month's Starter limit">
<s-paragraph>
New orders are still recorded, so no customer is turned away. <s-link href="/app/settings#plan">Upgrade</s-link> to keep reminders and capacity planning running.
</s-paragraph>
</s-banner>
)}
<s-section>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap="base">
<Kpi label="Bookings today" value={d.todayCount} />
<Kpi label="Next 7 days" value={d.weekCount} />
<Kpi label="Needs review" value={d.review} href="/app/bookings?status=review" alert={d.review > 0} />
<Kpi label="On waitlist" value={d.waiting} href="/app/peak" />
</s-grid>
</s-section>
<s-section heading="Capacity, next 14 days">
{d.rows.length === 0 && <s-paragraph>No pickup or delivery locations are switched on yet.</s-paragraph>}
{d.rows.map((r) => (
<s-stack key={r.key} gap="small-200">
<s-text type="strong">{r.label}</s-text>
<div style={{ display: "grid", gridTemplateColumns: "repeat(14, minmax(28px, 1fr))", gap: 4, overflowX: "auto", alignItems: "end" }}>
{r.days.map((x) => {
const pct = x.cap ? Math.min(100, Math.round((100 * x.used) / x.cap)) : 0;
const full = x.cap != null && x.used >= x.cap;
return (
<div key={x.date} title={`${niceDate(x.date)}: ${x.open ? `${x.used}${x.cap != null ? ` of ${x.cap}` : ""} booked` : "closed"}${x.peak ? ` · ${x.peak}` : ""}`} style={{ display: "grid", gap: 2, textAlign: "center", fontSize: 11 }}>
<div style={{ height: 70, background: "var(--s-color-bg-surface-secondary, #f1f1f1)", borderRadius: 6, position: "relative", overflow: "hidden" }}>
{x.open && <div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: `${x.cap ? pct : Math.min(100, x.used * 8)}%`, background: full ? "#c5280c" : x.peak ? "#b98900" : "#008060" }} />}
</div>
<span>{x.open ? (x.cap != null ? `${x.used}/${x.cap}` : x.used) : "—"}</span>
<span style={{ opacity: 0.7 }}>{x.date.slice(8)}</span>
</div>
);
})}
</div>
</s-stack>
))}
</s-section>
<s-section heading="Upcoming festivals">
{d.peaks.length === 0 ? (
<s-paragraph>
No peak days set. Add Diwali, Mother's Day or Valentine's in <s-link href="/app/peak">Rush control</s-link> to cap orders, close earlier and open a waitlist.
</s-paragraph>
) : (
<s-unordered-list>
{d.peaks.map((p) => (
<s-list-item key={p.name + p.date}>
{p.name}, {niceDate(p.date, true)}
{p.cutoff ? `. Orders close ${p.cutoff.daysBefore} day(s) before at ${p.cutoff.time}.` : "."}
</s-list-item>
))}
</s-unordered-list>
)}
</s-section>
</s-page>
);
}
function Kpi({ label, value, href, alert }: { label: string; value: number; href?: string; alert?: boolean }) {
const body = (
<s-box padding="base" border="base" borderRadius="base" background="base">
<s-stack gap="small-200">
<s-text color="subdued">{label}</s-text>
<s-heading>
<span style={{ fontSize: 28, color: alert ? "#c5280c" : undefined }}>{value}</span>
</s-heading>
</s-stack>
</s-box>
);
return href ? <s-clickable href={href}>{body}</s-clickable> : body;
}

View File

@ -0,0 +1,152 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server";
import { adminContext, formBool, formString } from "../lib/admin.server";
import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format";
import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server";
import type { Method } from "../engine/types";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request);
const b = await prisma.booking.findFirst({
where: { id: params.id, shopId: cfg.shop.id },
include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } },
});
if (!b) throw new Response("Not found", { status: 404 });
const options = ["confirmed", "needs_review"].includes(b.status) ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: false }) : [];
return {
b: {
...b,
startUtc: b.startUtc?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
events: b.events.map((e) => ({ ...e, at: e.at.toISOString() })),
reminderJobs: b.reminderJobs.map((j) => ({ id: j.id, channel: j.channel, status: j.status, sendAt: j.sendAt.toISOString(), lastError: j.lastError })),
},
location: cfg.locations.find((l) => l.id === b.locationId)?.name ?? null,
options: options.slice(0, 400),
currency: cfg.shop.currency,
orderUrl: b.orderId ? `shopify://admin/orders/${b.orderId.split("/").pop()}` : null,
};
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { cfg, actor } = await adminContext(request);
const fd = await request.formData();
const intent = formString(fd, "intent");
if (intent === "dismiss") {
await prisma.booking.updateMany({ where: { id: params.id, shopId: cfg.shop.id, status: "confirmed" }, data: { reviewReason: null } });
return { ok: true, message: "Marked as checked" };
}
const [locationId, method, date, slot] = formString(fd, "target").split("|");
if (!slot) return { ok: false, message: "Choose a new time" };
const r = await rescheduleBooking({
domain: cfg.shop.domain,
bookingId: params.id!,
sel: { locationId, method: method as Method, date, slot },
actor,
force: formBool(fd, "force"),
});
return r.ok ? { ok: true, message: "Booking moved. Reminders were re-planned." } : { ok: false, message: r.message };
};
export default function BookingDetail() {
const { b, location, options, currency, orderUrl } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
const addr = (b.address ?? {}) as { line1?: string; city?: string; postal?: string };
const items = (b.items ?? []) as { title: string; quantity: number }[];
return (
<s-page heading={b.orderName ?? "Booking"}>
{orderUrl && <s-button slot="secondary-actions" href={orderUrl}>Open order</s-button>}
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{b.status === "needs_review" && (
<s-banner tone="critical" heading="This order needs a time">
<s-paragraph>{b.reviewReason}. Pick a time below to confirm it, or tick "allow over capacity" if you can fit it in.</s-paragraph>
</s-banner>
)}
{b.status === "confirmed" && b.reviewReason && (
<s-banner tone="warning" heading="Check this order">
<s-stack gap="base">
<s-paragraph>{b.reviewReason}</s-paragraph>
<Form method="post">
<input type="hidden" name="intent" value="dismiss" />
<s-button type="submit">Mark as checked</s-button>
</Form>
</s-stack>
</s-banner>
)}
<s-section heading="Booking">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base">
<Field label="Status"><s-badge tone={STATUS_TONE[b.status] ?? "neutral"}>{b.status.replace("_", " ")}</s-badge></Field>
<Field label="Method">{METHOD_LABEL[b.method]}</Field>
<Field label="Location">{location ?? "—"}</Field>
<Field label="Date">{niceDate(b.date, true)}</Field>
<Field label="Time">{b.method === "shipping" ? "Dispatch day" : slotLabel(b.slotStart, b.slotEnd)}</Field>
<Field label="Fee paid">{money(b.fee, currency)}</Field>
<Field label="Customer">{[b.customerName, b.customerEmail, b.customerPhone].filter(Boolean).join(" · ") || "—"}</Field>
<Field label="Address">{[addr.line1, addr.city, addr.postal].filter(Boolean).join(", ") || "—"}</Field>
<Field label="Items">{items.map((i) => `${i.quantity}× ${i.title}`).join(", ") || "—"}</Field>
<Field label="Rule version">v{b.configVersion}</Field>
</s-grid>
</s-section>
{options.length > 0 && (
<s-section heading={b.status === "needs_review" ? "Choose a time" : "Move to another time"}>
<Form method="post">
<s-stack gap="base">
<s-select label="New time" name="target">
{options.map((o) => (
<s-option key={`${o.date}|${o.slot}`} value={`${o.locationId}|${o.method}|${o.date}|${o.slot}`}>
{`${niceDate(o.date)} · ${o.method === "shipping" ? "dispatch" : slotLabel(o.start, o.end)} · ${o.full ? "full" : o.remaining == null ? "open" : `${o.remaining} left`}`}
</s-option>
))}
</s-select>
<s-checkbox name="force" label="Allow over capacity" details="Only for times marked full. Recorded in the history." />
<s-paragraph>The current place is released only if the new one saves. The customer isn't messaged automatically.</s-paragraph>
<s-button type="submit" variant="primary" loading={busy}>Save new time</s-button>
</s-stack>
</Form>
</s-section>
)}
<s-section heading="Reminders">
{b.reminderJobs.length === 0 ? (
<s-paragraph>No reminders planned.</s-paragraph>
) : (
<s-unordered-list>
{b.reminderJobs.map((j) => (
<s-list-item key={j.id}>
{j.channel} · {new Date(j.sendAt).toLocaleString("en-CA")} · {j.status}
{j.lastError ? ` (${j.lastError})` : ""}
</s-list-item>
))}
</s-unordered-list>
)}
</s-section>
<s-section heading="History">
<s-unordered-list>
{b.events.map((e) => (
<s-list-item key={e.id}>
{new Date(e.at).toLocaleString("en-CA")} · {e.actor} · {e.action}
{e.before ? ` · from ${describe(e.before)}` : ""}
{e.after ? ` · to ${describe(e.after)}` : ""}
</s-list-item>
))}
</s-unordered-list>
</s-section>
</s-page>
);
}
function describe(v: unknown) {
const o = v as Record<string, unknown>;
if (o.date) return `${o.date} ${o.slot ?? ""}`.trim() + (o.forced ? " (over capacity)" : "");
return Object.entries(o).map(([k, x]) => `${k}: ${String(x)}`).join(", ");
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<s-stack gap="small-300">
<s-text color="subdued">{label}</s-text>
<s-text>{children}</s-text>
</s-stack>
);
}

View File

@ -0,0 +1,127 @@
import type { LoaderFunctionArgs } from "react-router";
import { Form, useLoaderData, useSearchParams } from "react-router";
import prisma from "../db.server";
import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server";
import { bookingWhere } from "../lib/bookings-query.server";
import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request);
const sp = new URL(request.url).searchParams;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const page = Math.max(1, Number(sp.get("page") ?? 1));
const where = bookingWhere(cfg.shop.id, sp, today);
const [rows, total] = await Promise.all([
prisma.booking.findMany({
where,
orderBy: [{ status: "desc" }, { date: "asc" }, { slotStart: "asc" }],
take: 50,
skip: (page - 1) * 50,
}),
prisma.booking.count({ where }),
]);
return {
rows: rows.map((b) => ({ ...b, startUtc: b.startUtc?.toISOString() ?? null, endUtc: b.endUtc?.toISOString() ?? null, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString() })),
total,
page,
locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })),
shop: cfg.shop.domain,
};
};
export default function Bookings() {
const { rows, total, page, locations } = useLoaderData<typeof loader>();
const [sp] = useSearchParams();
const locName = (id: string | null) => locations.find((l) => l.id === id)?.name ?? "—";
const exportHref = `/app/bookings/export?${sp.toString()}`;
return (
<s-page heading="Bookings">
<s-button slot="secondary-actions" href={exportHref} target="_blank">Export CSV</s-button>
<s-section>
<Form method="get">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap="base" alignItems="end">
<s-select label="Show" name="status" value={sp.get("status") ?? "upcoming"}>
<s-option value="upcoming">Upcoming</s-option>
<s-option value="review">Needs review</s-option>
<s-option value="confirmed">Confirmed</s-option>
<s-option value="fulfilled">Fulfilled</s-option>
<s-option value="cancelled">Cancelled</s-option>
<s-option value="all">All</s-option>
</s-select>
<s-select label="Method" name="method" value={sp.get("method") ?? "all"}>
<s-option value="all">All methods</s-option>
<s-option value="delivery">Delivery</s-option>
<s-option value="pickup">Pickup</s-option>
<s-option value="shipping">Shipping</s-option>
</s-select>
<s-select label="Location" name="location" value={sp.get("location") ?? "all"}>
<s-option value="all">All locations</s-option>
{locations.map((l) => (
<s-option key={l.id} value={l.id}>{l.name}</s-option>
))}
</s-select>
<s-date-field label="From" name="from" value={sp.get("from") ?? ""} />
<s-date-field label="To" name="to" value={sp.get("to") ?? ""} />
<s-search-field label="Search" name="q" placeholder="Order, name or email" value={sp.get("q") ?? ""} />
<s-button type="submit">Apply</s-button>
</s-grid>
</Form>
</s-section>
<s-section padding="none">
<s-table paginate hasNextPage={page * 50 < total} hasPreviousPage={page > 1}>
<s-table-header-row>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header>Time</s-table-header>
<s-table-header>Method</s-table-header>
<s-table-header>Location</s-table-header>
<s-table-header listSlot="secondary">Customer</s-table-header>
<s-table-header>Status</s-table-header>
</s-table-header-row>
<s-table-body>
{rows.map((b) => (
<s-table-row key={b.id} clickDelegate={`b-${b.id}`}>
<s-table-cell>
<s-link id={`b-${b.id}`} href={`/app/bookings/${b.id}`}>{b.orderName ?? "No order"}</s-link>
</s-table-cell>
<s-table-cell>{niceDate(b.date)}</s-table-cell>
<s-table-cell>{b.method === "shipping" ? "Dispatch" : slotLabel(b.slotStart, b.slotEnd)}</s-table-cell>
<s-table-cell>
{METHOD_LABEL[b.method]} {b.peakDayId && <s-badge tone="warning">Peak</s-badge>}
</s-table-cell>
<s-table-cell>{locName(b.locationId)}</s-table-cell>
<s-table-cell>{b.customerName ?? b.customerEmail ?? "—"}</s-table-cell>
<s-table-cell>
<s-stack gap="small-300">
<s-badge tone={STATUS_TONE[b.status] ?? "neutral"}>{b.status.replace("_", " ")}</s-badge>
{b.reviewReason && <s-text color="subdued">{b.reviewReason}</s-text>}
</s-stack>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
{rows.length === 0 && (
<s-box padding="base">
<s-paragraph>No bookings match these filters.</s-paragraph>
</s-box>
)}
<s-box padding="base">
<s-stack direction="inline" gap="base" alignItems="center">
{page > 1 && <s-link href={`?${withPage(sp, page - 1)}`}>Previous</s-link>}
<s-text color="subdued">{total} bookings · page {page}</s-text>
{page * 50 < total && <s-link href={`?${withPage(sp, page + 1)}`}>Next</s-link>}
</s-stack>
</s-box>
</s-section>
</s-page>
);
}
function withPage(sp: URLSearchParams, p: number) {
const n = new URLSearchParams(sp);
n.set("page", String(p));
return n.toString();
}

View File

@ -0,0 +1,51 @@
import type { LoaderFunctionArgs } from "react-router";
import prisma from "../db.server";
import { toCsv } from "../engine/runsheet";
import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server";
import { bookingWhere } from "../lib/bookings-query.server";
/** Excel-friendly CSV (UTF-8 BOM) of the bookings matching the list filters. */
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request);
const sp = new URL(request.url).searchParams;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const rows = await prisma.booking.findMany({
where: bookingWhere(cfg.shop.id, sp, today),
orderBy: [{ date: "asc" }, { slotStart: "asc" }],
take: 20000,
});
const loc = Object.fromEntries(cfg.locations.map((l) => [l.id, l.name]));
const csv = toCsv(
rows.map((b) => {
const a = (b.address ?? {}) as { line1?: string; city?: string; postal?: string };
return {
order: b.orderName ?? "",
status: b.status,
review_note: b.reviewReason ?? "",
method: b.method,
location: b.locationId ? loc[b.locationId] ?? b.locationId : "",
date: b.date ?? "",
slot_start: b.slotStart ?? "",
slot_end: b.slotEnd ?? "",
timezone: b.timezone,
start_utc: b.startUtc?.toISOString() ?? "",
end_utc: b.endUtc?.toISOString() ?? "",
customer: b.customerName ?? "",
email: b.customerEmail ?? "",
phone: b.customerPhone ?? "",
address: [a.line1, a.city, a.postal].filter(Boolean).join(", "),
items: ((b.items ?? []) as { title: string; quantity: number }[]).map((i) => `${i.quantity}x ${i.title}`).join("; "),
fee: (b.fee / 100).toFixed(2),
rescheduled: b.rescheduleCount,
};
}),
["order", "status", "review_note", "method", "location", "date", "slot_start", "slot_end", "timezone", "start_utc", "end_utc", "customer", "email", "phone", "address", "items", "fee", "rescheduled"],
);
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="datewise-bookings-${today}.csv"`,
},
});
};

View File

@ -0,0 +1,105 @@
import { useState } from "react";
import type { LoaderFunctionArgs } from "react-router";
import { Form, useLoaderData } from "react-router";
import { computeAvailability } from "../engine/availability";
import { evaluateRules } from "../engine/rules";
import { addDays, localParts } from "../engine/time";
import type { Method } from "../engine/types";
import { adminContext } from "../lib/admin.server";
import { niceDate, slotLabel } from "../lib/format";
import { enrichCart } from "../services/catalog.server";
import { reservations } from "../services/storefront.server";
/**
* "Why is this slot unavailable?" — runs the exact checkout engine for a sample cart,
* location, method and date and shows every reason with the rule that caused it.
*/
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, admin } = await adminContext(request);
const sp = new URL(request.url).searchParams;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const method = (sp.get("method") as Method) || "delivery";
const loc = cfg.locations.find((l) => l.id === sp.get("location")) ?? cfg.locations[0];
const date = sp.get("date") || addDays(today, 1);
const variants = (sp.get("variants") ?? "").split(",").filter(Boolean);
const base = { locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), method, locationId: loc?.id ?? "", date, variants };
if (!loc) return { ...base, result: null };
const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id]));
const { cart } = await enrichCart(admin, cfg.shop.domain, variants.map((v) => ({ variantId: v, quantity: 1 })), s2a);
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date());
const cons = evaluateRules(cart, cfg.rules, cfg.settings.mixedPrepPolicy);
const av = computeAvailability({ location: loc, method, cart, rules: cfg.rules, peakDays: cfg.peakDays, usage, settings: cfg.settings, now: Date.now(), constraints: cons });
const d = av.dates.find((x) => x.date === date);
return {
...base,
result: {
items: cart.items.map((i) => i.title),
matched: cons.matched,
prep: av.prepMinutes,
prepDriver: cons.prepDriver ?? null,
blockers: av.blockers,
horizon: loc.methods[method]?.horizonDays ?? null,
day: d ? { reasons: d.reasons, peak: d.peak, slots: d.slots.map((s) => ({ id: s.id, start: s.start, end: s.end, available: s.available, remaining: s.remaining, reasons: s.reasons })) } : null,
dateOnly: av.dateOnly,
},
};
};
export default function Diagnostics() {
const d = useLoaderData<typeof loader>();
const [variants, setVariants] = useState<{ id: string; title: string }[]>(d.variants.map((id) => ({ id, title: id.split("/").pop()! })));
const pick = async () => {
const sel = (await shopify.resourcePicker({ type: "variant", multiple: true, action: "select" })) as unknown as { id: string; displayName?: string; title?: string }[] | undefined;
if (sel) setVariants(sel.map((v) => ({ id: v.id, title: v.displayName ?? v.title ?? v.id })));
};
const r = d.result;
return (
<s-page heading="Why is a time unavailable?">
<s-section>
<Form method="get">
<input type="hidden" name="variants" value={variants.map((v) => v.id).join(",")} />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base" alignItems="end">
<s-select name="method" label="Method" value={d.method}>
<s-option value="delivery">Delivery</s-option>
<s-option value="pickup">Pickup</s-option>
<s-option value="shipping">Shipping</s-option>
</s-select>
<s-select name="location" label="Location" value={d.locationId}>
{d.locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
</s-select>
<s-date-field name="date" label="Date" value={d.date} />
</s-grid>
<s-stack direction="inline" gap="base" alignItems="center">
<s-button onClick={pick}>Choose products for the test cart</s-button>
<s-text color="subdued">{variants.length ? variants.map((v) => v.title).join(", ") : "Empty cart (only hours, closures and capacity apply)"}</s-text>
</s-stack>
<s-button type="submit" variant="primary">Check</s-button>
</s-stack>
</Form>
</s-section>
{r && (
<s-section heading={niceDate(d.date, true)}>
<s-stack gap="base">
<s-paragraph>
Rules matched: {r.matched.length ? r.matched.map((m) => `${m.ruleName} (${m.itemTitle})`).join("; ") : "none"}. Preparation used: {Math.round(r.prep / 6) / 10} h
{r.prepDriver ? `, set by ${r.prepDriver.itemTitle}` : ""}.
</s-paragraph>
{r.blockers.map((b, i) => <s-banner key={i} tone="critical" heading={b.code}>{b.message}{b.ruleId ? ` (rule ${b.ruleId})` : ""}</s-banner>)}
{!r.blockers.length && !r.day && <s-banner tone="warning" heading="BEYOND_HORIZON">This date is outside the {r.horizon}-day booking window, or in the past.</s-banner>}
{r.day?.reasons.map((x, i) => <s-banner key={i} tone="warning" heading={x.code}>{x.message}{x.ruleId ? ` (rule ${x.ruleId})` : ""}</s-banner>)}
{r.day?.slots.map((s) => (
<s-box key={s.id} padding="small" border="base" borderRadius="base" background={s.available ? "base" : "subdued"}>
<s-stack direction="inline" gap="base" alignItems="center">
<s-badge tone={s.available ? "success" : "critical"}>{s.available ? "Available" : "Blocked"}</s-badge>
<s-text type="strong">{r.dateOnly ? "Dispatch" : slotLabel(s.start, s.end)}</s-text>
<s-text color="subdued">{s.available ? (s.remaining == null ? "no limit" : `${s.remaining} left`) : s.reasons.map((x) => `${x.code}: ${x.message}`).join("; ")}</s-text>
</s-stack>
</s-box>
))}
</s-stack>
</s-section>
)}
</s-page>
);
}

View File

@ -0,0 +1,162 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData } from "react-router";
import prisma from "../db.server";
import { checkFreshness, evaluateRules } from "../engine/rules";
import { addDays, localParts } from "../engine/time";
import type { RuleAction } from "../engine/types";
import { adminContext, formInt, formList, formString } from "../lib/admin.server";
import { niceDate } from "../lib/format";
import { bumpConfig } from "../services/config.server";
import { RuleActionSchema, RuleTargetSchema } from "../services/settings";
type Fresh = Extract<RuleAction, { type: "freshness" }>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features } = await adminContext(request);
const today = localParts(Date.now(), cfg.shop.timezone).date;
const rules = cfg.rules
.filter((r) => r.action.type === "freshness")
.map((r) => {
const a = r.action as Fresh;
// Preview which of the next 28 days this product could be fulfilled.
const cons = evaluateRules({ items: [{ productId: "x", variantId: "x", title: r.name, quantity: 1, price: 0, tags: ["__preview"] }] }, [
{ ...r, enabled: true, target: { type: "tag", values: ["__preview"] } },
]);
const strip = Array.from({ length: 28 }, (_, i) => {
const d = addDays(today, i);
return { d, ok: !checkFreshness(cons.freshness, d, cfg.locations[0]?.id ?? "") };
});
return { ...r, a, strip };
});
return { rules, allowed: features.freshness, locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })) };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg, features } = await adminContext(request);
if (!features.freshness) return { ok: false, message: "Fresh-stock windows are on the Growth plan" };
const fd = await request.formData();
const intent = formString(fd, "intent");
const find = async (id: string) => prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } });
if (intent === "create") {
const target = RuleTargetSchema.safeParse({ type: formString(fd, "targetType"), values: formList(fd, "targetValues") });
const act = RuleActionSchema.safeParse({
type: "freshness",
arrivals: formList(fd, "arrivals").map((date) => ({ date })),
minDaysAfterArrival: formInt(fd, "minDays") ?? 1,
shelfLifeDays: formInt(fd, "shelfLife") ?? 4,
});
if (!target.success || !act.success) return { ok: false, message: "Check the product match and the dates (YYYY-MM-DD)" };
await prisma.productRule.create({
data: { shopId: cfg.shop.id, name: formString(fd, "name") || "Fresh stock", priority: 5, target: target.data, action: act.data, message: formString(fd, "message") || null },
});
} else {
const [op, id, extra] = intent.split(":");
const r = await find(id);
if (!r) return { ok: false, message: "Not found" };
const a = r.action as unknown as Fresh;
if (op === "add") {
const date = formString(fd, `arrival_${id}`);
const locs = formString(fd, `arrivalLoc_${id}`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return { ok: false, message: "Pick an arrival date" };
a.arrivals = [...a.arrivals, { date, ...(locs ? { locationIds: [locs] } : {}) }].sort((x, y) => (x.date < y.date ? -1 : 1));
}
if (op === "remove") a.arrivals = a.arrivals.filter((_, i) => i !== Number(extra));
if (op === "window") {
a.minDaysAfterArrival = formInt(fd, `min_${id}`) ?? a.minDaysAfterArrival;
a.shelfLifeDays = formInt(fd, `shelf_${id}`) ?? a.shelfLifeDays;
}
if (op === "delete") {
await prisma.productRule.delete({ where: { id } });
await bumpConfig(cfg.shop.id, cfg.shop.domain);
return { ok: true, message: "Removed" };
}
await prisma.productRule.update({ where: { id }, data: { action: a as object } });
}
await bumpConfig(cfg.shop.id, cfg.shop.domain);
return { ok: true, message: "Saved" };
};
export default function Freshness() {
const { rules, allowed, locations } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
return (
<s-page heading="Fresh stock">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{!allowed && <s-banner tone="info" heading="Growth plan feature">Tie bookable dates to when perishable stock arrives. <s-link href="/app/settings#plan">See plans</s-link></s-banner>}
<s-section>
<s-paragraph>
For perishables like flowers, produce and sweets: customers can only pick dates from arrival + "ready after" days up to arrival + shelf life. If no batch covers a date, the calendar tells the customer when the next fresh batch is ready.
</s-paragraph>
</s-section>
{rules.map((r) => (
<s-section key={r.id} heading={r.name}>
<s-stack gap="base">
<s-text color="subdued">Applies to {r.target.type}: {r.target.values.join(", ")}</s-text>
<s-stack direction="inline" gap="small-200">
{r.a.arrivals.map((x, i) => (
<Form method="post" key={`${x.date}-${i}`}>
<input type="hidden" name="intent" value={`remove:${r.id}:${i}`} />
<s-box padding="small-200" border="base" borderRadius="large">
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-text>{niceDate(x.date)}{x.locationIds?.length ? ` · ${locations.find((l) => l.id === x.locationIds![0])?.name ?? ""}` : ""}</s-text>
<s-button type="submit" variant="tertiary" accessibilityLabel={`Remove arrival ${x.date}`}>×</s-button>
</s-stack>
</s-box>
</Form>
))}
</s-stack>
<Form method="post">
<input type="hidden" name="intent" value={`add:${r.id}`} />
<s-stack direction="inline" gap="base" alignItems="end">
<s-date-field name={`arrival_${r.id}`} label="Stock arrives" />
<s-select name={`arrivalLoc_${r.id}`} label="At">
<s-option value="">All locations</s-option>
{locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
</s-select>
<s-button type="submit">Add arrival</s-button>
</s-stack>
</Form>
<Form method="post">
<input type="hidden" name="intent" value={`window:${r.id}`} />
<s-stack direction="inline" gap="base" alignItems="end">
<s-number-field name={`min_${r.id}`} label="Ready after (days)" defaultValue={String(r.a.minDaysAfterArrival)} min={0} />
<s-number-field name={`shelf_${r.id}`} label="Shelf life (days)" defaultValue={String(r.a.shelfLifeDays)} min={1} />
<s-button type="submit">Update window</s-button>
</s-stack>
</Form>
<s-text color="subdued">Bookable in the next 28 days:</s-text>
<div style={{ display: "grid", gridTemplateColumns: "repeat(28, minmax(22px, 1fr))", gap: 3, overflowX: "auto" }}>
{r.strip.map((s) => (
<div key={s.d} title={niceDate(s.d)} style={{ textAlign: "center", fontSize: 11, padding: "4px 0", borderRadius: 5, background: s.ok ? "#cdfee1" : "#f1f1f1", color: s.ok ? "#0c5132" : "#8a8a8a" }}>
{s.d.slice(8)}
</div>
))}
</div>
<Form method="post"><input type="hidden" name="intent" value={`delete:${r.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Delete this rule</s-button></Form>
</s-stack>
</s-section>
))}
<s-section heading="Track a perishable product">
<Form method="post">
<input type="hidden" name="intent" value="create" />
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(190px, 1fr))" gap="base" alignItems="end">
<s-text-field name="name" label="Name" placeholder="Marigold air shipments" />
<s-select name="targetType" label="Match by">
<s-option value="tag">Product tag</s-option>
<s-option value="productType">Product type</s-option>
<s-option value="collection">Collection ID</s-option>
<s-option value="product">Product ID</s-option>
</s-select>
<s-text-field name="targetValues" label="Values" placeholder="fresh-flowers" required />
<s-text-field name="arrivals" label="Arrival dates" placeholder="2026-11-03, 2026-11-05" />
<s-number-field name="minDays" label="Ready after (days)" defaultValue="1" min={0} />
<s-number-field name="shelfLife" label="Shelf life (days)" defaultValue="4" min={1} />
<s-text-field name="message" label="Customer message (optional)" />
<s-button type="submit" variant="primary" disabled={!allowed}>Add</s-button>
</s-grid>
</Form>
</s-section>
</s-page>
);
}

View File

@ -0,0 +1,338 @@
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");
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>
);
}

View File

@ -0,0 +1,90 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { redirect, useLoaderData } from "react-router";
import { Form } from "react-router";
import prisma from "../db.server";
import { METHODS } from "../engine/types";
import { adminContext } from "../lib/admin.server";
import { METHOD_LABEL } from "../lib/format";
import { bumpConfig } from "../services/config.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features } = await adminContext(request);
return {
locations: cfg.locations.map((l) => ({
id: l.id,
name: l.name,
address: l.address,
enabled: l.enabled,
priority: l.priority,
methods: METHODS.filter((m) => l.methods[m]?.enabled),
zones: l.zones.length,
})),
maxLocations: features.maxLocations,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg, features } = await adminContext(request);
if (cfg.locations.length >= features.maxLocations) return { error: `Your plan includes ${features.maxLocations} location(s). Upgrade in Settings to add more.` };
const hours = Object.fromEntries([1, 2, 3, 4, 5, 6].map((d) => [d, [{ start: "10:00", end: "18:00" }]]));
const loc = await prisma.location.create({
data: {
shopId: cfg.shop.id,
name: "New location",
address: "",
timezone: cfg.shop.timezone,
enabled: false,
priority: cfg.locations.length + 1,
methods: {
pickup: { enabled: true, weeklyHours: hours, slotMode: "generated", slotMinutes: 60, prepMinutes: 120, prepMode: "operating", sameDayCutoff: "14:00", horizonDays: 30, slotCapacity: 5 },
},
},
});
await bumpConfig(cfg.shop.id, cfg.shop.domain);
return redirect(`/app/locations/${loc.id}`);
};
export default function Locations() {
const { locations, maxLocations } = useLoaderData<typeof loader>();
return (
<s-page heading="Locations & hours">
<Form method="post" slot="primary-action">
<s-button type="submit" variant="primary" disabled={locations.length >= maxLocations}>Add location</s-button>
</Form>
<s-section padding="none">
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Location</s-table-header>
<s-table-header>Offers</s-table-header>
<s-table-header>Delivery zones</s-table-header>
<s-table-header>Priority</s-table-header>
<s-table-header>Status</s-table-header>
</s-table-header-row>
<s-table-body>
{locations.map((l) => (
<s-table-row key={l.id} clickDelegate={`l-${l.id}`}>
<s-table-cell>
<s-stack gap="small-300">
<s-link id={`l-${l.id}`} href={`/app/locations/${l.id}`}>{l.name}</s-link>
<s-text color="subdued">{l.address || "No address yet"}</s-text>
</s-stack>
</s-table-cell>
<s-table-cell>{l.methods.map((m) => METHOD_LABEL[m]).join(", ") || "Nothing yet"}</s-table-cell>
<s-table-cell>{l.zones}</s-table-cell>
<s-table-cell>{l.priority}</s-table-cell>
<s-table-cell>
<s-badge tone={l.enabled ? "success" : "neutral"}>{l.enabled ? "Taking bookings" : "Off"}</s-badge>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
</s-section>
<s-section>
<s-paragraph>
Locations were imported from Shopify and start switched off. Check the hours, then turn each one on. When several locations can take an order, customers are offered them in priority order (1 first).
</s-paragraph>
</s-section>
</s-page>
);
}

151
app/routes/app.peak.tsx Normal file
View File

@ -0,0 +1,151 @@
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>
);
}

186
app/routes/app.rules.tsx Normal file
View File

@ -0,0 +1,186 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server";
import type { RuleAction } from "../engine/types";
import { METHODS } from "../engine/types";
import { adminContext, formBool, formInt, formList, formString } from "../lib/admin.server";
import { METHOD_LABEL } from "../lib/format";
import { bumpConfig } from "../services/config.server";
import { RuleActionSchema, RuleTargetSchema } from "../services/settings";
const TARGETS = [
["productType", "Product type"],
["tag", "Product tag"],
["collection", "Collection ID"],
["vendor", "Vendor"],
["product", "Product ID"],
["variant", "Variant ID"],
] as const;
const ACTIONS = [
["prohibitMethods", "Block methods"],
["allowOnlyMethods", "Only allow methods"],
["addPrep", "Add preparation time"],
["restrictLocations", "Only from locations"],
["blockWeekdays", "Block weekdays"],
["blockDates", "Block dates"],
["dateWindow", "Only between dates"],
["requireScheduling", "Always require a time"],
] as const;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg } = await adminContext(request);
return {
rules: cfg.rules.filter((r) => r.action.type !== "freshness").sort((a, b) => a.priority - b.priority),
locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })),
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg } = await adminContext(request);
const fd = await request.formData();
const intent = formString(fd, "intent");
const own = async (id: string) => !!(await prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } }));
if (intent.startsWith("toggle:") || intent.startsWith("delete:")) {
const id = intent.split(":")[1];
if (!(await own(id))) return { ok: false, message: "Rule not found" };
if (intent.startsWith("delete:")) await prisma.productRule.delete({ where: { id } });
else {
const r = await prisma.productRule.findUniqueOrThrow({ where: { id } });
await prisma.productRule.update({ where: { id }, data: { enabled: !r.enabled } });
}
await bumpConfig(cfg.shop.id, cfg.shop.domain);
return { ok: true, message: "Rule updated" };
}
const target = { type: formString(fd, "targetType"), values: formList(fd, "targetValues") };
const type = formString(fd, "actionType");
const methods = METHODS.filter((m) => formBool(fd, `m_${m}`));
let action: unknown = { type };
if (type === "prohibitMethods" || type === "allowOnlyMethods") action = { type, methods };
if (type === "addPrep") action = { type, minutes: Math.round((Number(formString(fd, "prepHours")) || 0) * 60) };
if (type === "restrictLocations") action = { type, locationIds: cfg.locations.filter((l) => formBool(fd, `loc_${l.id}`)).map((l) => l.id) };
if (type === "blockWeekdays") action = { type, weekdays: [0, 1, 2, 3, 4, 5, 6].filter((d) => formBool(fd, `wd_${d}`)) };
if (type === "blockDates") action = { type, dates: formList(fd, "dates") };
if (type === "dateWindow") action = { type, from: formString(fd, "from") || undefined, to: formString(fd, "to") || undefined };
const t = RuleTargetSchema.safeParse(target);
const a = RuleActionSchema.safeParse(action);
if (!t.success) return { ok: false, message: "Add at least one value the rule applies to" };
if (!a.success) return { ok: false, message: `Rule settings: ${a.error.issues.map((i) => i.message).join("; ")}` };
await prisma.productRule.create({
data: {
shopId: cfg.shop.id,
name: formString(fd, "name") || describe(a.data as RuleAction),
priority: formInt(fd, "priority") ?? 10,
target: t.data,
action: a.data,
message: formString(fd, "message") || null,
},
});
await bumpConfig(cfg.shop.id, cfg.shop.domain);
return { ok: true, message: "Rule added" };
};
function describe(a: RuleAction): string {
switch (a.type) {
case "prohibitMethods": return `No ${a.methods.join(" or ")}`;
case "allowOnlyMethods": return `Only ${a.methods.join(" or ")}`;
case "addPrep": return `+${(a.minutes / 60).toFixed(a.minutes % 60 ? 1 : 0)} h preparation`;
case "restrictLocations": return `Only ${a.locationIds.length} location(s)`;
case "blockWeekdays": return `Not on ${a.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(", ")}`;
case "blockDates": return `Blocked on ${a.dates.length} date(s)`;
case "dateWindow": return `Only ${a.from ?? "…"} to ${a.to ?? "…"}`;
case "requireScheduling": return "Time required";
case "freshness": return "Fresh stock window";
}
}
export default function Rules() {
const { rules, locations } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
return (
<s-page heading="Product rules">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
<s-section>
<s-paragraph>
Rules apply to items in the cart. When rules disagree, a block beats an allowance, and a mixed cart uses its longest preparation time. Customers see the message you write, so they know which item caused it.
</s-paragraph>
</s-section>
<s-section padding="none">
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Rule</s-table-header>
<s-table-header>Applies to</s-table-header>
<s-table-header>Customer message</s-table-header>
<s-table-header>Status</s-table-header>
<s-table-header>Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{rules.map((r) => (
<s-table-row key={r.id}>
<s-table-cell>{r.name}</s-table-cell>
<s-table-cell>{TARGETS.find((t) => t[0] === r.target.type)?.[1]}: {r.target.values.join(", ")}</s-table-cell>
<s-table-cell>{r.message ?? "—"}</s-table-cell>
<s-table-cell><s-badge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "On" : "Off"}</s-badge></s-table-cell>
<s-table-cell>
<s-stack direction="inline" gap="small-200">
<Form method="post"><input type="hidden" name="intent" value={`toggle:${r.id}`} /><s-button type="submit" variant="tertiary">{r.enabled ? "Turn off" : "Turn on"}</s-button></Form>
<Form method="post"><input type="hidden" name="intent" value={`delete:${r.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Delete</s-button></Form>
</s-stack>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
{rules.length === 0 && <s-box padding="base"><s-paragraph>No rules yet.</s-paragraph></s-box>}
</s-section>
<s-section heading="Add a rule">
<Form method="post">
<input type="hidden" name="intent" value="create" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap="base">
<s-select name="targetType" label="When the cart has an item with">
{TARGETS.map(([v, l]) => <s-option key={v} value={v}>{l}</s-option>)}
</s-select>
<s-text-field name="targetValues" label="Matching any of" placeholder="Fresh Flowers, perishable" required />
<s-select name="actionType" label="Then">
{ACTIONS.map(([v, l]) => <s-option key={v} value={v}>{l}</s-option>)}
</s-select>
<s-text-field name="name" label="Rule name (optional)" />
</s-grid>
<s-box padding="base" background="subdued" borderRadius="base">
<s-stack gap="base">
<s-text color="subdued">Fill in only what your chosen action needs.</s-text>
<s-stack direction="inline" gap="base">
<s-text>Methods:</s-text>
{METHODS.map((m) => <s-checkbox key={m} name={`m_${m}`} label={METHOD_LABEL[m]} />)}
</s-stack>
<s-stack direction="inline" gap="base">
<s-text>Locations:</s-text>
{locations.map((l) => <s-checkbox key={l.id} name={`loc_${l.id}`} label={l.name} />)}
</s-stack>
<s-stack direction="inline" gap="base">
<s-text>Weekdays:</s-text>
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d, i) => <s-checkbox key={d} name={`wd_${i}`} label={d} />)}
</s-stack>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(170px, 1fr))" gap="base">
<s-number-field name="prepHours" label="Extra preparation (hours)" min={0} step={0.5} />
<s-text-field name="dates" label="Dates" placeholder="2026-12-25, 2026-12-26" />
<s-date-field name="from" label="From" />
<s-date-field name="to" label="To" />
</s-grid>
</s-stack>
</s-box>
<s-grid gridTemplateColumns="3fr 1fr" gap="base">
<s-text-field name="message" label="Message customers see" placeholder="Fresh flowers are pickup or local delivery only" />
<s-number-field name="priority" label="Order" defaultValue="10" details="Lower shows first" />
</s-grid>
<s-button type="submit" variant="primary" loading={busy}>Add rule</s-button>
</s-stack>
</Form>
</s-section>
</s-page>
);
}

112
app/routes/app.runsheet.tsx Normal file
View File

@ -0,0 +1,112 @@
import type { LoaderFunctionArgs } from "react-router";
import { Form, useLoaderData } from "react-router";
import prisma from "../db.server";
import { buildRunSheet, toCsv } from "../engine/runsheet";
import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server";
import { niceDate, slotLabel } from "../lib/format";
import { rowToBooking } from "../services/reminders.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features } = await adminContext(request);
const sp = new URL(request.url).searchParams;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const date = sp.get("date") || today;
const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled);
const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0];
if (!loc) return { empty: true as const };
const rows = await prisma.booking.findMany({ where: { shopId: cfg.shop.id, locationId: loc.id, date, method: "delivery", status: "confirmed" } });
const sheet = buildRunSheet(rows.map(rowToBooking), loc, date);
const csv = toCsv(
sheet.stops.map((s) => ({
stop: s.seq,
window: `${s.booking.slotStart}-${s.booking.slotEnd}`,
order: s.booking.orderName ?? "",
customer: s.booking.customer?.name ?? "",
phone: s.booking.customer?.phone ?? "",
address: [s.booking.address?.line1, s.booking.address?.city, s.booking.address?.postal].filter(Boolean).join(", "),
items: (s.booking.items ?? []).map((i) => `${i.quantity}x ${i.title}`).join("; "),
leg_km: s.legKm?.toFixed(1) ?? "",
})),
);
if (sp.get("format") === "csv" && features.runSheets)
return new Response(csv, {
headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="run-sheet-${date}.csv"` },
}) as never;
return {
empty: false as const,
allowed: features.runSheets,
date,
locationId: loc.id,
locations: delivering.map((l) => ({ id: l.id, name: l.name })),
sheet: { ...sheet, stops: sheet.stops.map((s) => ({ seq: s.seq, legKm: s.legKm, window: s.window, b: s.booking })) },
csvHref: `/app/runsheet?date=${date}&location=${loc.id}&format=csv`,
};
};
export default function RunSheet() {
const d = useLoaderData<typeof loader>();
if (d.empty)
return (
<s-page heading="Driver run sheet">
<s-section><s-paragraph>Turn on local delivery for a location to build run sheets.</s-paragraph></s-section>
</s-page>
);
const { sheet } = d;
return (
<s-page heading={`Run sheet · ${niceDate(d.date, true)}`}>
{d.allowed && <s-button slot="secondary-actions" href={d.csvHref} target="_blank">Download CSV</s-button>}
{!d.allowed && <s-banner tone="info" heading="Growth plan feature">Run sheets and route links. <s-link href="/app/settings#plan">See plans</s-link></s-banner>}
<s-section>
<Form method="get">
<s-stack direction="inline" gap="base" alignItems="end">
<s-date-field name="date" label="Date" value={d.date} />
<s-select name="location" label="From" value={d.locationId}>
{d.locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
</s-select>
<s-button type="submit">Show</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading={`${sheet.stops.length} stops · about ${sheet.totalKm ?? 0} km`}>
<s-stack gap="base">
<s-paragraph>Stops are grouped by promised window, then ordered nearest-first. Distances are straight-line estimates; open a leg in Google Maps for turn-by-turn directions.</s-paragraph>
{d.allowed && (
<s-stack direction="inline" gap="base">
{sheet.mapsUrls.map((u, i) => <s-button key={u} href={u} target="_blank">Open leg {i + 1} in Google Maps</s-button>)}
</s-stack>
)}
{sheet.unlocated.length > 0 && (
<s-banner tone="warning">{sheet.unlocated.length} order(s) have no map position (checkout didn't geocode the address): {sheet.unlocated.map((b) => b.orderName).join(", ")}. Add them by hand.</s-banner>
)}
</s-stack>
</s-section>
<s-section padding="none">
<s-table>
<s-table-header-row>
<s-table-header>#</s-table-header>
<s-table-header>Window</s-table-header>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Customer</s-table-header>
<s-table-header>Address</s-table-header>
<s-table-header format="numeric">Leg</s-table-header>
<s-table-header>Items</s-table-header>
</s-table-header-row>
<s-table-body>
{sheet.stops.map((s) => (
<s-table-row key={s.b.id}>
<s-table-cell>{s.seq}</s-table-cell>
<s-table-cell>{slotLabel(s.b.slotStart, s.b.slotEnd)}</s-table-cell>
<s-table-cell><s-link href={`/app/bookings/${s.b.id}`}>{s.b.orderName}</s-link></s-table-cell>
<s-table-cell>{[s.b.customer?.name, s.b.customer?.phone].filter(Boolean).join(" · ")}</s-table-cell>
<s-table-cell>{[s.b.address?.line1, s.b.address?.postal].filter(Boolean).join(", ")}</s-table-cell>
<s-table-cell>{s.legKm != null ? `${s.legKm.toFixed(1)} km` : "—"}</s-table-cell>
<s-table-cell>{(s.b.items ?? []).map((i) => `${i.quantity}× ${i.title}`).join(", ")}</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
</s-section>
</s-page>
);
}

336
app/routes/app.settings.tsx Normal file
View File

@ -0,0 +1,336 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server";
import type { ReminderPolicy } from "../engine/types";
import { adminContext, formBool, formInt, formString } from "../lib/admin.server";
import { PLAN_GROWTH, PLAN_PRO } from "../shopify.server";
import { bumpConfig } from "../services/config.server";
import { encrypt } from "../services/crypto.server";
import { PLAN_COPY } from "../services/plans";
import { type AppSettings, withDefaults } from "../services/settings";
const GUARD_HANDLE = "datewise-checkout-guard";
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>) {
const r = await admin.graphql(q, { variables });
return ((await r.json()) as { data: T }).data;
}
async function findGuard(admin: AdminGraphql) {
const d = await gql<{
shopifyFunctions: { nodes: { id: string; title: string; apiType: string }[] };
validations: { nodes: { id: string; enabled: boolean; shopifyFunction: { id: string } }[] };
}>(admin, `#graphql
query {
shopifyFunctions(first: 50) { nodes { id title apiType } }
validations(first: 25) { nodes { id enabled shopifyFunction { id } } }
}`);
const fn = d.shopifyFunctions.nodes.find((f) => f.title === GUARD_HANDLE || /datewise/i.test(f.title));
const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined;
return { fn, validation };
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, admin, billing } = await adminContext(request);
// Keep the stored plan in sync with Shopify billing.
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free";
if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } });
const guard = await findGuard(admin).catch(() => ({ fn: undefined, validation: undefined }));
const s = cfg.settings;
return {
plan,
s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } },
guard: { deployed: !!guard.fn, enabled: !!guard.validation?.enabled },
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg, admin, billing, features } = await adminContext(request);
const fd = await request.formData();
const intent = formString(fd, "intent");
const s: AppSettings = withDefaults(cfg.shop.settings);
const persist = async (next: AppSettings) => {
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { settings: next as object } });
await bumpConfig(cfg.shop.id, cfg.shop.domain);
};
if (intent.startsWith("plan:")) {
const plan = intent.slice(5);
if (plan === "free") {
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest: process.env.NODE_ENV !== "production", prorate: true });
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } });
return { ok: true, message: "Switched to Starter" };
}
// Redirects the merchant to Shopify's approval page.
await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest: process.env.NODE_ENV !== "production", returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` });
}
if (intent === "guard") {
const { fn, validation } = await findGuard(admin);
if (!fn) return { ok: false, message: "The checkout guard isn't deployed yet. Run `shopify app deploy`." };
const metafields = [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }];
const turnOn = formBool(fd, "on");
if (validation) {
await gql(admin, `#graphql
mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, {
id: validation.id,
v: { enable: turnOn, blockOnFailure: false, metafields },
});
} else if (turnOn) {
await gql(admin, `#graphql
mutation($v: ValidationCreateInput!) { validationCreate(validation: $v) { userErrors { message } } }`, {
v: { functionHandle: GUARD_HANDLE, title: "Datewise checkout guard", enable: true, blockOnFailure: false, metafields },
});
}
return { ok: true, message: turnOn ? "Checkout guard is on" : "Checkout guard is off" };
}
if (intent === "policy") {
s.holdMinutes = Math.min(60, Math.max(3, formInt(fd, "holdMinutes") ?? 10));
s.mixedPrepPolicy = formString(fd, "mixedPrepPolicy") === "sum" ? "sum" : "longest";
s.requireScheduling = formBool(fd, "requireScheduling");
s.releaseOnCancel = formBool(fd, "releaseOnCancel");
s.waitlistOfferMinutes = formInt(fd, "waitlistOfferMinutes") ?? 30;
s.locationRanking = (formString(fd, "locationRanking") as AppSettings["locationRanking"]) || "priority";
s.weekStartsOn = (formInt(fd, "weekStartsOn") ?? 0) as AppSettings["weekStartsOn"];
s.reschedule = {
enabled: formBool(fd, "rsEnabled"),
minHoursBefore: formInt(fd, "rsHours") ?? 24,
maxChanges: formInt(fd, "rsMax") ?? 2,
allowMethodChange: false,
};
await persist(s);
// Keep the checkout function's copy of the setting in sync.
const { validation } = await findGuard(admin).catch(() => ({ validation: undefined }));
if (validation)
await gql(admin, `#graphql
mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, {
id: validation.id,
v: { metafields: [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }] },
});
return { ok: true, message: "Policies saved" };
}
if (intent === "widget") {
let translations = s.widget.translations;
const raw = formString(fd, "translations");
try {
translations = raw ? JSON.parse(raw) : {};
} catch {
return { ok: false, message: "Translations must be valid JSON, e.g. {\"fr\": {\"heading\": \"Quand ?\"}}" };
}
s.widget = {
...s.widget,
heading: formString(fd, "heading") || s.widget.heading,
labels: { pickup: formString(fd, "lp") || "Store pickup", delivery: formString(fd, "ld") || "Local delivery", shipping: formString(fd, "ls") || "Shipping" },
accent: /^#[0-9a-f]{6}$/i.test(formString(fd, "accent")) ? formString(fd, "accent") : s.widget.accent,
showRemaining: formBool(fd, "showRemaining"),
drawer: formBool(fd, "drawer"),
translations,
};
await persist(s);
return { ok: true, message: "Widget saved" };
}
if (intent === "reminders") {
const list: ReminderPolicy[] = s.reminders.map((r) => ({
...r,
enabled: formBool(fd, `en_${r.id}`),
minutesBefore: Math.round((Number(formString(fd, `hrs_${r.id}`)) || r.minutesBefore / 60) * 60),
template: formString(fd, `tpl_${r.id}`) || r.template,
}));
const newTpl = formString(fd, "new_tpl");
if (newTpl) {
const channel = formString(fd, "new_channel") as ReminderPolicy["channel"];
if (channel !== "email" && !features.smsWhatsapp) return { ok: false, message: "SMS and WhatsApp reminders are on the Pro plan" };
list.push({ id: `r${Date.now()}`, channel, minutesBefore: Math.round((Number(formString(fd, "new_hrs")) || 24) * 60), template: newTpl, enabled: true });
}
s.reminders = list.filter((r) => !formBool(fd, `del_${r.id}`));
await persist(s);
return { ok: true, message: "Reminders saved. New bookings use them; existing bookings keep their plan until changed." };
}
if (intent === "messaging") {
s.messaging.emailFromName = formString(fd, "fromName");
s.messaging.replyTo = formString(fd, "replyTo") || undefined;
const sid = formString(fd, "twSid");
const tok = formString(fd, "twToken");
if (formBool(fd, "twClear")) s.messaging.twilio = null;
else if (sid && formString(fd, "twFrom")) s.messaging.twilio = { accountSid: sid, from: formString(fd, "twFrom"), authTokenEnc: tok ? encrypt(tok) : s.messaging.twilio?.authTokenEnc ?? "" };
const pid = formString(fd, "waPhone");
const wtok = formString(fd, "waToken");
if (formBool(fd, "waClear")) s.messaging.whatsapp = null;
else if (pid && formString(fd, "waTemplate"))
s.messaging.whatsapp = { phoneNumberId: pid, templateName: formString(fd, "waTemplate"), templateLang: formString(fd, "waLang") || "en", tokenEnc: wtok ? encrypt(wtok) : s.messaging.whatsapp?.tokenEnc ?? "" };
await persist(s);
return { ok: true, message: "Messaging saved. Tokens are stored encrypted." };
}
return { ok: false, message: "Unknown action" };
};
export default function Settings() {
const { plan, s, guard } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
return (
<s-page heading="Settings">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
<s-section heading="Checkout guard">
<s-stack gap="base">
<s-paragraph>
Some shoppers skip the cart (Buy now, Shop Pay, Apple Pay). The checkout guard stops checkout when a time is required but missing, when the chosen date has passed, or when the delivery fee in the cart doesn't match the quote. It works on every Shopify plan.
</s-paragraph>
<s-stack direction="inline" gap="base" alignItems="center">
<s-badge tone={guard.enabled ? "success" : "warning"}>{guard.enabled ? "On" : guard.deployed ? "Off" : "Not deployed"}</s-badge>
<Form method="post">
<input type="hidden" name="intent" value="guard" />
<input type="hidden" name="on" value={guard.enabled ? "false" : "true"} />
<s-button type="submit" disabled={!guard.deployed}>{guard.enabled ? "Turn off" : "Turn on"}</s-button>
</Form>
</s-stack>
</s-stack>
</s-section>
<s-section heading="Booking policies">
<Form method="post">
<input type="hidden" name="intent" value="policy" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-number-field name="holdMinutes" label="Hold a chosen time for (minutes)" defaultValue={String(s.holdMinutes)} min={3} max={60} details="Other shoppers see the place as taken during the hold" />
<s-select name="mixedPrepPolicy" label="Mixed carts use" value={s.mixedPrepPolicy}>
<s-option value="longest">The longest preparation time</s-option>
<s-option value="sum">All preparation times added</s-option>
</s-select>
<s-select name="locationRanking" label="Suggest locations by" value={s.locationRanking}>
<s-option value="priority">My priority order</s-option>
<s-option value="distance">Closest to the customer</s-option>
<s-option value="earliest">Earliest available time</s-option>
</s-select>
<s-select name="weekStartsOn" label="Calendar weeks start on" value={String(s.weekStartsOn)}>
<s-option value="0">Sunday</s-option>
<s-option value="1">Monday</s-option>
</s-select>
<s-number-field name="waitlistOfferMinutes" label="Hold a freed place for the waitlist (minutes)" defaultValue={String(s.waitlistOfferMinutes)} min={5} />
</s-grid>
<s-checkbox name="requireScheduling" label="Every order needs a date and time" defaultChecked={s.requireScheduling} details="Orders that arrive without one go to the review queue" />
<s-checkbox name="releaseOnCancel" label="Free the place when an order is cancelled" defaultChecked={s.releaseOnCancel} />
<s-divider />
<s-heading>Customer self-reschedule</s-heading>
<s-checkbox name="rsEnabled" label="Let customers change their time from the order status page" defaultChecked={s.reschedule.enabled} />
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-number-field name="rsHours" label="Up to this many hours before" defaultValue={String(s.reschedule.minHoursBefore)} min={0} />
<s-number-field name="rsMax" label="Changes allowed per order" defaultValue={String(s.reschedule.maxChanges)} min={0} />
</s-grid>
<s-button type="submit" variant="primary" loading={busy}>Save policies</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Storefront widget">
<Form method="post">
<input type="hidden" name="intent" value="widget" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap="base">
<s-text-field name="heading" label="Heading" defaultValue={s.widget.heading} />
<s-text-field name="ld" label="Delivery label" defaultValue={s.widget.labels.delivery} />
<s-text-field name="lp" label="Pickup label" defaultValue={s.widget.labels.pickup} />
<s-text-field name="ls" label="Shipping label" defaultValue={s.widget.labels.shipping} />
<s-color-field name="accent" label="Accent colour" defaultValue={s.widget.accent} />
</s-grid>
<s-checkbox name="showRemaining" label="Show places left" defaultChecked={s.widget.showRemaining} />
<s-checkbox name="drawer" label="Show in the cart drawer too" defaultChecked={s.widget.drawer} />
<s-text-area name="translations" label="Translations (JSON by language)" rows={3} defaultValue={JSON.stringify(s.widget.translations)} details='Example: {"fr": {"heading": "Quand le voulez-vous ?", "delivery": "Livraison locale"}}' />
<s-button type="submit" variant="primary" loading={busy}>Save widget</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Reminders">
<Form method="post">
<input type="hidden" name="intent" value="reminders" />
<s-stack gap="base">
<s-text color="subdued">Placeholders: {"{{name}} {{order}} {{date}} {{time}} {{method}} {{location}}"}</s-text>
{s.reminders.map((r) => (
<s-box key={r.id} padding="base" border="base" borderRadius="base">
<s-stack gap="small">
<s-stack direction="inline" gap="base" alignItems="center">
<s-checkbox name={`en_${r.id}`} label={`${r.channel.toUpperCase()}${r.methods ? ` · ${r.methods.join(", ")}` : ""}`} defaultChecked={r.enabled} />
<s-number-field name={`hrs_${r.id}`} label="Hours before" defaultValue={String(r.minutesBefore / 60)} step={0.5} min={0} />
<s-checkbox name={`del_${r.id}`} label="Delete" />
</s-stack>
<s-text-area name={`tpl_${r.id}`} label="Message" rows={2} defaultValue={r.template} />
</s-stack>
</s-box>
))}
<s-grid gridTemplateColumns="1fr 1fr 3fr" gap="base" alignItems="end">
<s-select name="new_channel" label="New reminder">
<s-option value="email">Email</s-option>
<s-option value="sms">SMS (Pro)</s-option>
<s-option value="whatsapp">WhatsApp (Pro)</s-option>
</s-select>
<s-number-field name="new_hrs" label="Hours before" defaultValue="24" />
<s-text-field name="new_tpl" label="Message" placeholder="Hi {{name}}, see you {{date}} {{time}}" />
</s-grid>
<s-button type="submit" variant="primary" loading={busy}>Save reminders</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Messaging accounts">
<Form method="post">
<input type="hidden" name="intent" value="messaging" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="fromName" label="Email sender name" defaultValue={s.messaging.emailFromName} />
<s-email-field name="replyTo" label="Replies go to" defaultValue={s.messaging.replyTo ?? ""} />
</s-grid>
<s-heading>Twilio (SMS)</s-heading>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="twSid" label="Account SID" defaultValue={s.messaging.twilio?.accountSid ?? ""} />
<s-password-field name="twToken" label="Auth token" placeholder={s.messaging.twilio ? "Saved. Leave blank to keep" : ""} />
<s-text-field name="twFrom" label="From number" defaultValue={s.messaging.twilio?.from ?? ""} />
</s-grid>
{s.messaging.twilio && <s-checkbox name="twClear" label="Disconnect Twilio" />}
<s-heading>WhatsApp (Meta Cloud API)</s-heading>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="waPhone" label="Phone number ID" defaultValue={s.messaging.whatsapp?.phoneNumberId ?? ""} />
<s-password-field name="waToken" label="Access token" placeholder={s.messaging.whatsapp ? "Saved. Leave blank to keep" : ""} />
<s-text-field name="waTemplate" label="Approved template name" defaultValue={s.messaging.whatsapp?.templateName ?? ""} details="Template body must contain {{1}}" />
<s-text-field name="waLang" label="Template language" defaultValue={s.messaging.whatsapp?.templateLang ?? "en"} />
</s-grid>
{s.messaging.whatsapp && <s-checkbox name="waClear" label="Disconnect WhatsApp" />}
<s-button type="submit" variant="primary" loading={busy}>Save messaging</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Plan" id="plan">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
{PLAN_COPY.map((p) => (
<s-box key={p.id} padding="base" border="base" borderRadius="base" background={plan === p.id ? "subdued" : "base"}>
<s-stack gap="small">
<s-heading>{p.name}</s-heading>
<s-text type="strong">{p.price}</s-text>
<s-unordered-list>
{p.points.map((x) => <s-list-item key={x}>{x}</s-list-item>)}
</s-unordered-list>
{plan === p.id ? (
<s-badge tone="success">Current plan</s-badge>
) : (
<Form method="post">
<input type="hidden" name="intent" value={`plan:${p.id}`} />
<s-button type="submit">{p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`}</s-button>
</Form>
)}
</s-stack>
</s-box>
))}
</s-grid>
</s-section>
</s-page>
);
}

37
app/routes/app.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Outlet, useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};
export default function App() {
const { apiKey } = useLoaderData<typeof loader>();
return (
<AppProvider apiKey={apiKey}>
<s-app-nav>
<s-link href="/app">Today</s-link>
<s-link href="/app/bookings">Bookings</s-link>
<s-link href="/app/locations">Locations & hours</s-link>
<s-link href="/app/rules">Product rules</s-link>
<s-link href="/app/freshness">Fresh stock</s-link>
<s-link href="/app/peak">Rush control</s-link>
<s-link href="/app/runsheet">Driver run sheet</s-link>
<s-link href="/app/diagnostics">Why unavailable?</s-link>
<s-link href="/app/settings">Settings</s-link>
</s-app-nav>
<Outlet />
</AppProvider>
);
}
// Shopify needs React Router to catch some thrown responses so headers are included.
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);

10
app/routes/auth.$.tsx Normal file
View File

@ -0,0 +1,10 @@
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);

31
app/routes/auth.login.tsx Normal file
View File

@ -0,0 +1,31 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData } from "react-router";
import { LoginErrorType } from "@shopify/shopify-app-react-router/server";
import { login } from "../shopify.server";
function message(errors: Awaited<ReturnType<typeof login>> | undefined) {
if (!errors || errors instanceof Response) return null;
if (errors.shop === LoginErrorType.MissingShop) return "Enter your shop domain to log in";
if (errors.shop === LoginErrorType.InvalidShop) return "Enter a valid shop domain, like your-store.myshopify.com";
return null;
}
export const loader = async ({ request }: LoaderFunctionArgs) => ({ error: message(await login(request)) });
export const action = async ({ request }: ActionFunctionArgs) => ({ error: message(await login(request)) });
export default function Login() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const error = actionData?.error ?? loaderData.error;
return (
<main style={{ maxWidth: 420, margin: "10vh auto", padding: 16, fontFamily: "system-ui, sans-serif" }}>
<h1>Log in to Datewise</h1>
<Form method="post" style={{ display: "grid", gap: 8 }}>
<label htmlFor="shop">Shop domain</label>
<input id="shop" name="shop" placeholder="your-store.myshopify.com" autoComplete="on" />
{error && <p role="alert">{error}</p>}
<button type="submit">Log in</button>
</Form>
</main>
);
}

6
app/routes/healthz.tsx Normal file
View File

@ -0,0 +1,6 @@
import prisma from "../db.server";
export const loader = async () => {
await prisma.$queryRaw`SELECT 1`;
return Response.json({ ok: true });
};

View File

@ -0,0 +1,7 @@
import type { ActionFunctionArgs } from "react-router";
import { proxyHandler } from "../lib/proxy.server";
import { getAvailability } from "../services/storefront.server";
// POST /apps/datewise/availability { lines, method?, locationId?, postal?, token?, locale? }
export const action = ({ request }: ActionFunctionArgs) =>
proxyHandler(request, 120, ({ shop, admin, body }) => getAvailability(shop, admin, body));

10
app/routes/proxy.hold.tsx Normal file
View File

@ -0,0 +1,10 @@
import type { ActionFunctionArgs } from "react-router";
import { proxyHandler } from "../lib/proxy.server";
import { holdSelection } from "../services/storefront.server";
// POST /apps/datewise/hold { lines, method, locationId, date, slot, token? }
export const action = ({ request }: ActionFunctionArgs) =>
proxyHandler(request, 30, async ({ shop, admin, body }) => {
if (!admin) return { ok: false, message: "App not installed" };
return holdSelection(shop, admin, body);
});

View File

@ -0,0 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { proxyHandler } from "../lib/proxy.server";
import { adoptOffer } from "../services/storefront.server";
// GET /apps/datewise/offer?token=wl_… (link sent to the first person on a waitlist)
export const loader = ({ request }: LoaderFunctionArgs) =>
proxyHandler(request, 20, ({ shop, url }) => adoptOffer(shop, url.searchParams.get("token") ?? ""));

View File

@ -0,0 +1,8 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { proxyHandler } from "../lib/proxy.server";
import { releaseSelection } from "../services/storefront.server";
// POST /apps/datewise/release { token }
export const action = ({ request }: ActionFunctionArgs) =>
proxyHandler(request, 30, ({ shop, body }) => releaseSelection(shop, z.object({ token: z.string().min(8).max(80) }).parse(body).token));

View File

@ -0,0 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import { proxyHandler } from "../lib/proxy.server";
import { joinWaitlist } from "../services/storefront.server";
// POST /apps/datewise/waitlist { method, locationId, date, slot?, email?, phone?, name? }
export const action = ({ request }: ActionFunctionArgs) => proxyHandler(request, 10, ({ shop, body }) => joinWaitlist(shop, body));

View File

@ -0,0 +1,23 @@
import type { ActionFunctionArgs } from "react-router";
import prisma from "../db.server";
import { authenticate } from "../shopify.server";
/**
* app/uninstalled: storefront behaviour stops immediately (proxy calls fail without a
* session and the theme extension is removed by Shopify). Data is kept for 48 hours so a
* quick reinstall keeps bookings; Shopify's shop/redact webhook then deletes everything.
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, session, payload } = await authenticate.webhook(request);
if (topic === "APP_UNINSTALLED") {
if (session) await prisma.session.deleteMany({ where: { shop } });
await prisma.shop.updateMany({ where: { domain: shop }, data: { uninstalledAt: new Date() } });
await prisma.hold.deleteMany({ where: { shop: { domain: shop } } });
await prisma.reminderJob.updateMany({ where: { shop: { domain: shop }, status: "pending" }, data: { status: "cancelled" } });
}
if (topic === "APP_SCOPES_UPDATE" && session) {
const current = (payload as { current?: string[] }).current;
if (current) await prisma.session.update({ where: { id: session.id }, data: { scope: current.toString() } });
}
return new Response();
};

View File

@ -0,0 +1,40 @@
import { Prisma } from "@prisma/client";
import type { ActionFunctionArgs } from "react-router";
import prisma from "../db.server";
import { authenticate } from "../shopify.server";
/**
* Mandatory privacy webhooks.
* customers/data_request → we hold booking contact details; logged for the merchant to fulfil.
* customers/redact → anonymise that customer's bookings and waitlist entries.
* shop/redact → delete all shop data (48h after uninstall).
*/
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
const p = payload as { customer?: { email?: string; phone?: string }; orders_to_redact?: number[] };
const s = await prisma.shop.findUnique({ where: { domain: shop } });
if (!s) return new Response();
if (topic === "CUSTOMERS_DATA_REQUEST") {
const n = await prisma.booking.count({ where: { shopId: s.id, customerEmail: p.customer?.email ?? "__none__" } });
console.info(`data_request for ${shop}: ${n} bookings hold this customer's contact details`);
}
if (topic === "CUSTOMERS_REDACT") {
const orderIds = (p.orders_to_redact ?? []).map((id) => `gid://shopify/Order/${id}`);
const where = {
shopId: s.id,
OR: [{ orderId: { in: orderIds } }, ...(p.customer?.email ? [{ customerEmail: p.customer.email }] : [])],
};
await prisma.booking.updateMany({
where,
data: { customerName: null, customerEmail: null, customerPhone: null, address: Prisma.DbNull },
});
if (p.customer?.email || p.customer?.phone)
await prisma.waitlistEntry.deleteMany({ where: { shopId: s.id, OR: [{ email: p.customer?.email ?? "__" }, { phone: p.customer?.phone ?? "__" }] } });
}
if (topic === "SHOP_REDACT") {
await prisma.shop.delete({ where: { id: s.id } });
await prisma.session.deleteMany({ where: { shop } });
}
return new Response();
};

View File

@ -0,0 +1,11 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { firstDelivery, handleOrderCancelled } from "../services/orders.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, webhookId } = await authenticate.webhook(request);
const eventId = request.headers.get("x-shopify-event-id") ?? webhookId;
if (!(await firstDelivery(eventId, topic, shop))) return new Response();
await handleOrderCancelled(shop, payload as { admin_graphql_api_id: string });
return new Response();
};

View File

@ -0,0 +1,19 @@
import type { ActionFunctionArgs } from "react-router";
import prisma from "../db.server";
import { authenticate } from "../shopify.server";
import { firstDelivery, handleOrderCreate, type OrderPayload } from "../services/orders.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload, admin, webhookId } = await authenticate.webhook(request);
const eventId = request.headers.get("x-shopify-event-id") ?? webhookId;
if (!(await firstDelivery(eventId, topic, shop))) return new Response();
try {
await handleOrderCreate(shop, payload as OrderPayload, admin);
} catch (e) {
console.error("orders/create failed", shop, e);
// Let Shopify retry; the idempotency row must not block the retry.
await prisma.processedWebhook.delete({ where: { id: eventId } }).catch(() => {});
return new Response("retry", { status: 500 });
}
return new Response();
};

View File

@ -0,0 +1,9 @@
import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";
import { handleOrderFulfilled } from "../services/orders.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, payload } = await authenticate.webhook(request);
await handleOrderFulfilled(shop, payload as { admin_graphql_api_id: string });
return new Response();
};

View File

@ -0,0 +1,106 @@
/**
* Order webhook → booking, against real Postgres (TEST_DATABASE_URL).
* Admin API calls (tagging/metafields) are skipped by passing no admin client.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
const url = process.env.TEST_DATABASE_URL;
if (url) process.env.DATABASE_URL = url;
const d = url ? describe : describe.skip;
d("order webhooks", async () => {
const { default: prisma } = await import("../../db.server");
const { handleOrderCreate, handleOrderCancelled } = await import("../orders.server");
const { ReservationService } = await import("../reservations.server");
const { withDefaults } = await import("../settings");
const svc = new ReservationService(prisma);
const SHOP = "orders-test.myshopify.com";
let shopId = "";
let locId = "";
const sel = (o: Record<string, unknown> = {}) => ({ t: "tok_orders_test_0001", m: "delivery", l: locId, d: "2030-06-04", s: "10:00-13:00", f: 799, v: 1, ...o });
const order = (id: number, attr: object | null, feePrice = "7.99") => ({
id,
admin_graphql_api_id: `gid://shopify/Order/${id}`,
name: `#${id}`,
email: "priya@example.com",
phone: "+15195550142",
note_attributes: attr ? [{ name: "_datewise", value: JSON.stringify(attr) }] : [],
customer: { first_name: "Priya", last_name: "R" },
shipping_address: { address1: "1 King St", city: "Kitchener", zip: "N2H 1A1", latitude: 43.45, longitude: -80.49 },
line_items: [
{ title: "Marigold", quantity: 2, price: "19.99", product_id: 101, variant_id: 1001 },
...(feePrice ? [{ title: "Delivery & handling", quantity: 1, price: feePrice, product_id: 900, variant_id: 9000 }] : []),
],
});
beforeAll(async () => {
await prisma.shop.deleteMany({ where: { domain: SHOP } });
});
afterAll(async () => {
await prisma.shop.deleteMany({ where: { domain: SHOP } });
});
beforeEach(async () => {
await prisma.shop.deleteMany({ where: { domain: SHOP } });
const shop = await prisma.shop.create({
data: {
domain: SHOP,
plan: "Growth",
feeProductId: "gid://shopify/Product/900",
settings: withDefaults({}) as object,
locations: {
create: [{
name: "Kitchener",
address: "x",
timezone: "America/Toronto",
methods: { delivery: { enabled: true, weeklyHours: { 2: [{ start: "10:00", end: "19:00" }] }, slotMode: "generated", slotMinutes: 180, prepMinutes: 0, prepMode: "elapsed", horizonDays: 3650, slotCapacity: 1 } },
}],
},
},
include: { locations: true },
});
shopId = shop.id;
locId = shop.locations[0].id;
});
const hold = (token = "tok_orders_test_0001", fee = 799) =>
svc.hold(shopId, token, { locationId: locId, method: "delivery", date: "2030-06-04", slot: "10:00-13:00" }, { dayCap: null, slotCap: 1 }, new Date(), 10, fee);
it("confirms the held place, stores contact + items, plans reminders", async () => {
await hold();
const r = await handleOrderCreate(SHOP, order(1, sel()));
expect("ok" in r && r.ok).toBe(true);
const b = await prisma.booking.findFirstOrThrow({ where: { shopId }, include: { reminderJobs: true } });
expect(b).toMatchObject({ status: "confirmed", customerName: "Priya R", fee: 799, slotStart: "10:00", reviewReason: null });
expect(b.startUtc?.toISOString()).toBe("2030-06-04T14:00:00.000Z");
expect(b.reminderJobs.length).toBeGreaterThan(0);
});
it("is idempotent for the same order", async () => {
await hold();
await handleOrderCreate(SHOP, order(2, sel()));
await handleOrderCreate(SHOP, order(2, sel()));
expect(await prisma.booking.count({ where: { shopId } })).toBe(1);
});
it("flags a fee that doesn't match the quote, but keeps the place", async () => {
await hold();
await handleOrderCreate(SHOP, order(3, sel(), "0.01"));
const b = await prisma.booking.findFirstOrThrow({ where: { shopId } });
expect(b.status).toBe("confirmed");
expect(b.reviewReason).toMatch(/Fee paid 0.01 differs from quote 7.99/);
});
it("orders without a time go to review when scheduling is required", async () => {
await handleOrderCreate(SHOP, order(4, null, ""));
const b = await prisma.booking.findFirstOrThrow({ where: { shopId } });
expect(b.status).toBe("needs_review");
});
it("cancellation frees the place for the next customer", async () => {
await hold();
await handleOrderCreate(SHOP, order(5, sel()));
expect((await hold("tok_orders_test_0002")).ok).toBe(false);
await handleOrderCancelled(SHOP, { admin_graphql_api_id: "gid://shopify/Order/5" });
expect((await hold("tok_orders_test_0002")).ok).toBe(true);
});
});

View File

@ -0,0 +1,159 @@
/**
* Runs against a real Postgres. Set TEST_DATABASE_URL (schema from prisma/migrations applied).
* TEST_DATABASE_URL=postgresql://postgres@localhost:5433/datewise_test npx vitest run app/services
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createPrisma } from "../../db.server";
import { ReservationService } from "../reservations.server";
import type { PrismaClient } from "@prisma/client";
const url = process.env.TEST_DATABASE_URL;
const d = url ? describe : describe.skip;
d("ReservationService (Postgres)", () => {
let db: PrismaClient;
let svc: ReservationService;
let shopId: string;
const sel = { locationId: "kit", method: "delivery" as const, date: "2026-11-08", slot: "10:00-13:00" };
const now = new Date("2026-11-05T15:00:00Z");
const details = { timezone: "America/Toronto", configVersion: 1, snapshot: {} };
beforeAll(async () => {
db = createPrisma(url);
svc = new ReservationService(db);
});
afterAll(async () => db.$disconnect());
beforeEach(async () => {
await db.shop.deleteMany({ where: { domain: "test.myshopify.com" } });
const s = await db.shop.create({ data: { domain: "test.myshopify.com", settings: {} } });
shopId = s.id;
});
it("25 concurrent shoppers, 3 places → exactly 3 holds", async () => {
const caps = { dayCap: 30, slotCap: 3 };
const results = await Promise.all(
Array.from({ length: 25 }, (_, i) => svc.hold(shopId, `t${i}`, sel, caps, now, 10)),
);
expect(results.filter((r) => r.ok)).toHaveLength(3);
const c = await db.capacityCounter.findMany({ where: { shopId } });
expect(c.find((x) => x.slot === "*")!.count).toBe(3);
expect(c.find((x) => x.slot === sel.slot)!.count).toBe(3);
});
it("expired holds free capacity inside the next hold", async () => {
const caps = { dayCap: 30, slotCap: 1 };
expect((await svc.hold(shopId, "a", sel, caps, now, 10)).ok).toBe(true);
expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(false);
const later = new Date(now.getTime() + 11 * 60000);
expect((await svc.hold(shopId, "b", sel, caps, later, 10)).ok).toBe(true);
expect(await db.hold.count({ where: { shopId } })).toBe(1);
});
it("moving a hold releases the previous slot", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.hold(shopId, "a", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
expect((await svc.hold(shopId, "b", sel, caps, now, 10)).ok).toBe(true);
});
it("usage excludes stale holds and the caller's own hold", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.hold(shopId, "b", sel, caps, now, 1);
const u = await svc.usage(shopId, "2026-11-01", "2026-11-30", new Date(now.getTime() + 2 * 60000), "a");
expect(u["kit|delivery|2026-11-08"].slots[sel.slot]).toBe(0);
});
it("concurrent duplicate webhooks create one booking", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const rs = await Promise.all(
Array.from({ length: 6 }, () => svc.confirm(shopId, "order-1", "a", null, caps, now, details)),
);
expect(await db.booking.count({ where: { shopId } })).toBe(1);
expect(rs.filter((r) => r.ok && !r.duplicate)).toHaveLength(1);
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(1);
});
it("expired hold at order time: re-reserve when free, flag when full", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const late = new Date(now.getTime() + 15 * 60000);
const ok = await svc.confirm(shopId, "o1", "a", null, caps, late, details);
expect(ok.ok && ok.reReserved).toBe(true);
await svc.hold(shopId, "x", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
await svc.hold(shopId, "y", { ...sel, slot: "13:00-16:00" }, caps, new Date(now.getTime() + 11 * 60000), 10);
const flagged = await svc.confirm(shopId, "o2", "x", null, caps, new Date(now.getTime() + 12 * 60000), details);
expect(flagged.ok).toBe(false);
const b = await db.booking.findUnique({ where: { id: flagged.bookingId } });
expect(b!.status).toBe("needs_review");
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: "13:00-16:00" } });
expect(c!.count).toBe(1); // only y's hold — no overbooking
});
it("order without a selection lands in review", async () => {
const r = await svc.confirm(shopId, "o9", null, null, { dayCap: 1, slotCap: 1 }, now, details);
expect(r.ok).toBe(false);
});
it("reschedule: atomic swap, failure preserves, audit recorded", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
const r = await svc.confirm(shopId, "o1", "a", null, caps, now, details);
await svc.hold(shopId, "other", { ...sel, slot: "13:00-16:00" }, caps, now, 10);
const fail = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "13:00-16:00" }, caps, now, "customer");
expect(fail.ok).toBe(false);
expect((await db.booking.findUnique({ where: { id: r.bookingId } }))!.slotStart).toBe("10:00");
const ok = await svc.reschedule(shopId, r.bookingId, { ...sel, slot: "16:00-19:00" }, caps, now, "customer");
expect(ok.ok && ok.freed?.slot).toBe("10:00-13:00");
const counters = await db.capacityCounter.findMany({ where: { shopId } });
expect(counters.find((c) => c.slot === "10:00-13:00")!.count).toBe(0);
expect(counters.find((c) => c.slot === "16:00-19:00")!.count).toBe(1);
expect(counters.find((c) => c.slot === "*")!.count).toBe(2); // booking + other's hold
const b = await db.booking.findUnique({ where: { id: r.bookingId } });
expect(b!.rescheduleCount).toBe(1);
expect(await db.bookingEvent.count({ where: { bookingId: r.bookingId, action: "rescheduled" } })).toBe(1);
});
it("concurrent reschedules across two dates don't deadlock or overbook", async () => {
const caps = { dayCap: 30, slotCap: 1 };
const a = { ...sel, date: "2026-11-07" };
const b = { ...sel, date: "2026-11-08" };
await svc.hold(shopId, "h1", a, caps, now, 10);
const r1 = await svc.confirm(shopId, "o1", "h1", null, caps, now, details);
await svc.hold(shopId, "h2", b, caps, now, 10);
const r2 = await svc.confirm(shopId, "o2", "h2", null, caps, now, details);
// Swap attempts in opposite directions at once: both target full slots → both fail cleanly.
const [x, y] = await Promise.all([
svc.reschedule(shopId, r1.bookingId, b, caps, now, "merchant:1"),
svc.reschedule(shopId, r2.bookingId, a, caps, now, "merchant:2"),
]);
expect(x.ok || y.ok).toBe(false);
const total = await db.capacityCounter.findMany({ where: { shopId, slot: sel.slot } });
expect(total.every((t) => t.count <= 1)).toBe(true);
});
it("cancel frees capacity once and is idempotent", async () => {
const caps = { dayCap: 30, slotCap: 1 };
await svc.hold(shopId, "a", sel, caps, now, 10);
await svc.confirm(shopId, "o1", "a", null, caps, now, details);
const c1 = await svc.cancel(shopId, "o1");
const c2 = await svc.cancel(shopId, "o1");
expect(c1.freed?.slot).toBe(sel.slot);
expect(c2.freed).toBeNull();
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(0);
});
it("background sweep releases expired holds", async () => {
const caps = { dayCap: 30, slotCap: 5 };
await svc.hold(shopId, "a", sel, caps, now, 1);
await svc.hold(shopId, "b", sel, caps, now, 30);
const n = await svc.sweepExpired(new Date(now.getTime() + 5 * 60000), 500, shopId);
expect(n).toBe(1);
const c = await db.capacityCounter.findFirst({ where: { shopId, slot: sel.slot } });
expect(c!.count).toBe(1);
});
});

View File

@ -0,0 +1,104 @@
/**
* Booking changes initiated by staff or customers. Customers go through the same engine
* validation as checkout (rules, cutoffs, freshness, capacity); staff may override capacity.
*/
import prisma from "../db.server";
import { computeAvailability, validateSelection } from "../engine/availability";
import { capsFor, type Selection } from "../engine/capacity";
import { canCustomerReschedule } from "../engine/customer";
import { addDays, localParts, zonedToUtc } from "../engine/time";
import type { Cart, Method } from "../engine/types";
import { loadShopConfig } from "./config.server";
import { features } from "./plans";
import { rowToBooking, scheduleReminders } from "./reminders.server";
import { ReservationService } from "./reservations.server";
import { offerFreedPlace } from "./waitlist.server";
const reservations = new ReservationService(prisma);
export async function rescheduleBooking(opts: {
domain: string;
bookingId: string;
sel: Selection;
actor: string; // "customer" | "merchant:<id>"
force?: boolean;
}) {
const cfg = await loadShopConfig(opts.domain, { fresh: true });
const row = await prisma.booking.findFirst({ where: { id: opts.bookingId, shopId: cfg.shop.id } });
if (!row) return { ok: false as const, message: "Booking not found" };
const loc = cfg.locations.find((l) => l.id === opts.sel.locationId);
if (!loc) return { ok: false as const, message: "Unknown location" };
const now = Date.now();
const isCustomer = opts.actor === "customer";
if (isCustomer) {
const can = canCustomerReschedule(rowToBooking(row), cfg.settings.reschedule, now);
if (!can.ok) return { ok: false as const, message: can.reason };
if (!cfg.settings.reschedule.allowMethodChange && (opts.sel.method !== row.method || opts.sel.locationId !== row.locationId))
return { ok: false as const, message: "Only the date and time can be changed" };
}
// Customers: full rule check with the booked items. Staff: hours/closures only (no product rules).
const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({
productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0,
}));
const cart: Cart = { items: isCustomer ? items : [] };
const today = localParts(now, cfg.shop.timezone).date;
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now));
if (row.status === "confirmed" && row.locationId && row.date && row.slotStart) {
// Don't count the booking against itself when re-validating.
const k = `${row.locationId}|${row.method}|${row.date}`;
if (usage[k]) {
usage[k].day = Math.max(0, usage[k].day - 1);
const sk = `${row.slotStart}-${row.slotEnd}`;
usage[k].slots[sk] = Math.max(0, (usage[k].slots[sk] ?? 0) - 1);
}
}
const peaks = features(cfg.shop.plan).peakDays ? cfg.peakDays : [];
const v = validateSelection(
{ location: loc, method: opts.sel.method as Method, cart, rules: isCustomer ? cfg.rules : [], peakDays: peaks, usage, settings: cfg.settings, now },
opts.sel.date,
opts.sel.slot,
);
const capacityOnly = !v.ok && v.reasons.every((r) => r.code === "SLOT_FULL" || r.code === "DAY_FULL");
if (!v.ok && !(opts.force && !isCustomer && capacityOnly)) return { ok: false as const, message: v.reasons[0]?.message ?? "Not available" };
const [s, e] = opts.sel.slot.split("-");
const r = await reservations.reschedule(cfg.shop.id, row.id, opts.sel, capsFor(loc, opts.sel.method, opts.sel.date, opts.sel.slot, peaks), new Date(now), opts.actor, {
startUtc: new Date(zonedToUtc(opts.sel.date, s, loc.timezone)),
endUtc: new Date(zonedToUtc(opts.sel.date, e, loc.timezone)),
force: opts.force && !isCustomer,
});
if (!r.ok) return r;
await scheduleReminders(row.id);
if (r.freed) await offerFreedPlace(opts.domain, r.freed);
return { ok: true as const };
}
/** Options for a reschedule picker: available slots for a booking's location/method. */
export async function rescheduleOptions(domain: string, bookingId: string, opts: { customer: boolean }) {
const cfg = await loadShopConfig(domain);
const row = await prisma.booking.findFirst({ where: { id: bookingId, shopId: cfg.shop.id } });
if (!row) return [];
const loc = cfg.locations.find((l) => l.id === (row.locationId || cfg.locations[0]?.id));
if (!loc) return [];
const now = Date.now();
const today = localParts(now, cfg.shop.timezone).date;
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now));
const items = ((row.items as { title: string; quantity: number }[] | null) ?? []).map((i, n) => ({ productId: `item-${n}`, variantId: `item-${n}`, title: i.title, quantity: i.quantity, price: 0 }));
const av = computeAvailability({
location: loc,
method: row.method as Method,
cart: { items: opts.customer ? items : [] },
rules: opts.customer ? cfg.rules : [],
peakDays: features(cfg.shop.plan).peakDays ? cfg.peakDays : [],
usage,
settings: cfg.settings,
now,
});
return av.dates.flatMap((d) =>
d.slots
.filter((s) => s.available || (!opts.customer && s.reasons.every((r) => /FULL/.test(r.code))))
.map((s) => ({ locationId: loc.id, method: row.method, date: d.date, slot: s.id, start: s.start, end: s.end, remaining: s.remaining, full: !s.available })),
);
}

View File

@ -0,0 +1,118 @@
/**
* Cart enrichment: the storefront only knows variant ids and quantities, so we look up
* collections, tags, vendor, type and per-location inventory with the Admin API.
* Results are cached briefly per shop+variant to keep the widget fast.
*/
import type { Cart, CartItem } from "../engine/types";
import type { Inventory } from "../engine/locations";
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
export interface StorefrontLine {
variantId: string | number;
quantity: number;
price?: number; // minor units, from /cart.js
title?: string;
}
interface VariantInfo {
item: Omit<CartItem, "quantity" | "price"> & { price: number };
stock: Record<string, number>; // shopify location gid → available
tracked: boolean;
isFee: boolean;
}
const cache = new Map<string, { at: number; v: VariantInfo }>();
const TTL = 60_000;
const gid = (id: string | number) => (String(id).startsWith("gid://") ? String(id) : `gid://shopify/ProductVariant/${id}`);
export const FEE_TAG = "datewise-fee";
export async function enrichCart(
admin: AdminGraphql,
shop: string,
lines: StorefrontLine[],
shopifyToAppLocation: Record<string, string>,
): Promise<{ cart: Cart; inventory: Inventory; feeLines: StorefrontLine[] }> {
const ids = [...new Set(lines.map((l) => gid(l.variantId)))];
const missing = ids.filter((id) => {
const c = cache.get(`${shop}|${id}`);
return !c || Date.now() - c.at > TTL;
});
for (let i = 0; i < missing.length; i += 50) {
const res = await admin.graphql(
`#graphql
query DatewiseCart($ids: [ID!]!) {
nodes(ids: $ids) {
... on ProductVariant {
id title price
product {
id title vendor productType tags
collections(first: 50) { nodes { id } }
}
inventoryItem {
tracked
inventoryLevels(first: 30) {
nodes { location { id } quantities(names: ["available"]) { quantity } }
}
}
}
}
}`,
{ variables: { ids: missing.slice(i, i + 50) } },
);
const json = (await res.json()) as { data: { nodes: (RawVariant | null)[] } };
for (const n of json.data.nodes) {
if (!n) continue;
const tracked = n.inventoryItem?.tracked ?? false;
const stock: Record<string, number> = {};
for (const lvl of n.inventoryItem?.inventoryLevels.nodes ?? [])
stock[lvl.location.id] = lvl.quantities[0]?.quantity ?? 0;
cache.set(`${shop}|${n.id}`, {
at: Date.now(),
v: {
isFee: n.product.tags.includes(FEE_TAG),
tracked,
stock,
item: {
productId: n.product.id,
variantId: n.id,
title: n.product.title + (n.title && n.title !== "Default Title" ? ` – ${n.title}` : ""),
price: Math.round(Number(n.price) * 100),
collectionIds: n.product.collections.nodes.map((c) => c.id),
vendor: n.product.vendor,
productType: n.product.productType,
tags: n.product.tags,
},
},
});
}
}
const items: CartItem[] = [];
const inventory: Inventory = {};
const feeLines: StorefrontLine[] = [];
for (const l of lines) {
const info = cache.get(`${shop}|${gid(l.variantId)}`)?.v;
if (!info) continue;
if (info.isFee) {
feeLines.push(l);
continue;
}
items.push({ ...info.item, quantity: l.quantity, price: l.price ?? info.item.price });
// Every linked location gets an explicit figure; untracked items count as unlimited.
for (const [shopifyLoc, appLoc] of Object.entries(shopifyToAppLocation)) {
inventory[appLoc] ??= {};
inventory[appLoc][info.item.variantId] = info.tracked ? info.stock[shopifyLoc] ?? 0 : Number.MAX_SAFE_INTEGER;
}
}
return { cart: { items, subtotal: items.reduce((s, i) => s + i.price * i.quantity, 0) }, inventory, feeLines };
}
interface RawVariant {
id: string;
title: string;
price: string;
product: { id: string; title: string; vendor: string; productType: string; tags: string[]; collections: { nodes: { id: string }[] } };
inventoryItem: { tracked: boolean; inventoryLevels: { nodes: { location: { id: string }; quantities: { quantity: number }[] }[] } } | null;
}

View File

@ -0,0 +1,158 @@
/**
* Loads a shop's scheduling configuration and maps DB rows to engine types.
*/
import type { Location, PeakDay as PeakRow, ProductRule as RuleRow, Shop } from "@prisma/client";
import prisma from "../db.server";
import type { LocationConfig, Method, PeakDay, ProductRule } from "../engine/types";
import { type AppSettings, withDefaults } from "./settings";
export interface ShopConfig {
shop: Shop;
settings: AppSettings;
locations: LocationConfig[];
rules: ProductRule[];
peakDays: PeakDay[];
version: number;
}
export function toLocationConfig(l: Location): LocationConfig {
return {
id: l.id,
shopifyLocationId: l.shopifyLocationId ?? undefined,
name: l.name,
address: l.address,
timezone: l.timezone,
lat: l.lat ?? undefined,
lng: l.lng ?? undefined,
enabled: l.enabled,
priority: l.priority,
pickupInstructions: l.pickupInstructions ?? undefined,
methods: l.methods as unknown as LocationConfig["methods"],
exceptions: (l.exceptions ?? []) as unknown as LocationConfig["exceptions"],
zones: (l.zones ?? []) as unknown as LocationConfig["zones"],
};
}
export function toRule(r: RuleRow): ProductRule {
return {
id: r.id,
name: r.name,
enabled: r.enabled,
priority: r.priority,
target: r.target as unknown as ProductRule["target"],
action: r.action as unknown as ProductRule["action"],
message: r.message ?? undefined,
};
}
export function toPeak(p: PeakRow): PeakDay {
return {
id: p.id,
name: p.name,
dates: p.dates,
locationIds: p.locationIds.length ? p.locationIds : undefined,
methods: p.methods.length ? (p.methods as Method[]) : undefined,
dailyCapacity: p.dailyCapacity,
slotCapacity: p.slotCapacity,
extraPrepMinutes: p.extraPrepMinutes,
cutoff: p.cutoffDaysBefore != null && p.cutoffTime ? { daysBefore: p.cutoffDaysBefore, time: p.cutoffTime } : null,
surcharge: p.surcharge,
waitlist: p.waitlist,
message: p.message ?? undefined,
};
}
const cache = new Map<string, { at: number; cfg: ShopConfig }>();
const TTL = 5000;
export async function loadShopConfig(domain: string, opts: { fresh?: boolean } = {}): Promise<ShopConfig> {
const hit = cache.get(domain);
if (!opts.fresh && hit && Date.now() - hit.at < TTL) return hit.cfg;
const shop = await prisma.shop.findUnique({
where: { domain },
include: { locations: { orderBy: { priority: "asc" } }, rules: true, peakDays: true },
});
if (!shop || shop.uninstalledAt) throw new Response("Shop not installed", { status: 404 });
const cfg: ShopConfig = {
shop,
settings: withDefaults(shop.settings),
locations: shop.locations.map(toLocationConfig),
rules: shop.rules.map(toRule),
peakDays: shop.peakDays.map(toPeak),
version: shop.configVersion,
};
cache.set(domain, { at: Date.now(), cfg });
return cfg;
}
/** Call after any configuration write. Bookings keep the version they were accepted under. */
export async function bumpConfig(shopId: string, domain: string) {
await prisma.shop.update({ where: { id: shopId }, data: { configVersion: { increment: 1 } } });
cache.delete(domain);
}
export async function getShop(domain: string) {
const shop = await prisma.shop.findUnique({ where: { domain } });
if (!shop) throw new Response("Shop not found", { status: 404 });
return shop;
}
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
const DEFAULT_HOURS = { start: "10:00", end: "18:00" };
/**
* First install: create the shop row and import Shopify locations as pickup-ready
* (disabled until the merchant reviews hours). Re-install: clear uninstalledAt.
*/
export async function ensureShop(domain: string, admin: AdminGraphql) {
const res = await admin.graphql(`#graphql
query DatewiseInstall {
shop { ianaTimezone currencyCode }
locations(first: 50, includeInactive: false) {
nodes { id name isActive fulfillsOnlineOrders address { formatted latitude longitude } }
}
}`);
const { data } = (await res.json()) as {
data: {
shop: { ianaTimezone: string; currencyCode: string };
locations: { nodes: { id: string; name: string; fulfillsOnlineOrders: boolean; address: { formatted: string[]; latitude: number | null; longitude: number | null } }[] };
};
};
const existing = await prisma.shop.findUnique({ where: { domain } });
if (existing) {
await prisma.shop.update({
where: { domain },
data: { uninstalledAt: null, timezone: data.shop.ianaTimezone, currency: data.shop.currencyCode },
});
return existing;
}
const weekdays = Object.fromEntries([1, 2, 3, 4, 5, 6].map((d) => [d, [DEFAULT_HOURS]]));
return prisma.shop.create({
data: {
domain,
timezone: data.shop.ianaTimezone,
currency: data.shop.currencyCode,
settings: withDefaults({}) as object,
locations: {
create: data.locations.nodes
.filter((l) => l.fulfillsOnlineOrders)
.map((l, i) => ({
shopifyLocationId: l.id,
name: l.name,
address: l.address.formatted.join(", "),
timezone: data.shop.ianaTimezone,
lat: l.address.latitude,
lng: l.address.longitude,
enabled: false,
priority: i + 1,
methods: {
pickup: { enabled: true, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 60, prepMinutes: 120, prepMode: "operating", sameDayCutoff: "14:00", horizonDays: 30, slotCapacity: 5 },
delivery: { enabled: false, weeklyHours: weekdays, slotMode: "generated", slotMinutes: 180, prepMinutes: 240, prepMode: "elapsed", sameDayCutoff: "11:00", horizonDays: 30, slotCapacity: 5, dailyCapacity: 15 },
shipping: { enabled: false, weeklyHours: Object.fromEntries([1, 2, 3, 4, 5].map((d) => [d, [DEFAULT_HOURS]])), slotMode: "dateOnly", prepMinutes: 0, prepMode: "operating", sameDayCutoff: "13:00", horizonDays: 14 },
},
})),
},
},
});
}

View File

@ -0,0 +1,22 @@
/** AES-256-GCM for merchant messaging credentials. APP_ENCRYPTION_KEY = 32 random bytes, base64. */
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
function key(): Buffer {
const k = Buffer.from(process.env.APP_ENCRYPTION_KEY ?? "", "base64");
if (k.length !== 32) throw new Error("APP_ENCRYPTION_KEY must be 32 bytes (base64)");
return k;
}
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return [iv, c.getAuthTag(), enc].map((b) => b.toString("base64url")).join(".");
}
export function decrypt(blob: string): string {
const [iv, tag, enc] = blob.split(".").map((p) => Buffer.from(p, "base64url"));
const d = createDecipheriv("aes-256-gcm", key(), iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
}

103
app/services/fees.server.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* Charging delivery fees and peak surcharges on every Shopify plan.
*
* Carrier-calculated rates need Advanced/Plus (or the paid add-on), so instead the
* app manages one hidden "Delivery & handling" product tagged `datewise-fee`, with a
* variant per fee amount. The widget puts exactly one fee line in the cart that
* matches the quote; the checkout validation function and the order webhook both
* re-check it.
*/
import prisma from "../db.server";
import { FEE_TAG } from "./catalog.server";
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>): Promise<T> {
const r = await admin.graphql(q, { variables });
const j = (await r.json()) as { data: T; errors?: unknown };
if (j.errors) throw new Error(`Admin API: ${JSON.stringify(j.errors)}`);
return j.data;
}
const label = (cents: number) => (cents / 100).toFixed(2);
const numericId = (gid: string) => gid.split("/").pop()!;
export async function ensureFeeProduct(admin: AdminGraphql, shopId: string): Promise<string> {
const shop = await prisma.shop.findUniqueOrThrow({ where: { id: shopId } });
if (shop.feeProductId) {
const d = await gql<{ product: { id: string } | null }>(admin, `#graphql
query($id: ID!) { product(id: $id) { id } }`, { id: shop.feeProductId });
if (d.product) return d.product.id;
}
const created = await gql<{ productCreate: { product: { id: string } | null; userErrors: { message: string }[] } }>(
admin,
`#graphql
mutation DatewiseFeeProduct($product: ProductCreateInput!) {
productCreate(product: $product) { product { id } userErrors { message } }
}`,
{
product: {
title: "Delivery & handling",
handle: "datewise-delivery-fee",
tags: [FEE_TAG],
status: "ACTIVE",
productType: "Service",
descriptionHtml: "<p>Added automatically for scheduled delivery and pickup.</p>",
productOptions: [{ name: "Fee", values: [{ name: label(0) }] }],
seo: { title: "Delivery & handling" },
},
},
);
const id = created.productCreate.product?.id;
if (!id) throw new Error(created.productCreate.userErrors.map((e) => e.message).join("; "));
// Hide from storefront search/collections.
await gql(admin, `#graphql
mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, {
m: [{ ownerId: id, namespace: "seo", key: "hidden", type: "number_integer", value: "1" }],
});
// Must be on the Online Store channel to be added to carts.
const pubs = await gql<{ publications: { nodes: { id: string; catalog: { title: string } | null }[] } }>(admin, `#graphql
query { publications(first: 20) { nodes { id catalog { title } } } }`);
const online = pubs.publications.nodes.find((p) => /online store/i.test(p.catalog?.title ?? ""));
if (online)
await gql(admin, `#graphql
mutation($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { message } } }`, {
id,
input: [{ publicationId: online.id }],
});
await prisma.shop.update({ where: { id: shopId }, data: { feeProductId: id } });
return id;
}
/** Returns the numeric variant id for a fee amount, creating the variant when needed. */
export async function ensureFeeVariant(admin: AdminGraphql, shopId: string, cents: number): Promise<string | null> {
if (cents <= 0) return null;
const productId = await ensureFeeProduct(admin, shopId);
const d = await gql<{ product: { variants: { nodes: { id: string; price: string }[] } } }>(admin, `#graphql
query($id: ID!) { product(id: $id) { variants(first: 250) { nodes { id price } } } }`, { id: productId });
const found = d.product.variants.nodes.find((v) => Math.round(Number(v.price) * 100) === cents);
if (found) return numericId(found.id);
const c = await gql<{ productVariantsBulkCreate: { productVariants: { id: string }[]; userErrors: { message: string }[] } }>(
admin,
`#graphql
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkCreate(productId: $productId, variants: $variants) {
productVariants { id } userErrors { message }
}
}`,
{
productId,
variants: [
{
price: label(cents),
optionValues: [{ optionName: "Fee", name: label(cents) }],
inventoryPolicy: "CONTINUE",
inventoryItem: { tracked: false, requiresShipping: false },
},
],
},
);
const v = c.productVariantsBulkCreate.productVariants[0];
if (!v) throw new Error(c.productVariantsBulkCreate.userErrors.map((e) => e.message).join("; "));
return numericId(v.id);
}

View File

@ -0,0 +1,69 @@
/**
* Maps adapter (Google Maps Platform). Used only when a shop has radius or
* driving-distance zones; postal-code zones never call out.
*
* Failure policy: on timeout/error we return the address WITHOUT coordinates/driving
* distances. The engine then refuses distance-based zones ("couldn't verify your
* address") rather than accepting an unverified address.
*/
import type { LocationConfig } from "../engine/types";
import type { CustomerAddress } from "../engine/zones";
const KEY = process.env.GOOGLE_MAPS_API_KEY;
const TIMEOUT = 2500;
const cache = new Map<string, { at: number; v: CustomerAddress }>();
const TTL = 24 * 3600_000;
async function getJson(url: string): Promise<unknown> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT);
try {
const r = await fetch(url, { signal: ctrl.signal });
if (!r.ok) throw new Error(`maps ${r.status}`);
return await r.json();
} finally {
clearTimeout(t);
}
}
export function needsMaps(locations: LocationConfig[]) {
return locations.some((l) => l.zones.some((z) => z.radiusKm != null || z.drivingKm != null));
}
export async function resolveAddress(
input: { postal?: string; address?: string; country?: string },
locations: LocationConfig[],
): Promise<CustomerAddress & { degraded?: boolean }> {
const base: CustomerAddress = { postal: input.postal };
if (!KEY || !needsMaps(locations)) return base;
const query = [input.address, input.postal, input.country].filter(Boolean).join(", ");
if (!query) return base;
const ck = `${query}|${locations.map((l) => l.id).join(",")}`;
const hit = cache.get(ck);
if (hit && Date.now() - hit.at < TTL) return hit.v;
try {
const geo = (await getJson(
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${KEY}`,
)) as { status: string; results: { geometry: { location: { lat: number; lng: number } } }[] };
if (geo.status !== "OK" || !geo.results[0]) return base;
const { lat, lng } = geo.results[0].geometry.location;
const out: CustomerAddress = { ...base, lat, lng };
const driving = locations.filter((l) => l.lat != null && l.zones.some((z) => z.drivingKm != null));
if (driving.length) {
const dm = (await getJson(
`https://maps.googleapis.com/maps/api/distancematrix/json?origins=${driving
.map((l) => `${l.lat},${l.lng}`)
.join("|")}&destinations=${lat},${lng}&mode=driving&key=${KEY}`,
)) as { rows: { elements: { status: string; distance?: { value: number } }[] }[] };
out.drivingKm = {};
driving.forEach((l, i) => {
const el = dm.rows[i]?.elements[0];
out.drivingKm![l.id] = el?.status === "OK" && el.distance ? el.distance.value / 1000 : null;
});
}
cache.set(ck, { at: Date.now(), v: out });
return out;
} catch {
return { ...base, degraded: true };
}
}

View File

@ -0,0 +1,81 @@
/**
* Outbound messages. Email goes through the app's Resend account (reply-to = merchant);
* SMS and WhatsApp use the merchant's own Twilio / Meta Cloud API credentials so
* messaging costs stay on their account.
*
* WhatsApp business-initiated messages must use a pre-approved template. Datewise sends
* the merchant's chosen template with the rendered reminder text as body parameter {{1}}.
*/
import { decrypt } from "./crypto.server";
import type { AppSettings } from "./settings";
export type Channel = "email" | "sms" | "whatsapp";
export interface OutboundMessage {
channel: Channel;
to: string;
subject?: string;
body: string;
}
export class NotConfigured extends Error {}
async function post(url: string, init: RequestInit) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(url, { ...init, signal: ctrl.signal });
if (!r.ok) throw new Error(`${new URL(url).host} ${r.status}: ${(await r.text()).slice(0, 300)}`);
return r;
} finally {
clearTimeout(t);
}
}
export async function send(settings: AppSettings, shopName: string, msg: OutboundMessage): Promise<void> {
if (msg.channel === "email") {
const key = process.env.RESEND_API_KEY;
const domain = process.env.EMAIL_FROM_DOMAIN;
if (!key || !domain) throw new NotConfigured("Email sending is not configured on the server");
const from = `${settings.messaging.emailFromName || shopName} <reminders@${domain}>`;
await post("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
from,
to: [msg.to],
reply_to: settings.messaging.replyTo || undefined,
subject: msg.subject ?? `Your order with ${shopName}`,
text: msg.body,
}),
});
return;
}
if (msg.channel === "sms") {
const t = settings.messaging.twilio;
if (!t) throw new NotConfigured("Connect Twilio in Settings to send SMS");
const auth = Buffer.from(`${t.accountSid}:${decrypt(t.authTokenEnc)}`).toString("base64");
await post(`https://api.twilio.com/2010-04-01/Accounts/${t.accountSid}/Messages.json`, {
method: "POST",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ To: msg.to, From: t.from, Body: msg.body }),
});
return;
}
const w = settings.messaging.whatsapp;
if (!w) throw new NotConfigured("Connect WhatsApp in Settings to send WhatsApp messages");
await post(`https://graph.facebook.com/v21.0/${w.phoneNumberId}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${decrypt(w.tokenEnc)}`, "Content-Type": "application/json" },
body: JSON.stringify({
messaging_product: "whatsapp",
to: msg.to.replace(/[^\d]/g, ""),
type: "template",
template: {
name: w.templateName,
language: { code: w.templateLang },
components: [{ type: "body", parameters: [{ type: "text", text: msg.body.slice(0, 1000) }] }],
},
}),
});
}

View File

@ -0,0 +1,160 @@
/**
* Order webhooks → bookings.
*
* orders/create confirm the hold (idempotent), verify the fee that was paid,
* tag the order and write a booking metafield, plan reminders.
* orders/cancelled release capacity (per policy) and offer it to the waitlist.
* orders/fulfilled mark fulfilled.
*
* Orders that reach us without a selection (Buy now, accelerated checkouts that
* skipped the cart, draft orders) go to the review queue when scheduling is required.
*/
import prisma from "../db.server";
import { capsFor } from "../engine/capacity";
import { evaluateRules } from "../engine/rules";
import { zonedToUtc } from "../engine/time";
import { loadShopConfig } from "./config.server";
import { features } from "./plans";
import { scheduleReminders } from "./reminders.server";
import { ReservationService } from "./reservations.server";
import { SELECTION_ATTR, SelectionAttrSchema } from "./settings";
import { offerFreedPlace } from "./waitlist.server";
const reservations = new ReservationService(prisma);
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
export interface OrderPayload {
id: number;
admin_graphql_api_id: string;
name: string;
email?: string | null;
phone?: string | null;
note_attributes?: { name: string; value: string }[];
customer?: { first_name?: string | null; last_name?: string | null; email?: string | null; phone?: string | null } | null;
shipping_address?: { address1?: string; address2?: string; city?: string; zip?: string; latitude?: number | null; longitude?: number | null; phone?: string | null; first_name?: string; last_name?: string } | null;
line_items: { title: string; quantity: number; price: string; product_id: number | null; variant_id: number | null; requires_shipping?: boolean }[];
source_name?: string;
}
/** Webhook de-duplication by Shopify event id (falls back to topic+order). */
export async function firstDelivery(eventId: string, topic: string, shop: string): Promise<boolean> {
try {
await prisma.processedWebhook.create({ data: { id: eventId, topic, shop } });
return true;
} catch {
return false;
}
}
export async function handleOrderCreate(domain: string, order: OrderPayload, admin?: AdminGraphql) {
const cfg = await loadShopConfig(domain, { fresh: true });
const feeProductNumeric = cfg.shop.feeProductId?.split("/").pop();
const feeLines = order.line_items.filter((l) => feeProductNumeric && String(l.product_id) === feeProductNumeric);
const goods = order.line_items.filter((l) => !feeLines.includes(l));
const paidFee = feeLines.reduce((s, l) => s + Math.round(Number(l.price) * 100) * l.quantity, 0);
const raw = order.note_attributes?.find((a) => a.name === SELECTION_ATTR)?.value;
const parsed = raw ? SelectionAttrSchema.safeParse(safeJson(raw)) : null;
const attr = parsed?.success ? parsed.data : null;
const customerName = [order.customer?.first_name ?? order.shipping_address?.first_name, order.customer?.last_name ?? order.shipping_address?.last_name].filter(Boolean).join(" ") || undefined;
const sa = order.shipping_address;
const details = {
orderName: order.name,
timezone: cfg.shop.timezone,
customerName,
customerEmail: order.email ?? order.customer?.email ?? undefined,
customerPhone: order.phone ?? sa?.phone ?? order.customer?.phone ?? undefined,
address: sa ? { line1: [sa.address1, sa.address2].filter(Boolean).join(", "), city: sa.city, postal: sa.zip, lat: sa.latitude ?? undefined, lng: sa.longitude ?? undefined } : undefined,
items: goods.map((l) => ({ title: l.title, quantity: l.quantity })),
fee: paidFee,
configVersion: attr?.v ?? cfg.version,
snapshot: { attr: raw ?? null, source: order.source_name ?? null, paidFee },
};
const orderId = order.admin_graphql_api_id;
if (!attr) {
// Does this order need a time at all?
const cons = evaluateRules({ items: goods.map((l) => ({ productId: `gid://shopify/Product/${l.product_id}`, variantId: `gid://shopify/ProductVariant/${l.variant_id}`, title: l.title, quantity: l.quantity, price: 0 })) }, cfg.rules);
const needs = goods.length > 0 && (cfg.settings.requireScheduling || cons.requiresScheduling);
if (!needs) return { skipped: true };
const r = await reservations.confirm(cfg.shop.id, orderId, null, null, { dayCap: 0, slotCap: 0 }, new Date(), details);
await tagOrder(admin, orderId, ["Datewise: needs time"]);
return r;
}
const loc = cfg.locations.find((l) => l.id === attr.l);
const sel = { locationId: attr.l, method: attr.m, date: attr.d, slot: attr.s };
const caps = loc ? capsFor(loc, attr.m, attr.d, attr.s, features(cfg.shop.plan).peakDays ? cfg.peakDays : []) : { dayCap: 0, slotCap: 0 };
const [start, end] = attr.s.split("-");
const hold = await prisma.hold.findUnique({ where: { token: attr.t } });
const quoted = hold?.fee ?? attr.f;
const peak = cfg.peakDays.find((p) => p.dates.includes(attr.d) && (!p.locationIds?.length || p.locationIds.includes(attr.l)));
const r = await reservations.confirm(cfg.shop.id, orderId, attr.t, loc ? sel : null, caps, new Date(), {
...details,
startUtc: new Date(zonedToUtc(attr.d, start, cfg.shop.timezone)),
endUtc: new Date(zonedToUtc(attr.d, end, cfg.shop.timezone)),
peakDayId: peak?.id ?? null,
snapshot: { ...details.snapshot, quotedFee: quoted, location: loc?.name ?? null },
});
if (r.duplicate) return r;
if (r.ok && paidFee !== quoted) {
// Keep the place (the customer did book it) but flag the money difference for staff.
await prisma.booking.update({
where: { id: r.bookingId },
data: { reviewReason: `Fee paid ${(paidFee / 100).toFixed(2)} differs from quote ${(quoted / 100).toFixed(2)}` },
});
}
const label = cfg.settings.widget.labels[attr.m];
await tagOrder(admin, orderId, r.ok ? [`Datewise`, `${label}: ${attr.d}`] : ["Datewise: needs review"]);
await writeBookingMetafield(admin, orderId, { method: attr.m, location: loc?.name, date: attr.d, start, end, status: r.ok ? "confirmed" : "needs_review" });
if (r.ok) await scheduleReminders(r.bookingId);
return r;
}
export async function handleOrderCancelled(domain: string, order: { admin_graphql_api_id: string }) {
const cfg = await loadShopConfig(domain);
if (!cfg.settings.releaseOnCancel) {
const b = await prisma.booking.findUnique({ where: { shopId_orderId: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id } } });
if (b) await prisma.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } });
return { freed: null };
}
const r = await reservations.cancel(cfg.shop.id, order.admin_graphql_api_id);
if (r.freed) await offerFreedPlace(domain, r.freed);
return r;
}
export async function handleOrderFulfilled(domain: string, order: { admin_graphql_api_id: string }) {
const cfg = await loadShopConfig(domain);
await prisma.booking.updateMany({
where: { shopId: cfg.shop.id, orderId: order.admin_graphql_api_id, status: "confirmed" },
data: { status: "fulfilled" },
});
}
async function tagOrder(admin: AdminGraphql | undefined, id: string, tags: string[]) {
if (!admin) return;
await admin
.graphql(`#graphql
mutation($id: ID!, $tags: [String!]!) { tagsAdd(id: $id, tags: $tags) { userErrors { message } } }`, { variables: { id, tags } })
.catch((e) => console.error("tagsAdd failed", e));
}
async function writeBookingMetafield(admin: AdminGraphql | undefined, ownerId: string, value: object) {
if (!admin) return;
await admin
.graphql(`#graphql
mutation($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { message } } }`, {
variables: { m: [{ ownerId, namespace: "datewise", key: "booking", type: "json", value: JSON.stringify(value) }] },
})
.catch((e) => console.error("metafieldsSet failed", e));
}
function safeJson(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}

32
app/services/plans.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* Feature gates per billing plan. "free" = no active subscription.
* Keep in sync with the App Store listing and shopify.server.ts billing config.
*/
export interface Features {
maxLocations: number;
monthlyBookings: number | null;
productRules: boolean;
inventoryAware: boolean;
peakDays: boolean;
waitlist: boolean;
freshness: boolean;
smsWhatsapp: boolean;
runSheets: boolean;
drivingDistance: boolean;
}
const PLANS: Record<string, Features> = {
free: { maxLocations: 1, monthlyBookings: 50, productRules: true, inventoryAware: false, peakDays: false, waitlist: false, freshness: false, smsWhatsapp: false, runSheets: false, drivingDistance: false },
Growth: { maxLocations: 3, monthlyBookings: null, productRules: true, inventoryAware: true, peakDays: true, waitlist: true, freshness: true, smsWhatsapp: false, runSheets: true, drivingDistance: false },
Pro: { maxLocations: 50, monthlyBookings: null, productRules: true, inventoryAware: true, peakDays: true, waitlist: true, freshness: true, smsWhatsapp: true, runSheets: true, drivingDistance: true },
};
export function features(plan: string): Features {
return PLANS[plan] ?? PLANS.free;
}
export const PLAN_COPY = [
{ id: "free", name: "Starter", price: "Free", points: ["1 location", "50 scheduled orders a month", "Capacity holds and cutoffs", "Email reminders"] },
{ id: "Growth", name: "Growth", price: "$19/month", points: ["Up to 3 locations", "Unlimited orders", "Festival rush control and waitlist", "Fresh-stock windows", "Driver run sheets"] },
{ id: "Pro", name: "Pro", price: "$49/month", points: ["Up to 50 locations", "SMS and WhatsApp reminders", "Driving-distance zones", "Everything in Growth"] },
];

View File

@ -0,0 +1,95 @@
/**
* Reminder jobs: planned from the engine whenever a booking is confirmed or moved,
* sent by the worker. Retries with backoff; never sends for cancelled bookings.
*/
import type { Booking as BookingRow } from "@prisma/client";
import prisma from "../db.server";
import { planReminders } from "../engine/customer";
import type { Booking, Method } from "../engine/types";
import { loadShopConfig } from "./config.server";
import { NotConfigured, send } from "./notifications.server";
import { features } from "./plans";
export function rowToBooking(b: BookingRow): Booking {
return {
id: b.id,
orderId: b.orderId ?? undefined,
orderName: b.orderName ?? undefined,
status: b.status as Booking["status"],
method: b.method as Method,
locationId: b.locationId ?? "",
date: b.date ?? "",
slotStart: b.slotStart ?? undefined,
slotEnd: b.slotEnd ?? undefined,
timezone: b.timezone,
customer: { name: b.customerName ?? undefined, email: b.customerEmail ?? undefined, phone: b.customerPhone ?? undefined },
address: (b.address as Booking["address"]) ?? undefined,
items: (b.items as Booking["items"]) ?? undefined,
rescheduleCount: b.rescheduleCount,
};
}
const fmtTime = (t: string) => {
const [h, m] = t.split(":").map(Number);
return `${h % 12 || 12}${m ? `:${String(m).padStart(2, "0")}` : ""} ${h >= 12 ? "PM" : "AM"}`;
};
export async function scheduleReminders(bookingId: string, now = Date.now()) {
const row = await prisma.booking.findUniqueOrThrow({ where: { id: bookingId }, include: { shop: true } });
await prisma.reminderJob.updateMany({ where: { bookingId, status: "pending" }, data: { status: "cancelled" } });
if (row.status !== "confirmed" || !row.date) return 0;
const cfg = await loadShopConfig(row.shop.domain);
const f = features(cfg.shop.plan);
const b = rowToBooking(row);
const loc = cfg.locations.find((l) => l.id === b.locationId);
const policies = cfg.settings.reminders.filter((p) => p.channel === "email" || f.smsWhatsapp);
const planned = planReminders(b, policies, now, {
date: new Date(`${b.date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }),
time: b.slotStart && b.slotEnd ? `${fmtTime(b.slotStart)}–${fmtTime(b.slotEnd)}` : "",
method: cfg.settings.widget.labels[b.method].toLowerCase(),
location: loc?.name ?? "",
});
if (planned.length)
await prisma.reminderJob.createMany({
data: planned.map((p) => ({ shopId: row.shopId, bookingId, policyId: p.policyId, channel: p.channel, sendAt: new Date(p.sendAt), body: p.body })),
});
return planned.length;
}
export async function runDueReminders(now = new Date(), batch = 100) {
const due = await prisma.reminderJob.findMany({
where: { status: "pending", sendAt: { lte: now }, attempts: { lt: 5 } },
include: { booking: true, shop: true },
take: batch,
orderBy: { sendAt: "asc" },
});
let sent = 0;
for (const j of due) {
// Claim the job so parallel workers don't double-send.
const claimed = await prisma.reminderJob.updateMany({ where: { id: j.id, status: "pending" }, data: { status: "sending", attempts: { increment: 1 } } });
if (!claimed.count) continue;
if (j.booking.status !== "confirmed") {
await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "cancelled" } });
continue;
}
const to = j.channel === "email" ? j.booking.customerEmail : j.booking.customerPhone;
try {
if (!to) throw new NotConfigured("No contact for channel");
const cfg = await loadShopConfig(j.shop.domain);
await send(cfg.settings, j.shop.domain.replace(".myshopify.com", ""), { channel: j.channel as "email", to, body: j.body, subject: `Reminder: order ${j.booking.orderName ?? ""}` });
await prisma.reminderJob.update({ where: { id: j.id }, data: { status: "sent", sentAt: new Date() } });
sent++;
} catch (e) {
const permanent = e instanceof NotConfigured || j.attempts + 1 >= 5;
await prisma.reminderJob.update({
where: { id: j.id },
data: {
status: permanent ? "failed" : "pending",
lastError: String((e as Error).message).slice(0, 500),
sendAt: permanent ? undefined : new Date(now.getTime() + 2 ** j.attempts * 60_000),
},
});
}
}
return sent;
}

View File

@ -0,0 +1,411 @@
/**
* Postgres reservation store — production twin of engine/capacity.ts MemoryReservationStore.
*
* Concurrency model
* - Every (shop, location, method, date, slot) has a CapacityCounter row; slot "*" is the day total.
* - Counters = confirmed bookings + holds that have not been swept yet.
* - Any mutation runs in one transaction that locks the counter rows it touches
* with SELECT … FOR UPDATE, always in sorted key order (no deadlocks; "*" sorts
* before "HH:MM" so the day row is always locked first).
* - Expired holds for a locked date are swept inside the same transaction, so a stale
* hold can never block a live customer.
*
* Guarantees (covered by reservations.server.test.ts against real Postgres):
* - N concurrent customers for the last K places → exactly K succeed.
* - Duplicate order webhooks → one booking (unique shopId+orderId).
* - Expired hold at order time → re-reserved if space, otherwise flagged needs_review.
* - Reschedule releases old + reserves new atomically; failure leaves the booking untouched.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import type { Caps, Selection } from "../engine/capacity";
import type { Method, UsageSnapshot } from "../engine/types";
import { usageKey } from "../engine/availability";
type Tx = Prisma.TransactionClient;
interface Key {
locationId: string;
method: string;
date: string;
slot: string;
}
const keyStr = (k: Key) => `${k.locationId}|${k.method}|${k.date}|${k.slot}`;
const keysFor = (s: Selection): Key[] => [
{ locationId: s.locationId, method: s.method, date: s.date, slot: "*" },
{ locationId: s.locationId, method: s.method, date: s.date, slot: s.slot },
];
const sameSel = (a: Selection, b: Selection) =>
a.locationId === b.locationId && a.method === b.method && a.date === b.date && a.slot === b.slot;
export type HoldResult =
| { ok: true; token: string; expiresAt: Date }
| { ok: false; code: "DAY_FULL" | "SLOT_FULL"; message: string };
export interface BookingDetails {
orderName?: string;
timezone: string;
startUtc?: Date;
endUtc?: Date;
customerName?: string;
customerEmail?: string;
customerPhone?: string;
address?: Prisma.InputJsonValue;
items?: Prisma.InputJsonValue;
fee?: number;
peakDayId?: string | null;
configVersion: number;
snapshot: Prisma.InputJsonValue;
}
export type ConfirmResult =
| { ok: true; bookingId: string; duplicate: boolean; reReserved: boolean }
| { ok: false; bookingId: string; code: "NEEDS_REVIEW"; message: string; duplicate: boolean };
export class ReservationService {
constructor(
private db: PrismaClient,
private opts: { txTimeoutMs?: number } = {},
) {}
private tx<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
return this.db.$transaction(fn, { timeout: this.opts.txTimeoutMs ?? 10000, maxWait: 10000 });
}
/** Ensure + lock counter rows in deterministic order; returns counts by key. */
private async lock(tx: Tx, shopId: string, keys: Key[]): Promise<Map<string, number>> {
const uniq = [...new Map(keys.map((k) => [keyStr(k), k])).values()].sort((a, b) =>
keyStr(a) < keyStr(b) ? -1 : 1,
);
const out = new Map<string, number>();
for (const k of uniq) {
await tx.$executeRaw`
INSERT INTO "CapacityCounter" ("shopId","locationId","method","date","slot","count")
VALUES (${shopId}, ${k.locationId}, ${k.method}, ${k.date}, ${k.slot}, 0)
ON CONFLICT DO NOTHING`;
const rows = await tx.$queryRaw<{ count: number }[]>`
SELECT "count" FROM "CapacityCounter"
WHERE "shopId"=${shopId} AND "locationId"=${k.locationId} AND "method"=${k.method}
AND "date"=${k.date} AND "slot"=${k.slot}
FOR UPDATE`;
out.set(keyStr(k), Number(rows[0].count));
}
return out;
}
private async bump(tx: Tx, shopId: string, sel: Selection, delta: 1 | -1, counts?: Map<string, number>) {
for (const k of keysFor(sel)) {
await tx.$executeRaw`
UPDATE "CapacityCounter" SET "count" = GREATEST(0, "count" + ${delta})
WHERE "shopId"=${shopId} AND "locationId"=${k.locationId} AND "method"=${k.method}
AND "date"=${k.date} AND "slot"=${k.slot}`;
if (counts) counts.set(keyStr(k), Math.max(0, (counts.get(keyStr(k)) ?? 0) + delta));
}
}
/** Delete expired holds for the dates whose day rows we hold locks on. */
private async sweepLocked(tx: Tx, shopId: string, keys: Key[], now: Date, counts: Map<string, number>, keep?: string) {
const days = [...new Map(keys.filter((k) => k.slot === "*").map((k) => [keyStr(k), k])).values()];
for (const d of days) {
const expired = await tx.hold.findMany({
where: { shopId, locationId: d.locationId, method: d.method, date: d.date, expiresAt: { lte: now }, NOT: keep ? { token: keep } : undefined },
});
for (const h of expired) {
await tx.hold.delete({ where: { token: h.token } });
await this.ensureLocked(tx, shopId, keysFor(h as Selection), counts);
await this.bump(tx, shopId, h as Selection, -1, counts);
}
}
}
/** Slot rows for expired holds may not have been in the original lock set. */
private async ensureLocked(tx: Tx, shopId: string, keys: Key[], counts: Map<string, number>) {
const missing = keys.filter((k) => !counts.has(keyStr(k)));
if (missing.length) for (const [k, v] of await this.lock(tx, shopId, missing)) counts.set(k, v);
}
private fits(counts: Map<string, number>, sel: Selection, caps: Caps): null | "DAY_FULL" | "SLOT_FULL" {
const [day, slot] = keysFor(sel).map((k) => counts.get(keyStr(k)) ?? 0);
if (caps.dayCap != null && day >= caps.dayCap) return "DAY_FULL";
if (caps.slotCap != null && slot >= caps.slotCap) return "SLOT_FULL";
return null;
}
async hold(
shopId: string,
token: string,
sel: Selection,
caps: Caps,
now: Date,
holdMinutes: number,
fee = 0,
): Promise<HoldResult> {
const expiresAt = new Date(now.getTime() + holdMinutes * 60000);
return this.tx(async (tx) => {
const prev = await tx.hold.findUnique({ where: { token } });
if (prev && prev.shopId !== shopId) throw new Error("token belongs to another shop");
const keys = [...keysFor(sel), ...(prev ? keysFor(prev as Selection) : [])];
const counts = await this.lock(tx, shopId, keys);
await this.sweepLocked(tx, shopId, keys, now, counts, token);
const live = prev && prev.expiresAt > now;
if (prev && live && sameSel(prev as Selection, sel)) {
// Re-confirming the same place never shortens an existing (e.g. waitlist-offer) hold.
const until = prev.expiresAt > expiresAt ? prev.expiresAt : expiresAt;
await tx.hold.update({ where: { token }, data: { expiresAt: until, fee } });
return { ok: true as const, token, expiresAt: until };
}
if (prev) {
// Moving (or reviving) the hold: release the previous place first.
await this.bump(tx, shopId, prev as Selection, -1, counts);
await tx.hold.delete({ where: { token } });
}
const full = this.fits(counts, sel, caps);
if (full)
return {
ok: false as const,
code: full,
message: full === "DAY_FULL" ? "Fully booked for this day" : "This time slot was just taken",
};
await this.bump(tx, shopId, sel, 1, counts);
await tx.hold.create({ data: { token, shopId, ...sel, expiresAt, fee } });
return { ok: true as const, token, expiresAt };
});
}
async release(shopId: string, token: string): Promise<void> {
await this.tx(async (tx) => {
const h = await tx.hold.findUnique({ where: { token } });
if (!h || h.shopId !== shopId) return;
await this.lock(tx, shopId, keysFor(h as Selection));
await tx.hold.delete({ where: { token } });
await this.bump(tx, shopId, h as Selection, -1);
});
}
async confirm(
shopId: string,
orderId: string,
token: string | null,
fallback: (Selection & { method: Method }) | null,
caps: Caps,
now: Date,
details: BookingDetails,
): Promise<ConfirmResult> {
const existing = await this.db.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } });
if (existing)
return existing.status === "needs_review"
? { ok: false, bookingId: existing.id, code: "NEEDS_REVIEW", message: existing.reviewReason ?? "", duplicate: true }
: { ok: true, bookingId: existing.id, duplicate: true, reReserved: false };
try {
return await this.tx(async (tx) => {
const hold = token ? await tx.hold.findUnique({ where: { token } }) : null;
const sel: Selection | null = hold && hold.shopId === shopId ? (hold as Selection) : fallback;
const base = {
shopId,
orderId,
orderName: details.orderName,
timezone: details.timezone,
customerName: details.customerName,
customerEmail: details.customerEmail,
customerPhone: details.customerPhone,
address: details.address,
items: details.items,
fee: details.fee ?? 0,
peakDayId: details.peakDayId ?? null,
configVersion: details.configVersion,
snapshot: details.snapshot,
};
if (!sel) {
const b = await tx.booking.create({
data: { ...base, status: "needs_review", method: "pickup", reviewReason: "Order has no scheduling data" },
});
await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "flagged", after: { reason: "missing" } } });
return { ok: false as const, bookingId: b.id, code: "NEEDS_REVIEW" as const, message: "Order has no scheduling data", duplicate: false };
}
const keys = keysFor(sel);
const counts = await this.lock(tx, shopId, keys);
// Re-check under the lock: a concurrent duplicate webhook may have committed meanwhile.
const already = await tx.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } });
if (already) throw new DuplicateSignal();
const lockedHold = token ? await tx.hold.findUnique({ where: { token } }) : null;
const live = !!lockedHold && lockedHold.shopId === shopId && lockedHold.expiresAt > now && sameSel(lockedHold as Selection, sel);
// Sweep everything expired, including this order's own stale hold.
await this.sweepLocked(tx, shopId, keys, now, counts, live ? token! : undefined);
const [slotStart, slotEnd] = sel.slot.split("-");
const slotData = {
method: sel.method,
locationId: sel.locationId,
date: sel.date,
slotStart,
slotEnd,
startUtc: details.startUtc,
endUtc: details.endUtc,
};
let reReserved = false;
if (live) {
await tx.hold.delete({ where: { token: token! } }); // place converts from hold to booking; counter unchanged
} else {
const full = this.fits(counts, sel, caps);
if (full) {
const reason = token ? "Hold expired and the slot filled up" : "Selected slot was full when the order arrived";
const b = await tx.booking.create({ data: { ...base, ...slotData, status: "needs_review", reviewReason: reason } });
await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "flagged", after: { reason: full } } });
return { ok: false as const, bookingId: b.id, code: "NEEDS_REVIEW" as const, message: reason, duplicate: false };
}
await this.bump(tx, shopId, sel, 1, counts);
reReserved = true;
}
const b = await tx.booking.create({ data: { ...base, ...slotData, status: "confirmed" } });
await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor: "system", action: "confirmed", after: { ...sel, reReserved } } });
return { ok: true as const, bookingId: b.id, duplicate: false, reReserved };
});
} catch (e: unknown) {
// Concurrent duplicate webhook lost the race on the unique index: whole tx rolled back.
if (e instanceof DuplicateSignal || isUniqueViolation(e)) {
const b = await this.db.booking.findUniqueOrThrow({ where: { shopId_orderId: { shopId, orderId } } });
return b.status === "needs_review"
? { ok: false, bookingId: b.id, code: "NEEDS_REVIEW", message: b.reviewReason ?? "", duplicate: true }
: { ok: true, bookingId: b.id, duplicate: true, reReserved: false };
}
throw e;
}
}
/**
* Move a booking. Also used to resolve needs_review bookings (they hold no capacity yet).
* `force` lets a merchant knowingly exceed capacity (recorded in the audit log).
*/
async reschedule(
shopId: string,
bookingId: string,
sel: Selection,
caps: Caps,
now: Date,
actor: string,
extra: { startUtc?: Date; endUtc?: Date; force?: boolean } = {},
): Promise<{ ok: true; freed: Selection | null } | { ok: false; message: string }> {
return this.tx(async (tx) => {
const b = await tx.booking.findFirst({ where: { id: bookingId, shopId } });
if (!b || !["confirmed", "needs_review"].includes(b.status)) return { ok: false as const, message: "Booking can't be rescheduled" };
const old: Selection | null =
b.status === "confirmed" && b.locationId && b.date && b.slotStart
? { locationId: b.locationId, method: b.method as Method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` }
: null;
if (old && sameSel(old, sel)) return { ok: true as const, freed: null };
const keys = [...keysFor(sel), ...(old ? keysFor(old) : [])];
const counts = await this.lock(tx, shopId, keys);
await this.sweepLocked(tx, shopId, keys, now, counts);
// Evaluate the new place as if the old one were already released.
if (old) await this.bump(tx, shopId, old, -1, counts);
const full = this.fits(counts, sel, caps);
if (full && !extra.force) throw new RollbackSignal(full === "DAY_FULL" ? "That day is fully booked" : "That slot is full");
await this.bump(tx, shopId, sel, 1, counts);
const [slotStart, slotEnd] = sel.slot.split("-");
await tx.booking.update({
where: { id: b.id },
data: {
locationId: sel.locationId,
method: sel.method,
date: sel.date,
slotStart,
slotEnd,
startUtc: extra.startUtc,
endUtc: extra.endUtc,
status: "confirmed",
reviewReason: null,
rescheduleCount: actor === "customer" ? { increment: 1 } : undefined,
},
});
await tx.bookingEvent.create({
data: {
shopId,
bookingId: b.id,
actor,
action: b.status === "needs_review" ? "resolved" : "rescheduled",
before: old ? { ...old } : { status: b.status },
after: { ...sel, forced: !!(full && extra.force) },
},
});
return { ok: true as const, freed: old };
}).catch((e) => {
if (e instanceof RollbackSignal) return { ok: false as const, message: e.message };
throw e;
});
}
async cancel(shopId: string, orderId: string, actor = "system"): Promise<{ freed: Selection | null; bookingId: string | null }> {
return this.tx(async (tx) => {
const b = await tx.booking.findUnique({ where: { shopId_orderId: { shopId, orderId } } });
if (!b || b.status === "cancelled") return { freed: null, bookingId: b?.id ?? null };
let freed: Selection | null = null;
if (b.status === "confirmed" && b.locationId && b.date && b.slotStart) {
freed = { locationId: b.locationId, method: b.method as Method, date: b.date, slot: `${b.slotStart}-${b.slotEnd}` };
await this.lock(tx, shopId, keysFor(freed));
await this.bump(tx, shopId, freed, -1);
}
await tx.booking.update({ where: { id: b.id }, data: { status: "cancelled" } });
await tx.bookingEvent.create({ data: { shopId, bookingId: b.id, actor, action: "cancelled", before: { status: b.status } } });
await tx.reminderJob.updateMany({ where: { bookingId: b.id, status: "pending" }, data: { status: "cancelled" } });
return { freed, bookingId: b.id };
});
}
/** Usage snapshot for the engine. Excludes expired-but-unswept holds and, optionally, the caller's own hold. */
async usage(shopId: string, fromDate: string, toDate: string, now: Date, excludeToken?: string): Promise<UsageSnapshot> {
const [counters, stale] = await Promise.all([
this.db.capacityCounter.findMany({ where: { shopId, date: { gte: fromDate, lte: toDate } } }),
this.db.hold.findMany({
where: {
shopId,
date: { gte: fromDate, lte: toDate },
OR: [{ expiresAt: { lte: now } }, ...(excludeToken ? [{ token: excludeToken }] : [])],
},
}),
]);
const out: UsageSnapshot = {};
for (const c of counters) {
const k = usageKey(c.locationId, c.method as Method, c.date);
out[k] ??= { day: 0, slots: {} };
if (c.slot === "*") out[k].day += c.count;
else out[k].slots[c.slot] = (out[k].slots[c.slot] ?? 0) + c.count;
}
for (const h of stale) {
const k = usageKey(h.locationId, h.method as Method, h.date);
if (!out[k]) continue;
out[k].day = Math.max(0, out[k].day - 1);
out[k].slots[h.slot] = Math.max(0, (out[k].slots[h.slot] ?? 0) - 1);
}
return out;
}
/** Background sweep (worker, every minute). Returns number of holds released. */
async sweepExpired(now: Date, limit = 500, shopId?: string): Promise<number> {
const expired = await this.db.hold.findMany({ where: { expiresAt: { lte: now }, ...(shopId ? { shopId } : {}) }, take: limit, orderBy: { expiresAt: "asc" } });
const groups = new Map<string, { shopId: string; key: Key }>();
for (const h of expired)
groups.set(`${h.shopId}|${h.locationId}|${h.method}|${h.date}`, {
shopId: h.shopId,
key: { locationId: h.locationId, method: h.method, date: h.date, slot: "*" },
});
let n = 0;
for (const { shopId, key } of groups.values()) {
n += await this.tx(async (tx) => {
const counts = await this.lock(tx, shopId, [key]);
const before = await tx.hold.count({ where: { shopId, locationId: key.locationId, method: key.method, date: key.date, expiresAt: { lte: now } } });
await this.sweepLocked(tx, shopId, [key], now, counts);
return before;
});
}
return n;
}
}
class RollbackSignal extends Error {}
class DuplicateSignal extends Error {}
function isUniqueViolation(e: unknown): boolean {
const err = e as { code?: string; message?: string; meta?: { code?: string } };
return err?.code === "P2002" || err?.meta?.code === "23505" || /unique constraint|23505/i.test(err?.message ?? "");
}

166
app/services/settings.ts Normal file
View File

@ -0,0 +1,166 @@
/**
* App-level settings stored in Shop.settings (JSON). Engine settings + widget + messaging.
* Shared by server and admin UI (no server-only imports here).
*/
import { z } from "zod";
import type { ShopSettings } from "../engine/types";
export interface WidgetSettings {
heading: string;
labels: { pickup: string; delivery: string; shipping: string };
accent: string;
dateFormat: "short" | "long";
showRemaining: boolean;
/** Show the calendar inside the cart drawer too (app embed). */
drawer: boolean;
/** Per-locale overrides of customer-facing text. */
translations: Record<string, Partial<{ heading: string; pickup: string; delivery: string; shipping: string }>>;
}
export interface AppSettings extends ShopSettings {
/** Block checkout (validation function) until a time is chosen. */
requireScheduling: boolean;
/** Minutes a freed place is held for the first person on a waitlist. */
waitlistOfferMinutes: number;
/** Return capacity when an order is cancelled. */
releaseOnCancel: boolean;
locationRanking: "priority" | "distance" | "earliest";
widget: WidgetSettings;
messaging: {
emailFromName: string;
replyTo?: string;
/** Encrypted with APP_ENCRYPTION_KEY (services/crypto.server.ts). */
twilio?: { accountSid: string; authTokenEnc: string; from: string } | null;
whatsapp?: { phoneNumberId: string; tokenEnc: string; templateName: string; templateLang: string } | null;
};
}
export const DEFAULT_SETTINGS: AppSettings = {
mixedPrepPolicy: "longest",
holdMinutes: 10,
weekStartsOn: 0,
reschedule: { enabled: true, minHoursBefore: 24, maxChanges: 2, allowMethodChange: false },
reminders: [
{
id: "day-before",
channel: "email",
minutesBefore: 1440,
enabled: true,
template: "Hi {{name}}, a reminder that your {{method}} for order {{order}} is on {{date}}, {{time}}. Need a different time? Use the link on your order page.",
},
{
id: "out-for-delivery",
channel: "sms",
minutesBefore: 90,
methods: ["delivery"],
enabled: false,
template: "{{name}}, order {{order}} arrives today {{time}}.",
},
],
requireScheduling: true,
waitlistOfferMinutes: 30,
releaseOnCancel: true,
locationRanking: "priority",
widget: {
heading: "When would you like it?",
labels: { pickup: "Store pickup", delivery: "Local delivery", shipping: "Shipping" },
accent: "#0D6B5D",
dateFormat: "short",
showRemaining: true,
drawer: true,
translations: {},
},
messaging: { emailFromName: "", twilio: null, whatsapp: null },
};
/** Deep-merge stored settings over defaults so new keys appear after upgrades. */
export function withDefaults(stored: unknown): AppSettings {
const s = (stored ?? {}) as Partial<AppSettings>;
return {
...DEFAULT_SETTINGS,
...s,
reschedule: { ...DEFAULT_SETTINGS.reschedule, ...(s.reschedule ?? {}) },
widget: {
...DEFAULT_SETTINGS.widget,
...(s.widget ?? {}),
labels: { ...DEFAULT_SETTINGS.widget.labels, ...(s.widget?.labels ?? {}) },
},
messaging: { ...DEFAULT_SETTINGS.messaging, ...(s.messaging ?? {}) },
reminders: s.reminders ?? DEFAULT_SETTINGS.reminders,
};
}
const hhmm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour HH:MM");
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
const method = z.enum(["pickup", "delivery", "shipping"]);
const range = z.object({ start: hhmm, end: hhmm }).refine((r) => r.start < r.end, "End must be after start");
export const MethodConfigSchema = z.object({
enabled: z.boolean(),
weeklyHours: z.record(z.string(), z.array(range)),
slotMode: z.enum(["generated", "explicit", "dateOnly"]),
slotMinutes: z.number().int().min(15).max(720).optional(),
slots: z
.record(z.string(), z.array(range.and(z.object({ capacity: z.number().int().min(0).nullable().optional(), surcharge: z.number().int().min(0).optional() }))))
.optional(),
prepMinutes: z.number().int().min(0).max(60 * 24 * 60),
prepMode: z.enum(["elapsed", "operating"]),
sameDayCutoff: hhmm.nullable().optional(),
nextDayCutoff: hhmm.nullable().optional(),
horizonDays: z.number().int().min(1).max(365),
dailyCapacity: z.number().int().min(0).nullable().optional(),
slotCapacity: z.number().int().min(0).nullable().optional(),
});
export const ExceptionSchema = z.object({
date: ymd,
closed: z.boolean().optional(),
hours: z.array(range).optional(),
methods: z.array(method).optional(),
note: z.string().max(200).optional(),
});
export const ZoneSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
postalPatterns: z.array(z.string()).optional(),
radiusKm: z.number().positive().nullable().optional(),
drivingKm: z.number().positive().nullable().optional(),
fee: z.number().int().min(0),
freeOver: z.number().int().min(0).nullable().optional(),
minOrder: z.number().int().min(0).nullable().optional(),
});
export const RuleTargetSchema = z.object({
type: z.enum(["product", "variant", "collection", "vendor", "productType", "tag"]),
values: z.array(z.string().min(1)).min(1),
});
export const RuleActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("prohibitMethods"), methods: z.array(method).min(1) }),
z.object({ type: z.literal("allowOnlyMethods"), methods: z.array(method).min(1) }),
z.object({ type: z.literal("restrictLocations"), locationIds: z.array(z.string()).min(1) }),
z.object({ type: z.literal("addPrep"), minutes: z.number().int().min(1) }),
z.object({ type: z.literal("blockWeekdays"), weekdays: z.array(z.number().int().min(0).max(6)).min(1) }),
z.object({ type: z.literal("blockDates"), dates: z.array(ymd).min(1) }),
z.object({ type: z.literal("dateWindow"), from: ymd.optional(), to: ymd.optional() }),
z.object({
type: z.literal("freshness"),
arrivals: z.array(z.object({ date: ymd, locationIds: z.array(z.string()).optional() })),
minDaysAfterArrival: z.number().int().min(0),
shelfLifeDays: z.number().int().min(1),
}),
z.object({ type: z.literal("requireScheduling") }),
]);
export const SelectionAttrSchema = z.object({
t: z.string().min(8).max(80), // hold token
m: method,
l: z.string(),
d: ymd,
s: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/),
f: z.number().int().min(0).default(0), // fee (minor units)
v: z.number().int().optional(), // config version
});
export type SelectionAttr = z.infer<typeof SelectionAttrSchema>;
export const SELECTION_ATTR = "_datewise";

View File

@ -0,0 +1,285 @@
/**
* Storefront API used by the theme widget via the app proxy.
* Every response is computed by the same engine the tests cover; the widget only renders it.
*/
import { randomBytes } from "node:crypto";
import { z } from "zod";
import prisma from "../db.server";
import { firstAvailable, validateSelection } from "../engine/availability";
import { capsFor } from "../engine/capacity";
import { rankLocations } from "../engine/locations";
import { evaluateRules } from "../engine/rules";
import { addDays, localParts } from "../engine/time";
import { METHODS, type Method } from "../engine/types";
import { quoteFee } from "../engine/zones";
import { enrichCart } from "./catalog.server";
import { type ShopConfig, loadShopConfig } from "./config.server";
import { ensureFeeVariant } from "./fees.server";
import { resolveAddress } from "./geocode.server";
import { features } from "./plans";
import { ReservationService } from "./reservations.server";
import { SELECTION_ATTR } from "./settings";
export const reservations = new ReservationService(prisma);
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
const LineSchema = z.object({
variantId: z.union([z.string(), z.number()]),
quantity: z.number().int().min(1).max(10000),
price: z.number().int().min(0).optional(),
title: z.string().max(300).optional(),
});
export const QuerySchema = z.object({
lines: z.array(LineSchema).max(250),
method: z.enum(["pickup", "delivery", "shipping"]).optional(),
locationId: z.string().optional(),
postal: z.string().max(12).optional(),
address: z.string().max(300).optional(),
country: z.string().max(60).optional(),
token: z.string().max(80).optional(),
locale: z.string().max(10).optional(),
});
export const HoldSchema = QuerySchema.extend({
method: z.enum(["pickup", "delivery", "shipping"]),
locationId: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
slot: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/),
});
async function context(domain: string, admin: AdminGraphql | undefined, q: z.infer<typeof QuerySchema>) {
const cfg = await loadShopConfig(domain);
const now = Date.now();
const s2a = Object.fromEntries(cfg.locations.filter((l) => l.shopifyLocationId).map((l) => [l.shopifyLocationId!, l.id]));
const { cart, inventory, feeLines } = admin
? await enrichCart(admin, domain, q.lines, s2a)
: { cart: { items: [], subtotal: 0 }, inventory: {}, feeLines: [] };
const address = await resolveAddress({ postal: q.postal, address: q.address, country: q.country }, cfg.locations);
const today = localParts(now, cfg.shop.timezone).date;
const usage = await reservations.usage(cfg.shop.id, addDays(today, -1), addDays(today, 370), new Date(now), q.token);
const f = features(cfg.shop.plan);
const locations = cfg.locations.slice(0, f.maxLocations);
return { cfg, now, cart, inventory, feeLines, address, usage, locations, f };
}
type Ctx = Awaited<ReturnType<typeof context>>;
function rank(ctx: Ctx, method: Method) {
const { cfg } = ctx;
return rankLocations({
locations: ctx.locations,
method,
cart: ctx.cart,
rules: ctx.f.productRules ? cfg.rules : [],
peakDays: ctx.f.peakDays ? cfg.peakDays : [],
usage: ctx.usage,
settings: cfg.settings,
now: ctx.now,
address: ctx.address,
inventory: ctx.f.inventoryAware ? ctx.inventory : undefined,
ranking: cfg.settings.locationRanking,
});
}
function widgetText(cfg: ShopConfig, locale?: string) {
const w = cfg.settings.widget;
const t = (locale && (w.translations[locale] ?? w.translations[locale.split("-")[0]])) || {};
return {
heading: t.heading ?? w.heading,
labels: { pickup: t.pickup ?? w.labels.pickup, delivery: t.delivery ?? w.labels.delivery, shipping: t.shipping ?? w.labels.shipping },
accent: w.accent,
showRemaining: w.showRemaining,
weekStartsOn: cfg.settings.weekStartsOn,
};
}
export async function getAvailability(domain: string, admin: AdminGraphql | undefined, raw: unknown) {
const q = QuerySchema.parse(raw);
const ctx = await context(domain, admin, q);
const { cfg } = ctx;
const offered = METHODS.filter((m) => ctx.locations.some((l) => l.enabled && l.methods[m]?.enabled));
const ranked = Object.fromEntries(offered.map((m) => [m, rank(ctx, m)])) as Record<Method, ReturnType<typeof rank>>;
const noAddress = !q.postal && !q.address;
const methods = offered.map((m) => {
const r = ranked[m];
const ok = r.ranked.filter((x) => x.eligible);
const first = ok.map((x) => x.first?.date).filter(Boolean).sort()[0] ?? null;
// Delivery can't be judged before we know where: keep it selectable and ask for a postal code,
// unless something else (a product rule, stock) already rules it out.
const onlyAddress = r.ranked.length > 0 && r.ranked.every((x) => x.eligible || x.reasons.every((y) => y.code === "OUTSIDE_DELIVERY_AREA"));
const needsAddress = m === "delivery" && noAddress && !ok.length && onlyAddress;
return {
method: m,
available: ok.length > 0 || needsAddress,
needsAddress,
firstDate: first,
reason: ok.length || needsAddress ? null : r.splitRequired?.message ?? r.ranked[0]?.reasons[0]?.message ?? "Unavailable",
};
});
const method: Method | null =
q.method && offered.includes(q.method) ? q.method : methods.find((m) => m.available && !m.needsAddress)?.method ?? methods.find((m) => m.available)?.method ?? null;
if (!method)
return { ok: true, version: cfg.version, text: widgetText(cfg, q.locale), methods, method: null, locations: [], dates: [], requireScheduling: cfg.settings.requireScheduling };
const r = ranked[method];
const chosen = r.ranked.find((x) => x.location.id === q.locationId && x.eligible) ?? r.ranked.find((x) => x.eligible) ?? null;
const cons = evaluateRules(ctx.cart, ctx.f.productRules ? cfg.rules : [], cfg.settings.mixedPrepPolicy);
const av = chosen?.availability;
return {
ok: true,
version: cfg.version,
text: widgetText(cfg, q.locale),
holdMinutes: cfg.settings.holdMinutes,
requireScheduling: cfg.settings.requireScheduling,
methods,
method,
split: r.splitRequired?.message ?? null,
needsAddress: method === "delivery" && noAddress,
locations: r.ranked.map((x) => ({
id: x.location.id,
name: x.location.name,
address: x.location.address,
eligible: x.eligible,
reason: x.eligible ? null : method === "delivery" && noAddress && x.reasons[0]?.code === "OUTSIDE_DELIVERY_AREA" ? "Enter your postal code to see delivery times" : x.reasons[0]?.message ?? null,
first: x.first,
distanceKm: x.distanceKm != null ? Math.round(x.distanceKm * 10) / 10 : null,
zone: x.zone?.zone?.name ?? null,
fee: x.fee ? { fee: x.fee.fee, waived: x.fee.waived, freeOver: x.zone?.zone?.freeOver ?? null } : null,
pickupInstructions: method === "pickup" ? x.location.pickupInstructions ?? null : null,
})),
locationId: chosen?.location.id ?? null,
prepNote: cons.prepDriver && av ? { item: cons.prepDriver.itemTitle, minutes: av.prepMinutes } : null,
dateOnly: av?.dateOnly ?? false,
timezone: av?.timezone ?? cfg.shop.timezone,
dates: (av?.dates ?? []).map((d) => ({
date: d.date,
available: d.available,
remaining: d.remaining,
peak: d.peak,
surcharge: d.surcharge,
waitlist: d.waitlist && ctx.f.waitlist,
reasons: d.reasons.map((x) => x.message),
slots: d.slots.map((s) => ({
id: s.id,
start: s.start,
end: s.end,
available: s.available,
remaining: s.remaining,
surcharge: s.surcharge,
reason: s.reasons[0]?.message ?? null,
})),
})),
first: av ? firstAvailable(av) : null,
};
}
export async function holdSelection(domain: string, admin: AdminGraphql, raw: unknown) {
const q = HoldSchema.parse(raw);
const ctx = await context(domain, admin, q);
const { cfg } = ctx;
const r = rank(ctx, q.method);
const entry = r.ranked.find((x) => x.location.id === q.locationId);
if (!entry || !entry.eligible)
return { ok: false, message: entry?.reasons[0]?.message ?? r.splitRequired?.message ?? "That location isn't available for this cart" };
const loc = entry.location;
const v = validateSelection(
{
location: loc,
method: q.method,
cart: ctx.cart,
rules: ctx.f.productRules ? cfg.rules : [],
peakDays: ctx.f.peakDays ? cfg.peakDays : [],
usage: ctx.usage,
settings: cfg.settings,
now: ctx.now,
},
q.date,
q.slot,
);
if (!v.ok) return { ok: false, message: v.reasons[0]?.message ?? "That time isn't available" };
const extras: { label: string; amount: number }[] = [];
if (v.slot.surcharge) extras.push({ label: "Time slot", amount: v.slot.surcharge });
if (v.date.peak && v.date.surcharge) extras.push({ label: v.date.peak.name, amount: v.date.surcharge });
const fee = quoteFee(q.method === "delivery" ? entry.zone?.zone ?? null : null, ctx.cart.subtotal ?? 0, extras);
const token = q.token && /^[A-Za-z0-9_-]{16,80}$/.test(q.token) ? q.token : randomBytes(18).toString("base64url");
const caps = capsFor(loc, q.method, q.date, q.slot, ctx.f.peakDays ? cfg.peakDays : []);
const held = await reservations.hold(cfg.shop.id, token, { locationId: loc.id, method: q.method, date: q.date, slot: q.slot }, caps, new Date(ctx.now), cfg.settings.holdMinutes, fee.fee);
if (!held.ok) return { ok: false, message: held.message };
const feeVariantId = fee.fee > 0 ? await ensureFeeVariant(admin, cfg.shop.id, fee.fee) : null;
const dateOnly = loc.methods[q.method]?.slotMode === "dateOnly";
const human = humanAttributes(cfg, q.method, loc.name, q.date, v.slot.start, v.slot.end, v.date.peak?.name, dateOnly);
return {
ok: true,
token,
expiresAt: held.expiresAt.toISOString(),
fee,
feeVariantId,
attributes: {
[SELECTION_ATTR]: JSON.stringify({ t: token, m: q.method, l: loc.id, d: q.date, s: q.slot, f: fee.fee, v: cfg.version }),
...human,
},
};
}
/** Readable cart/order attributes for staff (visible on the Shopify order page and packing slips). */
function humanAttributes(cfg: ShopConfig, method: Method, location: string, date: string, start: string, end: string, peak?: string, dateOnly?: boolean) {
const label = cfg.settings.widget.labels[method];
const nice = new Date(`${date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
return {
"Fulfillment method": label,
[method === "pickup" ? "Pickup location" : "Fulfilled from"]: location,
[method === "shipping" ? "Dispatch date" : `${label} date`]: nice,
...(method !== "shipping" && !dateOnly ? { [`${label} time`]: `${start}–${end}` } : {}),
...(peak ? { "Peak day": peak } : {}),
};
}
export async function releaseSelection(domain: string, token: string) {
const cfg = await loadShopConfig(domain);
await reservations.release(cfg.shop.id, token);
return { ok: true };
}
const WaitSchema = z.object({
method: z.enum(["pickup", "delivery", "shipping"]),
locationId: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
slot: z.string().default("*"),
email: z.string().email().optional(),
phone: z.string().min(7).max(20).optional(),
name: z.string().max(100).optional(),
});
export async function joinWaitlist(domain: string, raw: unknown) {
const w = WaitSchema.parse(raw);
if (!w.email && !w.phone) return { ok: false, message: "Add an email or phone number" };
const cfg = await loadShopConfig(domain);
if (!features(cfg.shop.plan).waitlist) return { ok: false, message: "Waitlist isn't available" };
const dup = await prisma.waitlistEntry.findFirst({
where: { shopId: cfg.shop.id, locationId: w.locationId, method: w.method, date: w.date, OR: [{ email: w.email ?? "__" }, { phone: w.phone ?? "__" }] },
});
if (!dup) await prisma.waitlistEntry.create({ data: { shopId: cfg.shop.id, ...w } });
return { ok: true };
}
/** Customer arriving from a waitlist offer link adopts the reserved hold. */
export async function adoptOffer(domain: string, offerToken: string) {
const cfg = await loadShopConfig(domain);
const hold = await prisma.hold.findUnique({ where: { token: offerToken } });
if (!hold || hold.shopId !== cfg.shop.id || hold.expiresAt <= new Date()) return { ok: false, message: "This offer has expired" };
const loc = cfg.locations.find((l) => l.id === hold.locationId);
return {
ok: true,
token: hold.token,
method: hold.method,
locationId: hold.locationId,
date: hold.date,
slot: hold.slot,
expiresAt: hold.expiresAt.toISOString(),
location: loc?.name,
};
}

View File

@ -0,0 +1,49 @@
/**
* When a place frees up (cancellation or reschedule), offer it to the first person
* waiting: reserve it as a hold for `waitlistOfferMinutes` and message them a link
* that adopts the hold in their cart.
*/
import { randomBytes } from "node:crypto";
import prisma from "../db.server";
import { capsFor, type Selection } from "../engine/capacity";
import { loadShopConfig } from "./config.server";
import { send } from "./notifications.server";
import { features } from "./plans";
import { ReservationService } from "./reservations.server";
const reservations = new ReservationService(prisma);
export async function offerFreedPlace(domain: string, freed: Selection, now = new Date()) {
const cfg = await loadShopConfig(domain);
if (!features(cfg.shop.plan).waitlist) return null;
const entry = await prisma.waitlistEntry.findFirst({
where: {
shopId: cfg.shop.id,
locationId: freed.locationId,
method: freed.method,
date: freed.date,
slot: { in: [freed.slot, "*"] },
offeredAt: null,
},
orderBy: { createdAt: "asc" },
});
const loc = cfg.locations.find((l) => l.id === freed.locationId);
if (!entry || !loc) return null;
const token = `wl_${randomBytes(16).toString("base64url")}`;
const caps = capsFor(loc, freed.method, freed.date, freed.slot, cfg.peakDays);
const held = await reservations.hold(cfg.shop.id, token, freed, caps, now, cfg.settings.waitlistOfferMinutes);
if (!held.ok) return null;
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { offeredAt: now, offerToken: token } });
const link = `https://${domain}/cart?datewise_offer=${token}`;
const nice = new Date(`${freed.date}T12:00:00Z`).toLocaleDateString("en-CA", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" });
const body = `Good news${entry.name ? `, ${entry.name}` : ""}! A ${freed.method} place opened on ${nice} (${freed.slot.replace("-", "–")}). We're holding it for you for ${cfg.settings.waitlistOfferMinutes} minutes: ${link}`;
const channel = entry.email ? "email" : features(cfg.shop.plan).smsWhatsapp ? "sms" : null;
if (channel)
await send(cfg.settings, domain.replace(".myshopify.com", ""), {
channel,
to: (entry.email ?? entry.phone)!,
subject: "A place opened up for you",
body,
}).catch((e) => console.error("waitlist notify failed", e));
return { entryId: entry.id, token };
}

51
app/shopify.server.ts Normal file
View File

@ -0,0 +1,51 @@
import "@shopify/shopify-app-react-router/adapters/node";
import {
ApiVersion,
AppDistribution,
BillingInterval,
shopifyApp,
} from "@shopify/shopify-app-react-router/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
import { ensureShop } from "./services/config.server";
/** Plan names are referenced by billing checks and feature gates (services/plans.ts). */
export const PLAN_GROWTH = "Growth";
export const PLAN_PRO = "Pro";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.January26,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
future: { expiringOfflineAccessTokens: true },
billing: {
[PLAN_GROWTH]: {
trialDays: 14,
lineItems: [{ amount: 19, currencyCode: "USD", interval: BillingInterval.Every30Days }],
},
[PLAN_PRO]: {
trialDays: 14,
lineItems: [{ amount: 49, currencyCode: "USD", interval: BillingInterval.Every30Days }],
},
},
hooks: {
afterAuth: async ({ session, admin }) => {
await ensureShop(session.shop, admin);
},
},
...(process.env.SHOP_CUSTOM_DOMAIN ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } : {}),
});
export default shopify;
export const apiVersion = ApiVersion.January26;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

BIN
docs/DS_Shipping_spec.docx Normal file

Binary file not shown.

4
env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="@react-router/node" />
/// <reference types="@shopify/polaris-types" />
/// <reference types="@shopify/app-bridge-types" />

View File

@ -0,0 +1,16 @@
{
"name": "datewise-checkout-guard",
"version": "1.0.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"scripts": {
"shopify": "npm exec -- shopify",
"typegen": "npm exec -- shopify app function typegen",
"build": "npm exec -- shopify app function build",
"preview": "npm exec -- shopify app function run"
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
}
}

View File

@ -0,0 +1,16 @@
api_version = "2026-01"
[[extensions]]
name = "Datewise checkout guard"
handle = "datewise-checkout-guard"
type = "function"
description = "Blocks checkout when a required delivery/pickup time is missing, past, or its fee doesn't match."
[[extensions.targeting]]
target = "cart.validations.generate.run"
input_query = "src/cart_validations_generate_run.graphql"
export = "cart-validations-generate-run"
[extensions.build]
command = ""
path = "dist/function.wasm"

View File

@ -0,0 +1,37 @@
query CartValidationsGenerateRunInput {
buyerJourney {
step
}
cart {
selection: attribute(key: "_datewise") {
value
}
lines {
quantity
cost {
amountPerQuantity {
amount
}
}
merchandise {
__typename
... on ProductVariant {
requiresShipping
product {
isFee: hasAnyTag(tags: ["datewise-fee"])
}
}
}
}
}
shop {
localTime {
date
}
}
validation {
metafield(namespace: "$app:datewise", key: "config") {
jsonValue
}
}
}

View File

@ -0,0 +1,57 @@
// @ts-check
/**
* Datewise checkout guard (Cart and Checkout Validation Function, all Shopify plans).
*
* Covers the paths the cart widget can't see — Buy now buttons, accelerated checkouts,
* shared cart links. It cannot see live capacity (functions have no network access), so
* capacity is enforced at order time by the app (re-reserve or review queue).
*
* Checks, only at checkout (never while adding to cart):
* 1. Scheduling required but no selection → block
* 2. Selected date is before today in the shop's time zone → block
* 3. Delivery/peak fee in the cart ≠ fee quoted with the selection → block
* 4. Fee line present without a selection → block
*/
/** @param {any} input */
export function cartValidationsGenerateRun(input) {
const step = input.buyerJourney?.step;
if (step !== "CHECKOUT_COMPLETION" && step !== "CHECKOUT_INTERACTION") return { operations: [] };
const config = input.validation?.metafield?.jsonValue ?? {};
const requireScheduling = config.requireScheduling !== false;
const lines = input.cart?.lines ?? [];
const isFee = (/** @type {any} */ l) => l.merchandise?.__typename === "ProductVariant" && l.merchandise.product?.isFee;
const goods = lines.filter((/** @type {any} */ l) => !isFee(l));
const feeCents = lines
.filter(isFee)
.reduce((/** @type {number} */ s, /** @type {any} */ l) => s + Math.round(Number(l.cost.amountPerQuantity.amount) * 100) * l.quantity, 0);
/** @type {{message: string, target: string}[]} */
const errors = [];
const raw = input.cart?.selection?.value;
let sel = null;
if (raw) {
try {
sel = JSON.parse(raw);
} catch {
sel = null;
}
}
const valid = sel && typeof sel.d === "string" && /^\d{4}-\d{2}-\d{2}$/.test(sel.d) && typeof sel.s === "string";
if (!valid) {
if (requireScheduling && goods.length > 0)
errors.push({ message: "Please choose a delivery or pickup time in your cart before checking out.", target: "$.cart" });
if (feeCents > 0) errors.push({ message: "Your cart has a delivery fee but no delivery time. Please return to your cart.", target: "$.cart" });
} else {
const today = input.shop?.localTime?.date;
if (today && sel.d < today)
errors.push({ message: "The delivery or pickup date you chose has passed. Please choose a new time in your cart.", target: "$.cart" });
const quoted = Number.isInteger(sel.f) ? sel.f : 0;
if (feeCents !== quoted)
errors.push({ message: "The delivery fee doesn't match your selected time. Please return to your cart to update it.", target: "$.cart" });
}
return errors.length ? { operations: [{ validationAdd: { errors } }] } : { operations: [] };
}

View File

@ -0,0 +1 @@
export * from "./cart_validations_generate_run";

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { cartValidationsGenerateRun as run } from "../src/cart_validations_generate_run.js";
const line = (amount, qty = 1, fee = false) => ({
quantity: qty,
cost: { amountPerQuantity: { amount: String(amount) } },
merchandise: { __typename: "ProductVariant", requiresShipping: true, product: { isFee: fee } },
});
const input = (o = {}) => ({
buyerJourney: { step: "CHECKOUT_COMPLETION" },
cart: { selection: o.sel === undefined ? null : { value: o.sel }, lines: o.lines ?? [line("19.99", 2)] },
shop: { localTime: { date: o.today ?? "2026-11-05" } },
validation: { metafield: o.config === undefined ? { jsonValue: { requireScheduling: true } } : o.config },
});
const sel = (o = {}) => JSON.stringify({ t: "tok_aaaaaaaaaaaaaaaa", m: "delivery", l: "kit", d: "2026-11-07", s: "10:00-13:00", f: 0, ...o });
const msgs = (r) => r.operations.flatMap((op) => op.validationAdd.errors.map((e) => e.message));
describe("checkout guard", () => {
it("never blocks while adding to cart", () => {
expect(run({ ...input(), buyerJourney: { step: "CART_INTERACTION" } }).operations).toEqual([]);
});
it("blocks a missing time when required", () => {
expect(msgs(run(input()))[0]).toMatch(/choose a delivery or pickup time/);
});
it("allows a missing time when not required", () => {
expect(run(input({ config: { jsonValue: { requireScheduling: false } } })).operations).toEqual([]);
});
it("defaults to required when the metafield is missing", () => {
expect(msgs(run(input({ config: null }))).length).toBe(1);
});
it("passes a valid selection with no fee", () => {
expect(run(input({ sel: sel() })).operations).toEqual([]);
});
it("blocks a past date", () => {
expect(msgs(run(input({ sel: sel({ d: "2026-11-04" }) })))[0]).toMatch(/passed/);
});
it("checks the fee line against the quote", () => {
const ok = input({ sel: sel({ f: 1299 }), lines: [line("19.99"), line("12.99", 1, true)] });
expect(run(ok).operations).toEqual([]);
const tampered = input({ sel: sel({ f: 0 }), lines: [line("19.99"), line("12.99", 1, true)] });
expect(msgs(run(tampered))[0]).toMatch(/fee doesn't match/);
const removed = input({ sel: sel({ f: 1299 }), lines: [line("19.99")] });
expect(msgs(run(removed))[0]).toMatch(/fee doesn't match/);
});
it("blocks a fee without a selection", () => {
const r = run(input({ config: { jsonValue: { requireScheduling: false } }, lines: [line("5"), line("7.99", 1, true)] }));
expect(msgs(r)[0]).toMatch(/no delivery time/);
});
it("treats malformed JSON as missing", () => {
expect(msgs(run(input({ sel: "{oops" })))[0]).toMatch(/choose a delivery/);
});
});

View File

@ -0,0 +1,65 @@
/* Datewise widget — inherits the theme's fonts and text colour; one accent variable. */
.datewise {
--ss-accent: #0d6b5d;
--ss-line: rgba(127, 127, 127, 0.32);
--ss-soft: color-mix(in srgb, var(--ss-accent) 12%, transparent);
--ss-warn: #9a5b00;
--ss-warn-soft: rgba(240, 180, 74, 0.18);
--ss-bad: #b42318;
--ss-good: #1e7a3a;
display: grid;
gap: 12px;
margin: 16px 0;
font-size: 0.95em;
line-height: 1.4;
min-width: 0;
}
.datewise--drawer { margin: 0 0 12px; font-size: 0.9em; }
.datewise * { box-sizing: border-box; }
.datewise button { font: inherit; color: inherit; cursor: pointer; }
.datewise [aria-disabled="true"] { cursor: not-allowed; opacity: 0.5; }
.datewise :focus-visible { outline: 2px solid var(--ss-accent); outline-offset: 2px; }
.ss-h { margin: 0; font-size: 1.15em; }
.ss-live:empty { display: none; }
.ss-msg { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--ss-soft); }
.ss-critical { background: rgba(180, 35, 24, 0.1); color: var(--ss-bad); }
.ss-success { background: rgba(30, 122, 58, 0.1); color: var(--ss-good); }
.ss-peak { background: var(--ss-warn-soft); color: var(--ss-warn); }
.ss-note { margin: 0; opacity: 0.8; font-size: 0.9em; }
.ss-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }
.ss-method, .ss-loc, .ss-slot {
display: grid; gap: 2px; text-align: left; padding: 10px; border-radius: 10px;
border: 1.5px solid var(--ss-line); background: transparent;
}
.ss-method small, .ss-loc small, .ss-slot small { opacity: 0.75; font-size: 0.82em; }
.ss-method[aria-pressed="true"], .ss-loc[aria-pressed="true"], .ss-slot[aria-pressed="true"] { border-color: var(--ss-accent); background: var(--ss-soft); }
.ss-locs { display: grid; gap: 6px; }
.ss-loc { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.ss-loc span:first-child { display: grid; }
.ss-postal { display: grid; gap: 4px; }
.ss-postal label { font-size: 0.85em; opacity: 0.8; }
.ss-row { display: flex; gap: 8px; flex-wrap: wrap; }
.ss-row input { flex: 1; min-width: 0; padding: 8px 10px; border: 1px solid var(--ss-line); border-radius: 8px; font: inherit; background: transparent; color: inherit; }
.ss-row button { padding: 8px 14px; border-radius: 8px; border: 1px solid var(--ss-accent); background: var(--ss-accent); color: #fff; }
.ss-calhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.ss-calhead button { border: 0; background: transparent; font-size: 1.3em; padding: 2px 10px; }
.ss-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
.ss-dow { text-align: center; font-size: 0.75em; opacity: 0.7; }
.ss-day { position: relative; aspect-ratio: 1 / 0.9; border: 1px solid transparent; border-radius: 8px; background: transparent; padding: 0; font-variant-numeric: tabular-nums; }
.ss-avail { background: var(--ss-soft); }
.ss-avail:hover { border-color: var(--ss-accent); }
.ss-full { background: var(--ss-warn-soft); color: var(--ss-warn); }
.ss-closed { text-decoration: line-through; opacity: 0.45; }
.ss-out { opacity: 0.25; }
.ss-sel { background: var(--ss-accent); color: #fff; }
.ss-peakday::after { content: ""; position: absolute; top: 4px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: #c77c02; }
.ss-left { position: absolute; bottom: 1px; left: 0; right: 0; font-size: 0.6em; font-style: normal; opacity: 0.8; }
.ss-day-detail { display: grid; gap: 8px; }
.ss-day-detail h3 { margin: 4px 0 0; font-size: 1em; }
.ss-badge { font-size: 0.75em; padding: 1px 8px; border-radius: 99px; background: var(--ss-warn-soft); color: var(--ss-warn); }
.ss-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
.ss-hold { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--ss-soft); }
.ss-skeleton { height: 120px; border-radius: 10px; background: linear-gradient(90deg, var(--ss-line), transparent, var(--ss-line)); background-size: 200% 100%; opacity: 0.35; animation: ss-shimmer 1.2s infinite linear; }
@keyframes ss-shimmer { to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .ss-skeleton { animation: none; } }
.datewise[aria-busy="true"] .ss-slots, .datewise[aria-busy="true"] .ss-grid { opacity: 0.6; pointer-events: none; }

View File

@ -0,0 +1,421 @@
/* Datewise storefront widget. Vanilla JS, no dependencies, safe to load twice.
* Talks to the app through the shop's app proxy (/apps/datewise/*), so it works on any theme.
*
* Lifecycle
* cart change ─▶ refresh(): read /cart.js ─▶ POST availability ─▶ revalidate selection ─▶ render
* slot click ─▶ POST hold (capacity reserved for N minutes) ─▶ write cart attributes + fee line
* checkout ─▶ (optional) block click until a time is chosen; the checkout function enforces it server-side
*/
(function () {
"use strict";
if (window.__datewiseLoaded) return;
window.__datewiseLoaded = true;
// ---------- config ----------
var cfgEl = document.querySelector("[data-datewise-config]");
var CFG = {};
try { CFG = cfgEl ? JSON.parse(cfgEl.textContent) : {}; } catch (e) { CFG = {}; }
var T = Object.assign({
choose: "Pick a highlighted date", needTime: "Please choose a delivery or pickup time before checking out.",
held: "Held for you for", expired: "Your hold expired. Tap the time again to check it's still free.", left: "left", full: "Full",
postal: "Postal / ZIP code", check: "Check", waitlist: "Join the waitlist", joined: "You're on the waitlist. We'll message you if a place opens.",
dispatch: "Ships this day (not an arrival date)", change: "Change", prep: "Earliest times include preparation for", free: "Free", fee: "fee",
nextAvailable: "Next available", offer: "A place opened up and we're holding it for you", error: "Something went wrong. Please try again."
}, CFG.text || {});
var ROOT = ((window.Shopify && Shopify.routes && Shopify.routes.root) || "/").replace(/\/?$/, "/");
var API = ROOT + "apps/datewise";
var LOCALE = CFG.locale || document.documentElement.lang || "en";
var CURRENCY = CFG.currency || (window.Shopify && Shopify.currency && Shopify.currency.active) || "CAD";
var ATTR = "_datewise";
// ---------- storage (best effort) ----------
function lsGet(k) { try { return localStorage.getItem("datewise_" + k); } catch (e) { return null; } }
function lsSet(k, v) { try { v == null ? localStorage.removeItem("datewise_" + k) : localStorage.setItem("datewise_" + k, v); } catch (e) {} }
// ---------- state ----------
var S = {
data: null, cart: null, lines: [], loading: false, message: null, messageTone: "info",
method: lsGet("method"), locationId: lsGet("location"), postal: lsGet("postal") || "",
date: null, sel: null, token: lsGet("token"), expiresAt: null, monthOffset: 0, waitlistDone: false, mutating: 0
};
// ---------- helpers ----------
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
function money(c) { try { return new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY }).format(c / 100); } catch (e) { return "$" + (c / 100).toFixed(2); } }
function dateLabel(d, long) { return new Intl.DateTimeFormat(LOCALE, { weekday: long ? "long" : "short", month: long ? "long" : "short", day: "numeric", timeZone: "UTC" }).format(new Date(d + "T12:00:00Z")); }
function timeLabel(hhmm) { var p = hhmm.split(":"); return new Intl.DateTimeFormat(LOCALE, { hour: "numeric", minute: p[1] === "00" ? undefined : "2-digit", timeZone: "UTC" }).format(new Date(Date.UTC(2000, 0, 1, +p[0], +p[1]))); }
function slotText(s) { return timeLabel(s.start) + " – " + timeLabel(s.end); }
function addMonths(ym, n) { var y = +ym.slice(0, 4), m = +ym.slice(5, 7) - 1 + n; y += Math.floor(m / 12); m = ((m % 12) + 12) % 12; return y + "-" + String(m + 1).padStart(2, "0"); }
function daysIn(ym) { return new Date(Date.UTC(+ym.slice(0, 4), +ym.slice(5, 7), 0)).getUTCDate(); }
function weekday(d) { return new Date(d + "T12:00:00Z").getUTCDay(); }
function api(path, body, method) {
var url = API + path;
var init = { method: method || "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin" };
if (body && init.method !== "GET") init.body = JSON.stringify(body);
return realFetch(url, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: T.error }; }); });
}
function cartReq(path, body) {
S.mutating++;
return realFetch(ROOT + path, { method: body ? "POST" : "GET", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: body ? JSON.stringify(body) : undefined, credentials: "same-origin" })
.then(function (r) { return r.json(); })
.finally(function () { setTimeout(function () { S.mutating--; }, 50); });
}
// ---------- cart ----------
function readCart() {
return cartReq("cart.js").then(function (cart) {
S.cart = cart;
S.lines = cart.items.filter(function (i) { return !(i.properties && i.properties._datewise_fee); })
.map(function (i) { return { variantId: i.variant_id, quantity: i.quantity, price: i.final_price, title: i.product_title }; });
var raw = cart.attributes && cart.attributes[ATTR];
if (raw) {
try {
var a = JSON.parse(raw);
S.sel = { method: a.m, locationId: a.l, date: a.d, slot: a.s, fee: a.f };
S.token = a.t; lsSet("token", a.t);
S.method = S.method || a.m; S.locationId = S.locationId || a.l; S.date = S.date || a.d;
} catch (e) { S.sel = null; }
} else S.sel = null;
return cart;
});
}
function feeLines() { return (S.cart ? S.cart.items : []).filter(function (i) { return i.properties && i.properties._datewise_fee; }); }
function syncFee(variantId) {
var current = feeLines();
var chain = Promise.resolve();
var keep = null;
current.forEach(function (line) {
if (!keep && variantId && String(line.variant_id) === String(variantId) && line.quantity === 1) keep = line;
else chain = chain.then(function () { return cartReq("cart/change.js", { id: line.key, quantity: 0 }); });
});
if (variantId && !keep) chain = chain.then(function () { return cartReq("cart/add.js", { items: [{ id: Number(variantId), quantity: 1, properties: { _datewise_fee: "1" } }] }); });
var changed = current.length !== (keep ? 1 : 0) || (variantId && !keep);
return chain.then(function () { return changed; });
}
function writeAttributes(attrs) {
var prev = [];
try { prev = JSON.parse(lsGet("keys") || "[]"); } catch (e) {}
var out = {};
prev.forEach(function (k) { out[k] = ""; });
Object.keys(attrs).forEach(function (k) { out[k] = attrs[k]; });
lsSet("keys", JSON.stringify(Object.keys(attrs)));
return cartReq("cart/update.js", { attributes: out });
}
function notifyTheme(feeChanged) {
var detail = { source: "datewise" };
["cart:refresh", "cart:updated", "cart:change", "theme:cart:change"].forEach(function (n) { document.dispatchEvent(new CustomEvent(n, { detail: detail, bubbles: true })); });
if (typeof window.publish === "function" && window.PUB_SUB_EVENTS) {
readCart().then(function (cart) { window.publish(window.PUB_SUB_EVENTS.cartUpdate, { source: "datewise", cartData: cart }); });
} else if (feeChanged && /\/cart\/?$/.test(location.pathname)) {
setTimeout(function () { location.reload(); }, 300); // totals must show the fee line
}
}
function clearSelection(reason) {
var token = S.token;
S.sel = null; S.expiresAt = null;
if (reason) setMessage(reason, "critical");
if (token) api("/release", { token: token }).catch(function () {});
return writeAttributes({ [ATTR]: "" }).then(function () { return syncFee(null); }).then(function (changed) { lsSet("keys", "[]"); if (changed) notifyTheme(true); });
}
// ---------- data ----------
var timer = null;
function refresh(soon) {
clearTimeout(timer);
timer = setTimeout(load, soon ? 0 : 300);
}
function load() {
if (!roots().length && !CFG.checkoutGuard) return Promise.resolve();
S.loading = true; renderAll();
return readCart().then(function () {
if (!S.lines.length) { S.data = null; S.loading = false; if (S.sel) clearSelection(null); renderAll(); return; }
return api("/availability", { lines: S.lines, method: S.method || undefined, locationId: S.locationId || undefined, postal: S.postal || undefined, token: S.token || undefined, locale: LOCALE })
.then(function (data) {
S.loading = false;
if (!data || data.ok === false) { setMessage((data && data.message) || T.error, "critical"); renderAll(); return; }
S.data = data;
S.method = data.method; S.locationId = data.locationId;
revalidate();
renderAll();
});
}).catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); });
}
function findSlot(date, slot) {
var d = S.data && S.data.dates.filter(function (x) { return x.date === date; })[0];
return { d: d, s: d && d.slots.filter(function (x) { return x.id === slot; })[0] };
}
/** The cart, method or location changed: drop a selection the rules no longer allow, with a reason. */
function revalidate() {
if (!S.sel) return;
var d = S.data;
if (!d || d.method !== S.sel.method || d.locationId !== S.sel.locationId) return clearSelection(null);
var f = findSlot(S.sel.date, S.sel.slot);
if (!f.s || !f.s.available) {
var why = (f.s && f.s.reason) || (f.d && f.d.reasons[0]) || "";
clearSelection(why ? why + ". " + T.choose + "." : T.choose);
}
}
function hold(date, slot) {
if (!S.data) return;
setMessage(null);
S.loading = true; renderAll();
return api("/hold", { lines: S.lines, method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, token: S.token || undefined, postal: S.postal || undefined, locale: LOCALE })
.then(function (res) {
if (!res.ok) { S.loading = false; setMessage(res.message || T.error, "critical"); refresh(true); return; }
S.token = res.token; lsSet("token", res.token);
S.expiresAt = Date.parse(res.expiresAt);
S.sel = { method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, fee: res.fee ? res.fee.fee : 0 };
return writeAttributes(res.attributes)
.then(function () { return syncFee(res.feeVariantId); })
.then(function (changed) { S.loading = false; renderAll(); notifyTheme(changed); });
})
.catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); });
}
function setMessage(text, tone) { S.message = text; S.messageTone = tone || "info"; }
// ---------- rendering ----------
function roots() { return Array.prototype.slice.call(document.querySelectorAll("[data-datewise-root]")); }
function renderAll() { roots().forEach(render); }
function render(root) {
if (!root.classList.contains("datewise")) root.classList.add("datewise"); // custom mounts get styles too
var d = S.data;
var heading = root.getAttribute("data-heading") || (d && d.text && d.text.heading) || "";
if (d && d.text && d.text.accent) root.style.setProperty("--ss-accent", d.text.accent);
var h = "";
if (heading) h += '<h2 class="ss-h">' + esc(heading) + "</h2>";
h += '<div class="ss-live" role="status" aria-live="polite">' + (S.message ? '<p class="ss-msg ss-' + S.messageTone + '">' + esc(S.message) + "</p>" : "") + "</div>";
if (!d) { root.innerHTML = h + (S.loading ? '<div class="ss-skeleton" aria-hidden="true"></div>' : ""); root.setAttribute("aria-busy", S.loading); return; }
// methods
h += '<div class="ss-methods" role="group" aria-label="Delivery method">';
d.methods.forEach(function (m) {
var on = m.method === d.method;
h += '<button type="button" class="ss-method" data-ss="method" data-v="' + m.method + '" aria-pressed="' + on + '"' + (m.available || on ? "" : ' aria-disabled="true"') + ">" +
"<b>" + esc(d.text.labels[m.method]) + "</b><small>" + (m.needsAddress ? esc(T.postal) : m.available ? (m.firstDate ? esc(T.nextAvailable + ": " + dateLabel(m.firstDate)) : "") : esc(m.reason || "")) + "</small></button>";
});
h += "</div>";
if (!d.method) { root.innerHTML = h; return; }
if (d.method === "delivery") {
h += '<form class="ss-postal" data-ss="postal"><label for="ss-postal-' + rid(root) + '">' + esc(T.postal) + '</label><div class="ss-row"><input id="ss-postal-' + rid(root) + '" name="postal" autocomplete="postal-code" value="' + esc(S.postal) + '"><button type="submit">' + esc(T.check) + "</button></div></form>";
}
if (d.split) h += '<p class="ss-msg ss-critical">' + esc(d.split) + "</p>";
if (d.method !== "shipping" && d.locations.length > 1) {
h += '<div class="ss-locs" role="group" aria-label="Location">';
d.locations.forEach(function (l) {
var on = l.id === d.locationId;
var right = l.fee ? (l.fee.waived ? T.free : money(l.fee.fee)) : l.distanceKm != null ? l.distanceKm + " km" : "";
h += '<button type="button" class="ss-loc" data-ss="loc" data-v="' + esc(l.id) + '" aria-pressed="' + on + '"' + (l.eligible ? "" : ' aria-disabled="true"') + "><span><b>" + esc(l.name) + "</b><small>" +
esc(l.eligible ? (l.first ? T.nextAvailable + ": " + dateLabel(l.first.date) : "") : l.reason || "") + "</small></span><span>" + esc(right) + "</span></button>";
});
h += "</div>";
} else if (d.locationId) {
var only = d.locations.filter(function (l) { return l.id === d.locationId; })[0];
if (only && d.method !== "shipping") h += '<p class="ss-note">' + esc(only.name) + (only.fee ? " · " + (only.fee.waived ? T.free : money(only.fee.fee) + " " + T.fee) : "") + "</p>";
}
if (!d.locationId) {
if (d.needsAddress) { root.innerHTML = h; return; }
var why = d.locations[0] && d.locations[0].reason;
root.innerHTML = h + (why ? '<p class="ss-msg ss-critical">' + esc(why) + "</p>" : "");
return;
}
if (d.prepNote && d.prepNote.minutes >= 60) h += '<p class="ss-note">' + esc(T.prep + " " + d.prepNote.item) + " (" + Math.round(d.prepNote.minutes / 6) / 10 + " h)</p>";
var peak = d.dates.filter(function (x) { return x.peak && x.peak.message; })[0];
if (peak) h += '<p class="ss-msg ss-peak">' + esc(peak.peak.message) + "</p>";
h += calendar(d);
h += slots(d, root);
root.innerHTML = h;
root.setAttribute("aria-busy", S.loading);
tickTimer();
}
var ridSeq = 0;
function rid(root) { if (!root.__ssid) root.__ssid = ++ridSeq; return root.__ssid; }
function calendar(d) {
var map = {};
d.dates.forEach(function (x) { map[x.date] = x; });
var firstAvail = (d.dates.filter(function (x) { return x.available; })[0] || d.dates[0] || {}).date;
if (!firstAvail) return "";
var ym = addMonths(firstAvail.slice(0, 7), S.monthOffset);
var start = d.text.weekStartsOn || 0;
var first = ym + "-01";
var lead = (weekday(first) - start + 7) % 7;
var h = '<div class="ss-cal"><div class="ss-calhead"><button type="button" data-ss="month" data-v="-1" aria-label="Previous month">‹</button><b>' +
esc(new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(first + "T12:00:00Z"))) +
'</b><button type="button" data-ss="month" data-v="1" aria-label="Next month">›</button></div><div class="ss-grid" role="grid">';
for (var w = 0; w < 7; w++) {
var wd = (start + w) % 7;
h += '<span class="ss-dow" aria-hidden="true">' + esc(new Intl.DateTimeFormat(LOCALE, { weekday: "narrow", timeZone: "UTC" }).format(new Date(Date.UTC(2023, 0, 1 + wd)))) + "</span>";
}
for (var i = 0; i < lead; i++) h += "<span></span>";
for (var day = 1; day <= daysIn(ym); day++) {
var date = ym + "-" + String(day).padStart(2, "0");
var x = map[date];
var cls = "ss-day", label = dateLabel(date, true), extra = "";
if (!x) cls += " ss-out";
else if (x.available) { cls += " ss-avail"; if (d.text.showRemaining && x.remaining != null && x.remaining <= 5) extra = '<i class="ss-left">' + x.remaining + "</i>"; }
else if (x.waitlist || /full|booked/i.test(x.reasons[0] || "")) { cls += " ss-full"; label += ", " + T.full; }
else { cls += " ss-closed"; label += ", " + (x.reasons[0] || ""); }
if (S.date === date) cls += " ss-sel";
if (x && x.peak) { cls += " ss-peakday"; label += ", " + x.peak.name; }
h += '<button type="button" class="' + cls + '" data-ss="date" data-v="' + date + '" aria-label="' + esc(label) + '"' + (x && (x.available || x.waitlist || x.reasons.length) ? "" : ' aria-disabled="true"') + ' tabindex="' + (S.date === date || (!S.date && date === firstAvail) ? "0" : "-1") + '">' + day + extra + "</button>";
}
return h + "</div></div>";
}
function slots(d, root) {
if (!S.date) return '<p class="ss-note">' + esc(T.choose) + "</p>";
var x = d.dates.filter(function (y) { return y.date === S.date; })[0];
if (!x) return "";
var h = '<div class="ss-day-detail"><h3>' + esc(dateLabel(x.date, true)) + (x.peak ? ' <span class="ss-badge">' + esc(x.peak.name) + "</span>" : "") + "</h3>";
if (d.dateOnly) h += '<p class="ss-note">' + esc(T.dispatch) + "</p>";
if (!x.available) {
h += '<p class="ss-msg ' + (x.waitlist ? "ss-peak" : "ss-critical") + '">' + esc(x.reasons.join(". ")) + "</p>";
if (x.waitlist && !S.waitlistDone)
h += '<form class="ss-row" data-ss="waitlist"><input type="email" name="email" required placeholder="Email" aria-label="Email"><button type="submit">' + esc(T.waitlist) + "</button></form>";
if (S.waitlistDone) h += '<p class="ss-msg ss-success">' + esc(T.joined) + "</p>";
return h + "</div>";
}
h += '<div class="ss-slots" role="group" aria-label="Times">';
x.slots.forEach(function (s) {
var on = S.sel && S.sel.date === x.date && S.sel.slot === s.id;
var sub = s.available ? (d.text.showRemaining && s.remaining != null ? s.remaining + " " + T.left : "") + (s.surcharge ? (s.remaining != null && d.text.showRemaining ? " · " : "") + "+" + money(s.surcharge) : "") : s.reason || "";
h += '<button type="button" class="ss-slot" data-ss="slot" data-v="' + s.id + '" aria-pressed="' + !!on + '"' + (s.available || on ? "" : ' aria-disabled="true"') + "><b>" + esc(d.dateOnly ? dateLabel(x.date) : slotText(s)) + "</b><small>" + esc(sub) + "</small></button>";
});
h += "</div>";
if (S.sel && S.sel.date === x.date) {
var live = S.expiresAt && S.expiresAt > Date.now();
h += '<p class="ss-hold">' + (live ? esc(T.held) + ' <b data-ss-timer></b>' : S.expiresAt ? esc(T.expired) : "✓") + "</p>";
var loc = d.locations.filter(function (l) { return l.id === d.locationId; })[0];
if (loc && loc.pickupInstructions) h += '<p class="ss-note">' + esc(loc.pickupInstructions) + "</p>";
}
return h + "</div>";
}
function tickTimer() {
var left = S.expiresAt ? S.expiresAt - Date.now() : 0;
var els = document.querySelectorAll("[data-ss-timer]");
if (left <= 0 && els.length) return renderAll();
var s = Math.max(0, Math.round(left / 1000));
for (var i = 0; i < els.length; i++) els[i].textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
}
setInterval(tickTimer, 1000);
// ---------- events ----------
document.addEventListener("click", function (e) {
var t = e.target.closest && e.target.closest("[data-datewise-root] [data-ss]");
if (!t || t.tagName === "FORM") return;
if (t.getAttribute("aria-disabled") === "true" && t.getAttribute("data-ss") !== "date") return;
var a = t.getAttribute("data-ss"), v = t.getAttribute("data-v");
if (a === "method" && v !== S.method) { S.method = v; lsSet("method", v); S.date = null; S.monthOffset = 0; refresh(true); }
else if (a === "loc" && v !== S.locationId) { S.locationId = v; lsSet("location", v); S.date = null; refresh(true); }
else if (a === "month") { S.monthOffset += +v; renderAll(); }
else if (a === "date") { S.date = v; S.waitlistDone = false; setMessage(null); renderAll(); focusFirst(".ss-slot:not([aria-disabled])"); }
else if (a === "slot") hold(S.date, v);
});
document.addEventListener("submit", function (e) {
var f = e.target;
if (!f.closest || !f.closest("[data-datewise-root]")) return;
var a = f.getAttribute("data-ss");
if (!a) return;
e.preventDefault();
if (a === "postal") { S.postal = f.postal.value.trim(); lsSet("postal", S.postal); S.date = null; refresh(true); }
if (a === "waitlist" && S.data) {
api("/waitlist", { method: S.data.method, locationId: S.data.locationId, date: S.date, slot: "*", email: f.email.value.trim() }).then(function (r) {
if (r.ok) S.waitlistDone = true; else setMessage(r.message || T.error, "critical");
renderAll();
});
}
});
// Arrow-key navigation in the calendar grid.
document.addEventListener("keydown", function (e) {
var t = e.target;
if (!t.classList || !t.classList.contains("ss-day")) return;
var step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
if (!step) return;
var days = Array.prototype.slice.call(t.parentNode.querySelectorAll(".ss-day"));
var next = days[days.indexOf(t) + step];
if (next) { e.preventDefault(); t.tabIndex = -1; next.tabIndex = 0; next.focus(); }
});
function focusFirst(sel) { setTimeout(function () { var el = document.querySelector("[data-datewise-root] " + sel); if (el) el.focus(); }, 0); }
// Checkout guard (convenience; the checkout function is the real enforcement).
function needsTime() { return S.data && S.data.requireScheduling && S.lines.length > 0 && !S.sel; }
function guard(e) {
if (!CFG.checkoutGuard || !needsTime()) return;
var t = e.target.closest && e.target.closest('[name="checkout"], a[href$="/checkout"], form[action$="/checkout"] [type="submit"]');
if (!t) return;
e.preventDefault(); e.stopPropagation();
setMessage(T.needTime, "critical");
renderAll();
var r = roots()[0];
if (r) { r.scrollIntoView({ behavior: "smooth", block: "center" }); }
}
document.addEventListener("click", guard, true);
// React to cart changes made by the theme (add, quantity, remove) — revalidate the selection.
var realFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
var url = typeof input === "string" ? input : input && input.url;
var p = realFetch(input, init);
if (url && /\/cart\/(add|change|update|clear)(\.js)?/.test(url) && !S.mutating) p.then(function () { refresh(); });
return p;
};
["cart:updated", "cart:change", "cart-drawer:open", "ajaxCart:updated"].forEach(function (n) {
document.addEventListener(n, function (e) { if (!(e.detail && e.detail.source === "datewise")) refresh(); });
});
if (typeof window.subscribe === "function" && window.PUB_SUB_EVENTS)
window.subscribe(window.PUB_SUB_EVENTS.cartUpdate, function (ev) { if (!ev || ev.source !== "datewise") refresh(); });
// ---------- cart drawer mounting ----------
var DRAWER_SELECTORS = [CFG.drawerSelector, "cart-drawer .drawer__footer", "#CartDrawer .drawer__footer", "#CartDrawer-Footer", ".cart-drawer__footer", "#cart-drawer .cart__footer", ".js-cart-drawer .cart-drawer__footer", "[data-cart-drawer] footer"].filter(Boolean);
function mountDrawer() {
for (var i = 0; i < DRAWER_SELECTORS.length; i++) {
var foot = document.querySelector(DRAWER_SELECTORS[i]);
if (foot && !foot.querySelector("[data-datewise-root]")) {
var el = document.createElement("div");
el.className = "datewise datewise--drawer";
el.setAttribute("data-datewise-root", "");
el.setAttribute("data-drawer", "");
foot.insertBefore(el, foot.firstChild);
if (S.data) render(el); else refresh(true);
return;
}
}
}
if (cfgEl) {
mountDrawer();
new MutationObserver(function () { mountDrawer(); }).observe(document.body, { childList: true, subtree: true });
}
// ---------- waitlist offer links (?datewise_offer=…) ----------
function adoptOffer() {
var m = location.search.match(/[?&]datewise_offer=([^&]+)/);
if (!m) return Promise.resolve();
return api("/offer?token=" + encodeURIComponent(m[1]), null, "GET").then(function (o) {
if (!o.ok) { setMessage(o.message, "critical"); return; }
S.token = o.token; lsSet("token", o.token);
S.method = o.method; S.locationId = o.locationId; S.date = o.date;
setMessage(T.offer + ": " + dateLabel(o.date, true) + ".", "success");
return load().then(function () { if (S.data && S.data.locationId === o.locationId) return hold(o.date, o.slot); });
});
}
adoptOffer().then(function () { if (!S.data) refresh(true); });
})();

View File

@ -0,0 +1,56 @@
{%- comment -%}
App embed: loads the widget on every page so it can (1) appear inside the cart drawer,
(2) adopt waitlist offers from links, and (3) stop checkout clicks when a time is required.
{%- endcomment -%}
<script type="application/json" data-datewise-config>
{
"drawerSelector": {{ block.settings.drawer_selector | json }},
"checkoutGuard": {{ block.settings.checkout_guard | json }},
"locale": {{ request.locale.iso_code | json }},
"currency": {{ cart.currency.iso_code | json }},
"text": {
"choose": {{ 'datewise.choose' | t | json }},
"needTime": {{ 'datewise.need_time' | t | json }},
"held": {{ 'datewise.held' | t | json }},
"expired": {{ 'datewise.expired' | t | json }},
"left": {{ 'datewise.left' | t | json }},
"full": {{ 'datewise.full' | t | json }},
"postal": {{ 'datewise.postal' | t | json }},
"check": {{ 'datewise.check' | t | json }},
"waitlist": {{ 'datewise.waitlist' | t | json }},
"joined": {{ 'datewise.joined' | t | json }},
"dispatch": {{ 'datewise.dispatch' | t | json }},
"change": {{ 'datewise.change' | t | json }},
"prep": {{ 'datewise.prep' | t | json }},
"free": {{ 'datewise.free' | t | json }},
"fee": {{ 'datewise.fee' | t | json }},
"nextAvailable": {{ 'datewise.next_available' | t | json }},
"offer": {{ 'datewise.offer' | t | json }},
"error": {{ 'datewise.error' | t | json }}
}
}
</script>
{% schema %}
{
"name": "Datewise",
"target": "body",
"javascript": "datewise.js",
"stylesheet": "datewise.css",
"settings": [
{
"type": "checkbox",
"id": "checkout_guard",
"label": "Ask for a time before checkout",
"default": true,
"info": "Stops the checkout button until a time is chosen, when your Datewise settings require one."
},
{
"type": "text",
"id": "drawer_selector",
"label": "Cart drawer selector (advanced)",
"info": "Only if the widget doesn't appear in your cart drawer. CSS selector for the drawer's footer."
}
]
}
{% endschema %}

View File

@ -0,0 +1,31 @@
{%- comment -%}
App block for the cart page. The widget renders into this element; the app embed
(embed.liquid) adds the same widget to cart drawers.
{%- endcomment -%}
<div
class="datewise"
data-datewise-root
data-heading="{{ block.settings.heading | escape }}"
{{ block.shopify_attributes }}
>
<div class="ss-skeleton" aria-hidden="true"></div>
<noscript>{{ 'datewise.noscript' | t }}</noscript>
</div>
{% schema %}
{
"name": "Delivery date & time",
"target": "section",
"javascript": "datewise.js",
"stylesheet": "datewise.css",
"enabled_on": { "templates": ["cart"] },
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"info": "Leave blank to use the heading from the Datewise app settings."
}
]
}
{% endschema %}

View File

@ -0,0 +1,23 @@
{
"datewise": {
"noscript": "Turn on JavaScript to choose a delivery or pickup time.",
"choose": "Pick a highlighted date",
"need_time": "Please choose a delivery or pickup time before checking out.",
"held": "Held for you for",
"expired": "Your hold expired. Tap the time again to check it's still free.",
"left": "left",
"full": "Full",
"postal": "Postal / ZIP code",
"check": "Check",
"waitlist": "Join the waitlist",
"joined": "You're on the waitlist. We'll message you if a place opens.",
"dispatch": "Ships this day (not an arrival date)",
"change": "Change",
"prep": "Earliest times include preparation for",
"free": "Free",
"fee": "fee",
"next_available": "Next available",
"offer": "A place opened up and we're holding it for you",
"error": "Something went wrong. Please try again."
}
}

View File

@ -0,0 +1,23 @@
{
"datewise": {
"noscript": "Activez JavaScript pour choisir une heure de livraison ou de cueillette.",
"choose": "Choisissez une date en surbrillance",
"need_time": "Veuillez choisir une heure de livraison ou de cueillette avant de payer.",
"held": "Réservé pour vous pendant",
"expired": "Votre réservation a expiré. Touchez l'heure de nouveau pour vérifier qu'elle est libre.",
"left": "restant(s)",
"full": "Complet",
"postal": "Code postal",
"check": "Vérifier",
"waitlist": "Rejoindre la liste d'attente",
"joined": "Vous êtes sur la liste d'attente. Nous vous écrirons si une place se libère.",
"dispatch": "Expédié ce jour-là (pas une date d'arrivée)",
"change": "Modifier",
"prep": "Les premières heures incluent la préparation de",
"free": "Gratuit",
"fee": "frais",
"next_available": "Prochaine disponibilité",
"offer": "Une place s'est libérée et nous la gardons pour vous",
"error": "Une erreur s'est produite. Veuillez réessayer."
}
}

View File

@ -0,0 +1,2 @@
name = "Datewise widget"
type = "theme"

View File

@ -0,0 +1,10 @@
{
"name": "datewise-order-reschedule",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@shopify/ui-extensions": "2026.1.x",
"preact": "^10.26.0"
}
}

View File

@ -0,0 +1,15 @@
api_version = "2026-01"
[[extensions]]
name = "Datewise: delivery time"
handle = "datewise-order-reschedule"
type = "ui_extension"
[[extensions.targeting]]
module = "./src/OrderStatusBlock.jsx"
target = "customer-account.order-status.block.render"
[extensions.capabilities]
# Calls the Datewise backend with the customer's session token.
network_access = true
api_access = true

View File

@ -0,0 +1,103 @@
/**
* Order status page block (new customer accounts): shows the booked date/time and lets the
* customer change it within the merchant's policy. All rules and capacity are checked on
* the server; this UI only lists the options the server returns.
*/
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
import { APP_URL } from "./config";
export default async () => {
render(<Extension />, document.body);
};
const fmtDate = (d) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" });
const fmtTime = (t) =>
new Date(Date.UTC(2000, 0, 1, +t.slice(0, 2), +t.slice(3, 5))).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
async function call(path, init = {}) {
const token = await shopify.sessionToken.get();
const res = await fetch(`${APP_URL}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers || {}) },
});
return res.json();
}
function Extension() {
const orderId = shopify.order.value?.id;
const [data, setData] = useState(null);
const [editing, setEditing] = useState(false);
const [choice, setChoice] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const load = () =>
orderId &&
call(`/api/customer/booking?orderId=${encodeURIComponent(orderId)}`)
.then(setData)
.catch(() => setData({ booking: null }));
useEffect(() => {
load();
}, [orderId]);
if (!data?.booking) return null;
const b = data.booking;
const label = { pickup: "Pickup", delivery: "Delivery", shipping: "Dispatch" }[b.method];
const save = async () => {
const opt = data.options.find((o) => `${o.date}|${o.slot}` === choice);
if (!opt) return;
setBusy(true);
const r = await call("/api/customer/booking", { method: "POST", body: JSON.stringify({ orderId, date: opt.date, slot: opt.slot }) });
setBusy(false);
if (r.ok) {
setMsg({ tone: "success", text: "Your new time is saved." });
setEditing(false);
load();
} else setMsg({ tone: "critical", text: r.message || "That time is no longer available." });
};
return (
<s-section heading={`${label} time`}>
<s-stack gap="base">
{msg && <s-banner tone={msg.tone}>{msg.text}</s-banner>}
<s-text type="strong">
{fmtDate(b.date)}
{b.method !== "shipping" && b.slotStart ? `, ${fmtTime(b.slotStart)} – ${fmtTime(b.slotEnd)}` : ""}
</s-text>
{b.location && <s-text color="subdued">{b.method === "pickup" ? `Pick up at ${b.location}` : `From ${b.location}`}</s-text>}
{b.instructions && <s-text color="subdued">{b.instructions}</s-text>}
{data.canReschedule && !editing && (
<s-stack gap="small-200">
<s-button onClick={() => setEditing(true)}>Change date or time</s-button>
<s-text color="subdued">You can change it until {new Date(data.deadline).toLocaleString()}.</s-text>
</s-stack>
)}
{!data.canReschedule && data.reason && <s-text color="subdued">{data.reason}</s-text>}
{editing && (
<s-stack gap="base">
{data.options.length === 0 ? (
<s-text>There are no other times available right now.</s-text>
) : (
<s-select label="New time" value={choice} onChange={(e) => setChoice(e.currentTarget.value)}>
<s-option value="">Choose a time</s-option>
{data.options.map((o) => (
<s-option key={`${o.date}|${o.slot}`} value={`${o.date}|${o.slot}`}>
{`${fmtDate(o.date)}${b.method !== "shipping" ? `, ${fmtTime(o.start)} – ${fmtTime(o.end)}` : ""}`}
</s-option>
))}
</s-select>
)}
<s-stack direction="inline" gap="base">
<s-button variant="primary" loading={busy} disabled={!choice} onClick={save}>Save new time</s-button>
<s-button variant="secondary" onClick={() => setEditing(false)}>Cancel</s-button>
</s-stack>
</s-stack>
)}
</s-stack>
</s-section>
);
}

View File

@ -0,0 +1,2 @@
// Set to your deployed app URL (same as application_url in shopify.app.toml).
export const APP_URL = "https://REPLACE-WITH-APP-URL";

9968
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "datewise",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Datewise \u2014 delivery & pickup date/time scheduling for Shopify, with capacity holds, peak-day rush control, perishable-aware dates, customer self-reschedule, reminders and driver run sheets.",
"scripts": {
"build": "react-router build",
"dev": "shopify app dev",
"config:link": "shopify app config link",
"deploy": "shopify app deploy",
"start": "react-router-serve ./build/server/index.js",
"setup": "prisma generate && prisma migrate deploy",
"typecheck": "react-router typegen && tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"worker": "node --import tsx ./app/jobs/worker.ts",
"prototype": "esbuild app/engine/index.ts --bundle --format=iife --global-name=DatewiseEngine --target=es2019 --minify --outfile=prototype/engine.js && node prototype/build.mjs",
"test:db": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres@localhost:5432/datewise_test} vitest run",
"e2e:server": "tsx tests/e2e/server.ts",
"e2e": "node tests/e2e/widget.e2e.mjs"
},
"engines": {
"node": ">=20.10"
},
"dependencies": {
"@prisma/adapter-pg": "6.19",
"@prisma/client": "^6.17.1",
"@react-router/dev": "^7.9.0",
"@react-router/fs-routes": "^7.9.0",
"@react-router/node": "^7.9.0",
"@react-router/serve": "^7.9.0",
"@shopify/app-bridge-react": "^4.2.0",
"@shopify/shopify-app-react-router": "^3.0.2",
"@shopify/shopify-app-session-storage-prisma": "^11.0.1",
"isbot": "^5.1.0",
"pg": "^8.23.1",
"prisma": "^6.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.9.0",
"vite": "^6.3.0",
"vite-tsconfig-paths": "^5.1.4",
"zod": "^3.25.0"
},
"devDependencies": {
"@shopify/api-codegen-preset": "^1.1.0",
"@shopify/app-bridge-types": "^0.7.2",
"@shopify/polaris-types": "^1.1.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.23.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^3.2.0",
"playwright": "^1.56.0"
}
}

View File

@ -0,0 +1,216 @@
-- Datewise initial schema (PostgreSQL)
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"state" TEXT NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"scope" TEXT,
"expires" TIMESTAMP(3),
"accessToken" TEXT NOT NULL,
"userId" BIGINT,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT,
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT,
"collaborator" BOOLEAN DEFAULT false,
"emailVerified" BOOLEAN DEFAULT false,
"refreshToken" TEXT,
"refreshTokenExpires" TIMESTAMP(3),
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Shop" (
"id" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'CAD',
"timezone" TEXT NOT NULL DEFAULT 'America/Toronto',
"settings" JSONB NOT NULL,
"configVersion" INTEGER NOT NULL DEFAULT 1,
"plan" TEXT NOT NULL DEFAULT 'free',
"feeProductId" TEXT,
"installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uninstalledAt" TIMESTAMP(3),
CONSTRAINT "Shop_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Shop_domain_key" ON "Shop"("domain");
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"shopifyLocationId" TEXT,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"timezone" TEXT NOT NULL,
"lat" DOUBLE PRECISION,
"lng" DOUBLE PRECISION,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 1,
"pickupInstructions" TEXT,
"methods" JSONB NOT NULL,
"exceptions" JSONB NOT NULL DEFAULT '[]',
"zones" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Location_shopId_idx" ON "Location"("shopId");
CREATE TABLE "ProductRule" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 10,
"target" JSONB NOT NULL,
"action" JSONB NOT NULL,
"message" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductRule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProductRule_shopId_idx" ON "ProductRule"("shopId");
CREATE TABLE "PeakDay" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"dates" TEXT[],
"locationIds" TEXT[],
"methods" TEXT[],
"dailyCapacity" INTEGER,
"slotCapacity" INTEGER,
"extraPrepMinutes" INTEGER NOT NULL DEFAULT 0,
"cutoffDaysBefore" INTEGER,
"cutoffTime" TEXT,
"surcharge" INTEGER NOT NULL DEFAULT 0,
"waitlist" BOOLEAN NOT NULL DEFAULT false,
"message" TEXT,
CONSTRAINT "PeakDay_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "PeakDay_shopId_idx" ON "PeakDay"("shopId");
CREATE TABLE "CapacityCounter" (
"shopId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"date" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "CapacityCounter_pkey" PRIMARY KEY ("shopId","locationId","method","date","slot"),
CONSTRAINT "CapacityCounter_count_nonneg" CHECK ("count" >= 0)
);
CREATE TABLE "Hold" (
"token" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"date" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"fee" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Hold_pkey" PRIMARY KEY ("token")
);
CREATE INDEX "Hold_shopId_locationId_method_date_idx" ON "Hold"("shopId", "locationId", "method", "date");
CREATE INDEX "Hold_expiresAt_idx" ON "Hold"("expiresAt");
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"orderId" TEXT,
"orderName" TEXT,
"status" TEXT NOT NULL,
"reviewReason" TEXT,
"method" TEXT NOT NULL,
"locationId" TEXT,
"date" TEXT,
"slotStart" TEXT,
"slotEnd" TEXT,
"timezone" TEXT NOT NULL,
"startUtc" TIMESTAMP(3),
"endUtc" TIMESTAMP(3),
"customerName" TEXT,
"customerEmail" TEXT,
"customerPhone" TEXT,
"address" JSONB,
"items" JSONB,
"fee" INTEGER NOT NULL DEFAULT 0,
"peakDayId" TEXT,
"rescheduleCount" INTEGER NOT NULL DEFAULT 0,
"configVersion" INTEGER NOT NULL,
"snapshot" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Booking_shopId_orderId_key" ON "Booking"("shopId", "orderId");
CREATE INDEX "Booking_shopId_date_idx" ON "Booking"("shopId", "date");
CREATE INDEX "Booking_shopId_status_idx" ON "Booking"("shopId", "status");
CREATE TABLE "BookingEvent" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"action" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "BookingEvent_bookingId_idx" ON "BookingEvent"("bookingId");
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"date" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"offeredAt" TIMESTAMP(3),
"offerToken" TEXT,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "WaitlistEntry_shopId_locationId_method_date_idx" ON "WaitlistEntry"("shopId", "locationId", "method", "date");
CREATE TABLE "ReminderJob" (
"id" TEXT NOT NULL,
"shopId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"sendAt" TIMESTAMP(3) NOT NULL,
"body" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"sentAt" TIMESTAMP(3),
CONSTRAINT "ReminderJob_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ReminderJob_status_sendAt_idx" ON "ReminderJob"("status", "sendAt");
CREATE TABLE "ProcessedWebhook" (
"id" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"shop" TEXT NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProcessedWebhook_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Location" ADD CONSTRAINT "Location_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProductRule" ADD CONSTRAINT "ProductRule_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PeakDay" ADD CONSTRAINT "PeakDay_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CapacityCounter" ADD CONSTRAINT "CapacityCounter_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Hold" ADD CONSTRAINT "Hold_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BookingEvent" ADD CONSTRAINT "BookingEvent_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BookingEvent" ADD CONSTRAINT "BookingEvent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReminderJob" ADD CONSTRAINT "ReminderJob_shopId_fkey" FOREIGN KEY ("shopId") REFERENCES "Shop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ReminderJob" ADD CONSTRAINT "ReminderJob_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
provider = "postgresql"

244
prisma/schema.prisma Normal file
View File

@ -0,0 +1,244 @@
// Datewise data model. Postgres is required: reservations rely on row locks.
generator client {
provider = "prisma-client-js"
previewFeatures = ["queryCompiler", "driverAdapters"]
engineType = "client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Required by @shopify/shopify-app-session-storage-prisma
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
refreshToken String?
refreshTokenExpires DateTime?
}
model Shop {
id String @id @default(cuid())
domain String @unique
currency String @default("CAD")
timezone String @default("America/Toronto")
/// ShopSettings JSON (holds, reschedule policy, reminders, mixed-cart policy, widget text)
settings Json
/// Incremented on every config change; stored on bookings as the rule version used.
configVersion Int @default(1)
plan String @default("free")
feeProductId String?
installedAt DateTime @default(now())
uninstalledAt DateTime?
locations Location[]
rules ProductRule[]
peakDays PeakDay[]
bookings Booking[]
holds Hold[]
counters CapacityCounter[]
waitlist WaitlistEntry[]
reminderJobs ReminderJob[]
events BookingEvent[]
}
model Location {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
shopifyLocationId String?
name String
address String
timezone String
lat Float?
lng Float?
enabled Boolean @default(true)
priority Int @default(1)
pickupInstructions String?
/// Partial<Record<Method, MethodConfig>>
methods Json
/// DateException[]
exceptions Json @default("[]")
/// DeliveryZone[]
zones Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shopId])
}
model ProductRule {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
name String
enabled Boolean @default(true)
priority Int @default(10)
target Json
action Json
message String?
updatedAt DateTime @updatedAt
@@index([shopId])
}
model PeakDay {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
name String
dates String[]
locationIds String[]
methods String[]
dailyCapacity Int?
slotCapacity Int?
extraPrepMinutes Int @default(0)
cutoffDaysBefore Int?
cutoffTime String?
surcharge Int @default(0)
waitlist Boolean @default(false)
message String?
@@index([shopId])
}
/// One row per (location, method, date, slot). slot = "*" is the day total.
/// Counts confirmed bookings + live holds. Mutated only inside row-locked transactions.
model CapacityCounter {
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
slot String
count Int @default(0)
@@id([shopId, locationId, method, date, slot])
}
model Hold {
token String @id
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
slot String
expiresAt DateTime
/// Fee quoted to the customer for this selection (minor units); re-checked against the paid order.
fee Int @default(0)
createdAt DateTime @default(now())
@@index([shopId, locationId, method, date])
@@index([expiresAt])
}
model Booking {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
orderId String?
orderName String?
status String // confirmed | needs_review | cancelled | fulfilled
reviewReason String?
method String
locationId String?
date String? // local YYYY-MM-DD
slotStart String? // local HH:MM
slotEnd String?
timezone String
startUtc DateTime?
endUtc DateTime?
customerName String?
customerEmail String?
customerPhone String?
address Json?
items Json?
fee Int @default(0)
peakDayId String?
rescheduleCount Int @default(0)
configVersion Int
/// Snapshot of what the customer agreed to (method, location, slot, fee, rules matched)
snapshot Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events BookingEvent[]
reminderJobs ReminderJob[]
@@unique([shopId, orderId])
@@index([shopId, date])
@@index([shopId, status])
}
model BookingEvent {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
actor String // system | merchant:<staff id> | customer
action String // confirmed | flagged | rescheduled | cancelled | resolved
before Json?
after Json?
at DateTime @default(now())
@@index([bookingId])
}
model WaitlistEntry {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
locationId String
method String
date String
slot String // "*" = any slot that day
email String?
phone String?
name String?
createdAt DateTime @default(now())
offeredAt DateTime?
/// Hold token reserved for this person when a place is offered.
offerToken String?
@@index([shopId, locationId, method, date])
}
model ReminderJob {
id String @id @default(cuid())
shopId String
shop Shop @relation(fields: [shopId], references: [id], onDelete: Cascade)
bookingId String
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
policyId String
channel String
sendAt DateTime
body String
status String @default("pending") // pending | sent | failed | cancelled
attempts Int @default(0)
lastError String?
sentAt DateTime?
@@index([status, sendAt])
}
/// Webhook idempotency: Shopify may deliver the same event more than once.
model ProcessedWebhook {
id String @id // X-Shopify-Event-Id (falls back to webhook id)
topic String
shop String
receivedAt DateTime @default(now())
}

7
prototype/build.mjs Normal file
View File

@ -0,0 +1,7 @@
// Inlines the bundled engine into the prototype page.
import { readFileSync, writeFileSync } from "node:fs";
const dir = new URL(".", import.meta.url);
const src = readFileSync(new URL("src.html", dir), "utf8");
const engine = readFileSync(new URL("engine.js", dir), "utf8").replace(/<\/script/gi, "<\\/script");
writeFileSync(new URL("datewise-prototype.html", dir), src.replace("/*ENGINE*/", () => engine));
console.log("built prototype/datewise-prototype.html");

File diff suppressed because one or more lines are too long

2
prototype/engine.js vendored Normal file

File diff suppressed because one or more lines are too long

772
prototype/src.html Normal file
View File

@ -0,0 +1,772 @@
<title>Datewise Prototype</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
/* Layout: app bar with view switch + simulated clock; two working surfaces (storefront, admin) built from one token set. */
:root{
--bg:#F2F5F3; --surface:#FFFFFF; --sunk:#E8EEEB; --ink:#13201D; --muted:#5A6965; --line:#D6DFDB;
--accent:#0D6B5D; --accent-ink:#FFFFFF; --accent-soft:#DDEFEA;
--peak:#A86400; --peak-soft:#FFF1D6;
--ok:#1E7A3A; --ok-soft:#E1F3E5; --bad:#B42318; --bad-soft:#FBE4E1; --warn:#9A5B00; --warn-soft:#FDF0D8;
--display:"Bricolage Grotesque", "Segoe UI", system-ui, sans-serif;
--body:"IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--mono:"IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
--r:10px;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#0D1412; --surface:#151F1C; --sunk:#1B2824; --ink:#E4EDEA; --muted:#97A8A3; --line:#2A3A35;
--accent:#4CC2AD; --accent-ink:#04201A; --accent-soft:#163A33;
--peak:#F0B44A; --peak-soft:#3A2B0F;
--ok:#5FCB7E; --ok-soft:#173523; --bad:#F2877A; --bad-soft:#3E1C18; --warn:#F0B44A; --warn-soft:#3A2B0F;
color-scheme:dark}}
:root[data-theme="dark"]{
--bg:#0D1412; --surface:#151F1C; --sunk:#1B2824; --ink:#E4EDEA; --muted:#97A8A3; --line:#2A3A35;
--accent:#4CC2AD; --accent-ink:#04201A; --accent-soft:#163A33;
--peak:#F0B44A; --peak-soft:#3A2B0F;
--ok:#5FCB7E; --ok-soft:#173523; --bad:#F2877A; --bad-soft:#3E1C18; --warn:#F0B44A; --warn-soft:#3A2B0F;
color-scheme:dark}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--ink);font:15px/1.5 var(--body);margin:0}
button,input,select,textarea{font:inherit;color:inherit}
button{cursor:pointer}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
h1,h2,h3{font-family:var(--display);text-wrap:balance;margin:0;letter-spacing:-.01em}
h2{font-size:1.25rem} h3{font-size:1.02rem}
.mono,.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
.muted{color:var(--muted)} .small{font-size:.85rem}
.label{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}
/* App bar */
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:5;background:var(--surface);border-bottom:1px solid var(--line)}
.bar-in{max-width:1240px;margin:0 auto;padding:10px 16px;display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center}
.brand{display:flex;align-items:center;gap:10px}
.brand svg{flex:none}
.brand b{font-family:var(--display);font-size:1.2rem}
.brand span{font-size:.75rem;color:var(--muted);border:1px solid var(--line);border-radius:99px;padding:1px 8px}
.seg{display:inline-flex;background:var(--sunk);border-radius:99px;padding:3px}
.seg button{border:0;background:transparent;padding:6px 14px;border-radius:99px;font-weight:500;color:var(--muted)}
.seg button[aria-pressed="true"]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px rgb(0 0 0/.12)}
.clock{margin-left:auto;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.clock .now{font-family:var(--mono);font-size:.86rem;background:var(--sunk);padding:5px 10px;border-radius:8px}
select,input[type=text],input[type=date],input[type=number],input[type=email],input[type=tel],input[type=time]{
background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:6px 9px;min-width:0}
.btn{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:7px 12px;font-weight:500}
.btn:hover{border-color:var(--accent)}
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.btn.primary:disabled{opacity:.45;cursor:not-allowed}
a.btn{text-decoration:none;color:var(--ink);display:inline-block}
.btn.ghost{border-color:transparent;background:transparent;color:var(--accent);padding:4px 6px}
.btn.sm{padding:4px 9px;font-size:.85rem}
main{max-width:1240px;margin:0 auto;padding-inline:16px;padding-block:20px 60px}
.notice{border-radius:var(--r);padding:10px 14px;margin-bottom:14px;display:flex;gap:10px;align-items:flex-start}
.notice.bad{background:var(--bad-soft);color:var(--bad)}
.notice.ok{background:var(--ok-soft);color:var(--ok)}
.notice.peak{background:var(--peak-soft);color:var(--peak)}
.notice.info{background:var(--accent-soft);color:var(--ink)}
/* Storefront */
.store{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr);gap:22px;align-items:start}
@media (max-width:900px){.store{grid-template-columns:1fr}}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:18px;min-width:0}
.storehead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;gap:10px;flex-wrap:wrap}
.item{display:grid;grid-template-columns:44px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}
.item:first-of-type{border-top:0}
.thumb{width:44px;height:44px;border-radius:9px;display:grid;place-items:center;font-family:var(--display);font-weight:700;color:var(--accent-ink)}
.item .t{font-weight:500}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
.tag{font-size:.72rem;border-radius:99px;padding:1px 8px;background:var(--sunk);color:var(--muted)}
.tag.fresh{background:var(--ok-soft);color:var(--ok)}
.qty{display:flex;align-items:center;gap:4px}
.qty button{width:28px;height:28px;border-radius:7px;border:1px solid var(--line);background:var(--surface)}
.qty span{min-width:22px;text-align:center;font-family:var(--mono)}
.totals{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}
.totals .grand{font-weight:600;font-size:1.05rem}
.widget h2{display:flex;align-items:center;gap:8px}
.methods{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}
.mbtn{border:1.5px solid var(--line);background:var(--surface);border-radius:11px;padding:10px;text-align:left;display:grid;gap:2px}
.mbtn b{font-weight:600}
.mbtn small{color:var(--muted);font-size:.78rem}
.mbtn[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
.mbtn:disabled{opacity:.55;cursor:not-allowed}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.field{display:grid;gap:4px;min-width:0}
.loclist{display:grid;gap:6px;margin-top:6px}
.loc{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--line);border-radius:10px;padding:8px 10px;background:var(--surface);text-align:left}
.loc[aria-pressed="true"]{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.loc:disabled{cursor:not-allowed;background:var(--sunk)}
.dot{width:9px;height:9px;border-radius:50%;background:var(--ok)}
.loc:disabled .dot{background:var(--muted)}
.cal{margin-top:14px}
.calhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.grid7{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}
.dow{font-size:.72rem;text-align:center;color:var(--muted);font-weight:600;padding-bottom:2px}
.day{position:relative;aspect-ratio:1/0.86;border-radius:9px;border:1px solid transparent;background:transparent;display:grid;place-items:center;font-family:var(--mono);font-size:.92rem;padding:0;max-width:100%}
.day.avail{background:var(--accent-soft);border-color:transparent}
.day.avail:hover{border-color:var(--accent)}
.day.full{background:var(--warn-soft);color:var(--warn)}
.day.closed{color:var(--muted);text-decoration:line-through;text-decoration-color:var(--line)}
.day.out{color:var(--line);cursor:default}
.day.sel{background:var(--accent);color:var(--accent-ink)}
.day .pk{position:absolute;top:4px;right:5px;width:6px;height:6px;border-radius:50%;background:var(--peak)}
.day .lft{position:absolute;bottom:2px;font-size:.6rem;font-family:var(--body);opacity:.8}
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:.76rem;color:var(--muted);margin-top:8px}
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:10px}
.slot{border:1.5px solid var(--line);border-radius:10px;padding:8px 10px;background:var(--surface);text-align:left;display:grid;gap:4px}
.slot b{font-family:var(--mono);font-weight:500;font-size:.9rem}
.slot[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
.slot:disabled{background:var(--sunk);cursor:not-allowed;color:var(--muted)}
.meter{height:5px;border-radius:9px;background:var(--sunk);overflow:hidden}
.meter i{display:block;height:100%;background:var(--accent)}
.meter.low i{background:var(--warn)}
.slot small{font-size:.74rem;color:var(--muted)}
.hold{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;background:var(--sunk);border-radius:10px;padding:9px 12px;margin-top:12px}
.hold .mono{font-size:1rem}
.summary{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 14px;margin-top:12px;font-size:.92rem}
.summary dt{color:var(--muted)} .summary dd{margin:0}
.demo{border:1px dashed var(--line);border-radius:10px;padding:10px 12px;margin-top:14px;display:grid;gap:8px}
.co{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:12px}
@media (max-width:560px){.co{grid-template-columns:1fr}.methods{grid-template-columns:1fr}}
.confirm{display:grid;gap:12px}
.timeline{display:grid;gap:6px}
.timeline div{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px;font-size:.88rem}
/* Admin */
.admin{display:grid;grid-template-columns:200px minmax(0,1fr);gap:22px;align-items:start}
@media (max-width:820px){.admin{grid-template-columns:1fr}}
.nav{display:grid;gap:2px;position:sticky;top:76px}
@media (max-width:820px){.nav{position:static;grid-auto-flow:column;overflow-x:auto;grid-auto-columns:max-content}}
.nav button{border:0;background:transparent;text-align:left;padding:8px 12px;border-radius:8px;color:var(--muted);font-weight:500;display:flex;justify-content:space-between;gap:10px}
.nav button[aria-current="true"]{background:var(--surface);color:var(--ink);box-shadow:inset 3px 0 0 var(--accent)}
.badge{font-size:.72rem;border-radius:99px;padding:0 7px;background:var(--bad-soft);color:var(--bad);font-family:var(--mono)}
.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
@media (max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
.kpi{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
.kpi .v{font-family:var(--display);font-size:1.7rem;font-weight:700;line-height:1.1}
.kpi.alert .v{color:var(--bad)}
.stack{display:grid;gap:16px}
.capchart{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(26px,1fr);gap:4px;align-items:end;height:150px;padding-top:8px;overflow-x:auto}
.cbar{display:grid;grid-template-rows:1fr auto auto;height:100%;gap:3px;text-align:center}
.cbar .track{position:relative;background:var(--sunk);border-radius:5px;overflow:hidden;align-self:stretch}
.cbar .fill{position:absolute;left:0;right:0;bottom:0;background:var(--accent)}
.cbar.peak .fill{background:var(--peak)}
.cbar.full .fill{background:var(--bad)}
.cbar span{font-size:.64rem;color:var(--muted);font-family:var(--mono)}
.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:.88rem}
th{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;font-weight:600;background:var(--sunk)}
th,td{padding:8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle}
tr:last-child td{border-bottom:0}
td.wrap{white-space:normal;min-width:180px}
.pill{font-size:.74rem;padding:1px 8px;border-radius:99px;font-weight:500;display:inline-block}
.pill.confirmed{background:var(--ok-soft);color:var(--ok)}
.pill.needs_review{background:var(--bad-soft);color:var(--bad)}
.pill.cancelled{background:var(--sunk);color:var(--muted)}
.pill.peak{background:var(--peak-soft);color:var(--peak)}
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px}
.form{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{display:inline-flex;gap:6px;align-items:center;background:var(--sunk);border-radius:99px;padding:3px 6px 3px 10px;font-family:var(--mono);font-size:.82rem}
.chip button{border:0;background:transparent;color:var(--muted);padding:0 4px}
.strip{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(30px,1fr);gap:3px;overflow-x:auto}
.strip div{border-radius:6px;padding:4px 0;text-align:center;font-size:.68rem;font-family:var(--mono)}
.strip .y{background:var(--ok-soft);color:var(--ok)} .strip .n{background:var(--sunk);color:var(--muted)}
.reasons{display:grid;gap:6px}
.reason{display:grid;grid-template-columns:120px minmax(0,1fr);gap:10px;padding:8px 10px;border-radius:8px;background:var(--sunk);font-size:.88rem}
.reason.ok{background:var(--ok-soft)}
code{font-family:var(--mono);font-size:.82rem;background:var(--sunk);padding:1px 5px;border-radius:5px}
textarea{width:100%;min-height:120px;border:1px solid var(--line);border-radius:8px;padding:8px;font-family:var(--mono);font-size:.78rem;background:var(--surface)}
.switch{display:inline-flex;gap:8px;align-items:center}
.sectionhead{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
</style>
<header class="bar"><div class="bar-in">
<div class="brand">
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><rect x="2" y="4" width="24" height="21" rx="5" fill="var(--accent)"/><rect x="2" y="4" width="24" height="6" rx="3" fill="var(--ink)" opacity=".25"/><path d="M8.5 16.5l3.6 3.4 7.4-7.4" stroke="var(--accent-ink)" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<b>Datewise</b><span>prototype · sample data</span>
</div>
<div class="seg" role="group" aria-label="View">
<button id="v-shop" data-act="view" data-v="shop">Customer view</button>
<button id="v-admin" data-act="view" data-v="admin">Merchant admin</button>
</div>
<div class="clock">
<span class="label">Store time</span>
<span class="now" id="nowLabel"></span>
<select id="preset" aria-label="Jump to a moment">
<option value="live">Live clock</option>
<option value="2026-10-14T09:00">Wed Oct 14, 9:00 AM</option>
<option value="2026-10-14T14:05">Wed Oct 14, 2:05 PM: after pickup cutoff</option>
<option value="2026-11-05T10:00">Thu Nov 5, 10:00 AM: Diwali week</option>
<option value="2026-11-06T18:01">Fri Nov 6, 6:01 PM: Diwali cutoff passed</option>
</select>
<button class="btn sm" data-act="skip">+11 min</button>
</div>
</div></header>
<main id="app"></main>
<script>/*ENGINE*/</script>
<script>
(function(){
"use strict";
var E = window.DatewiseEngine;
var TZ = "America/Toronto";
/* ---------- formatting ---------- */
function money(c){ return "$" + (c/100).toFixed(2); }
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c];}); }
function t12(hhmm){ var p=hhmm.split(":"),h=+p[0],m=p[1]; var ap=h>=12?"PM":"AM"; h=h%12||12; return h+(m==="00"?"":":"+m)+" "+ap; }
function slotLabel(s,e){ return t12(s)+" – "+t12(e); }
function dLabel(d,opts){ return new Date(d+"T12:00:00Z").toLocaleDateString("en-CA",Object.assign({weekday:"short",month:"short",day:"numeric",timeZone:"UTC"},opts||{})); }
function nowLabel(ms){ var p=E.localParts(ms,TZ); return dLabel(p.date)+" · "+t12(p.time); }
var METHOD_NAME={pickup:"Store pickup",delivery:"Local delivery",shipping:"Shipping"};
/* ---------- simulated clock ---------- */
var clock={base:Date.now(),setAt:Date.now()};
function now(){ return clock.base + (Date.now()-clock.setAt); }
function setClock(ms){ clock.base=ms; clock.setAt=Date.now(); }
/* ---------- sample configuration ---------- */
function hrs(a,b){ return [{start:a,end:b}]; }
function week(days,a,b){ var o={}; days.forEach(function(d){o[d]=hrs(a,b);}); return o; }
var dSlots=[{start:"10:00",end:"13:00"},{start:"13:00",end:"16:00"},{start:"16:00",end:"19:00",surcharge:300}];
function explicit(days){ var o={}; days.forEach(function(d){o[d]=dSlots;}); return o; }
var LOCS=[{
id:"kit",name:"Kitchener — Guelph St",address:"Unit 7, 900 Guelph St, Kitchener, ON N2H 5Z6",timezone:TZ,lat:43.4643,lng:-80.4844,enabled:true,priority:1,
pickupInstructions:"Unit 7. Ring the bell at the side door.",
exceptions:[{date:"2026-11-08",hours:hrs("09:00","15:00"),methods:["pickup"],note:"Diwali Sunday pickup hours"},{date:"2026-10-26",closed:true,methods:["pickup"],note:"Closed for stock count"}],
zones:[{id:"kw",name:"Kitchener–Waterloo",postalPatterns:["N2A*","N2B*","N2C*","N2E*","N2G*","N2H*","N2K*","N2M*","N2P*","N2R*"],fee:799,freeOver:7500,minOrder:2500},
{id:"r25",name:"Within 25 km",radiusKm:25,fee:1299,minOrder:4000}],
methods:{
pickup:{enabled:true,weeklyHours:week([1,2,3,4,5,6],"10:00","18:00"),slotMode:"generated",slotMinutes:60,prepMinutes:60,prepMode:"elapsed",sameDayCutoff:"14:00",horizonDays:30,slotCapacity:4},
delivery:{enabled:true,weeklyHours:week([0,2,3,4,5,6],"10:00","19:00"),slotMode:"explicit",slots:explicit([0,2,3,4,5,6]),prepMinutes:120,prepMode:"elapsed",sameDayCutoff:"12:00",horizonDays:30,slotCapacity:5,dailyCapacity:12},
shipping:{enabled:true,weeklyHours:week([1,2,3,4,5],"10:00","17:00"),slotMode:"dateOnly",prepMinutes:0,prepMode:"operating",sameDayCutoff:"14:00",horizonDays:14}
}
},{
id:"wat",name:"Waterloo — King St (sample)",address:"King St N, Waterloo, ON",timezone:TZ,lat:43.4723,lng:-80.5449,enabled:true,priority:2,
pickupInstructions:"Front counter.",exceptions:[],
zones:[{id:"wl",name:"Waterloo",postalPatterns:["N2J*","N2L*","N2T*","N2V*"],fee:599,freeOver:6000,minOrder:2000}],
methods:{
pickup:{enabled:true,weeklyHours:week([2,3,4,5,6],"11:00","19:00"),slotMode:"generated",slotMinutes:120,prepMinutes:90,prepMode:"elapsed",sameDayCutoff:"15:00",horizonDays:30,slotCapacity:3},
delivery:{enabled:true,weeklyHours:week([3,5,6],"12:00","18:00"),slotMode:"generated",slotMinutes:180,prepMinutes:180,prepMode:"elapsed",sameDayCutoff:"11:00",horizonDays:21,slotCapacity:4,dailyCapacity:8}
}
}];
var PRODUCTS=[
{productId:"p_marigold",variantId:"v_marigold",title:"Fresh Marigold Garland",price:1999,collectionIds:["c_flowers"],vendor:"C2C Agricorp",productType:"Fresh Flowers",tags:["perishable"],color:"#D98A04",abbr:"MG"},
{productId:"p_pakku",variantId:"v_pakku",title:"பாக்கு (Pākkŭ) 200 g",price:699,collectionIds:["c_puja"],vendor:"Clicks to Cart",productType:"Puja",tags:[],color:"#7A4E2D",abbr:"PK"},
{productId:"p_diya",variantId:"v_diya",title:"Brass Diya Set (4)",price:2499,collectionIds:["c_puja","c_diwali"],vendor:"Clicks to Cart",productType:"Puja",tags:[],color:"#9C7A1C",abbr:"DY"},
{productId:"p_saree",variantId:"v_saree",title:"Kanchipuram Silk Saree",price:15999,collectionIds:["c_apparel"],vendor:"Clicks to Cart",productType:"Apparel",tags:[],color:"#8E1F4A",abbr:"SR"}
];
var INVENTORY={kit:{v_marigold:40,v_pakku:80,v_diya:30,v_saree:6},wat:{v_marigold:20,v_pakku:40,v_diya:12,v_saree:0}};
var RULES=[
{id:"r_noship",name:"Fresh flowers can't ship",enabled:true,priority:1,target:{type:"productType",values:["Fresh Flowers"]},action:{type:"prohibitMethods",methods:["shipping"]},message:"Fresh flowers are pickup or local delivery only"},
{id:"r_prep_fresh",name:"Garland stringing: +4 h",enabled:true,priority:2,target:{type:"tag",values:["perishable"]},action:{type:"addPrep",minutes:240}},
{id:"r_prep_saree",name:"Saree fall & pico: +1 day",enabled:true,priority:3,target:{type:"collection",values:["c_apparel"]},action:{type:"addPrep",minutes:1440}},
{id:"r_fresh",name:"Marigold air shipments",enabled:true,priority:4,target:{type:"product",values:["p_marigold"]},
action:{type:"freshness",arrivals:[{date:"2026-10-13"},{date:"2026-10-20"},{date:"2026-10-27"},{date:"2026-11-03"},{date:"2026-11-05"}],minDaysAfterArrival:1,shelfLifeDays:4}}
];
var PEAKS=[{id:"pk_diwali",name:"Diwali",dates:["2026-11-07","2026-11-08"],methods:["delivery","pickup"],dailyCapacity:30,slotCapacity:10,extraPrepMinutes:60,cutoff:{daysBefore:2,time:"18:00"},surcharge:500,waitlist:true,message:"Diwali rush: orders for Nov 7–8 close two days earlier at 6 PM"}];
var SETTINGS={mixedPrepPolicy:"longest",holdMinutes:10,weekStartsOn:0,
reschedule:{enabled:true,minHoursBefore:24,maxChanges:2,allowMethodChange:false},
reminders:[
{id:"rm1",channel:"whatsapp",minutesBefore:1440,enabled:true,template:"Hi {{name}}, your {{method}} for {{order}} is tomorrow, {{date}} {{time}}. Need to change it? Use the link in your order page."},
{id:"rm2",channel:"sms",minutesBefore:120,enabled:true,methods:["delivery"],template:"{{name}}, your driver is out for delivery {{time}} today. Order {{order}}."},
{id:"rm3",channel:"email",minutesBefore:1440,enabled:true,methods:["pickup"],template:"Your pickup for {{order}} is ready {{date}} {{time}}."}]};
var GEO={N2H:[43.455,-80.49],N2G:[43.445,-80.49],N2M:[43.44,-80.51],N2E:[43.42,-80.47],N2A:[43.45,-80.43],N2B:[43.47,-80.46],N2K:[43.48,-80.47],N2P:[43.40,-80.43],N2R:[43.40,-80.48],N2C:[43.43,-80.45],N2J:[43.47,-80.52],N2L:[43.475,-80.535],N2T:[43.455,-80.565],N2V:[43.50,-80.55],N3C:[43.39,-80.32],N3H:[43.40,-80.33],N1H:[43.545,-80.25],N3A:[43.36,-80.44],M5V:[43.64,-79.39],L5B:[43.59,-79.64]};
/* ---------- reservation store + seeded bookings ---------- */
var store=new E.MemoryReservationStore();
var NAMES=["Anjali R.","Karthik S.","Meena P.","Ravi K.","Deepa N.","Suresh V.","Lakshmi T.","Arjun M.","Priya D.","Vijay A.","Kavya B.","Hari G.","Nisha J.","Rahul C.","Divya L.","Ganesh O."];
var STREETS=["King St E","Weber St","Victoria St N","Highland Rd W","Fischer-Hallman Rd","Ottawa St S","Belmont Ave","Queen St S","Frederick St","Lancaster St W"];
var seed=7; function rnd(){ seed=(seed*16807)%2147483647; return (seed-1)/2147483646; }
var orderNo=1000;
function addBooking(b){ var id="bk_s"+(++orderNo); b.id=id; b.orderId="o"+orderNo; b.orderName="#"+orderNo; b.timezone=TZ; b.rescheduleCount=0; store.bookings.set(id,b); return b; }
(function seedBookings(){
var start="2026-10-10";
for(var i=0;i<56;i++){
var date=E.addDays(start,i), wd=E.weekdayOf(date);
var peak=PEAKS[0].dates.indexOf(date)>=0;
if([0,2,3,4,5,6].indexOf(wd)>=0 || peak){
var n=peak?(date==="2026-11-08"?27:22):Math.floor(3+rnd()*7);
for(var k=0;k<n;k++){
var s=peak&&date==="2026-11-08"&&k<10?dSlots[0]:dSlots[Math.floor(rnd()*3)];
var lat=43.4643+(rnd()-.5)*0.09, lng=-80.4844+(rnd()-.5)*0.13;
addBooking({status:"confirmed",method:"delivery",locationId:"kit",date:date,slotStart:s.start,slotEnd:s.end,
customer:{name:NAMES[Math.floor(rnd()*NAMES.length)],email:"customer"+orderNo+"@example.com",phone:"+1519555"+(1000+orderNo)},
address:{line1:(10+Math.floor(rnd()*400))+" "+STREETS[Math.floor(rnd()*STREETS.length)],city:"Kitchener",postal:"N2H",lat:lat,lng:lng},
items:[{title:"Fresh Marigold Garland",quantity:1+Math.floor(rnd()*3)}],peakDayId:peak?"pk_diwali":null});
}
}
if(wd>=1&&wd<=6){
var p=Math.floor(rnd()*5);
for(var q=0;q<p;q++){
var h=10+Math.floor(rnd()*8); var hh=(h<10?"0":"")+h+":00", he=(h+1<10?"0":"")+(h+1)+":00";
addBooking({status:"confirmed",method:"pickup",locationId:"kit",date:date,slotStart:hh,slotEnd:he,customer:{name:NAMES[Math.floor(rnd()*NAMES.length)],email:"pickup"+orderNo+"@example.com"},items:[{title:"பாக்கு (Pākkŭ) 200 g",quantity:1}]});
}
}
}
addBooking({status:"needs_review",method:"delivery",locationId:"kit",date:"2026-11-08",slotStart:"10:00",slotEnd:"13:00",customer:{name:"Sangeetha R.",email:"sangeetha@example.com",phone:"+15195550177"},address:{line1:"55 Weber St",postal:"N2H"},items:[{title:"Fresh Marigold Garland",quantity:4}],note:"Hold expired while paying and the slot filled up"});
addBooking({status:"needs_review",method:"pickup",locationId:"",date:"",customer:{name:"Mohan K.",email:"mohan@example.com"},items:[{title:"Brass Diya Set (4)",quantity:1}],note:"Order came through Buy now with no date selected"});
store.joinWaitlist({locationId:"kit",method:"delivery",date:"2026-11-08",slot:"10:00-13:00"},{name:"Uma S.",email:"uma@example.com"},Date.now());
store.joinWaitlist({locationId:"kit",method:"delivery",date:"2026-11-08",slot:"10:00-13:00"},{name:"Bala V.",phone:"+15195550190"},Date.now());
})();
/* ---------- state ---------- */
var S={view:"shop",tab:"overview",cart:{v_marigold:2,v_pakku:1,v_diya:1,v_saree:0},method:"delivery",locId:null,postal:"N2H 5Z6",
month:null,date:null,sel:null,token:null,notice:null,lastOrder:null,resched:null,
bf:{status:"all",method:"all",from:"",loc:"all"},run:{date:null},diag:{method:"delivery",loc:"kit",date:null},
customer:{name:"Priya Raman",email:"priya@example.com",phone:"+1 519 555 0142"},waitEmail:""};
function cart(){ var items=[]; PRODUCTS.forEach(function(p){ var q=S.cart[p.variantId]||0; if(q>0) items.push(Object.assign({},p,{quantity:q})); }); return {items:items,subtotal:items.reduce(function(s,i){return s+i.price*i.quantity;},0)}; }
function geocode(postal){ var n=E.normalizePostal(postal); var g=GEO[n.slice(0,3)]; return g?{postal:postal,lat:g[0],lng:g[1]}:{postal:postal}; }
function locById(id){ return LOCS.filter(function(l){return l.id===id;})[0]; }
function usageExcludingMine(){ var mine=S.token&&store.holds.get(S.token); if(mine) store.holds.delete(S.token); var u=store.usage(now()); if(mine) store.holds.set(S.token,mine); return u; }
function ctx(extra){ return Object.assign({locations:LOCS,method:S.method,cart:cart(),rules:RULES,peakDays:PEAKS,usage:usageExcludingMine(),settings:SETTINGS,now:now(),address:geocode(S.postal),inventory:INVENTORY,ranking:"priority"},extra||{}); }
function myHold(){ var h=S.token&&store.holds.get(S.token); return h&&h.expiresAt>now()?h:null; }
/* ---------- storefront ---------- */
function computeShop(){
var c=cart(); if(!c.items.length) return {empty:true};
var res=E.rankLocations(ctx());
var chosen=null;
res.ranked.forEach(function(r){ if(r.location.id===S.locId && r.eligible) chosen=r; });
if(!chosen) chosen=res.ranked.filter(function(r){return r.eligible;})[0]||null;
return {ranked:res.ranked,split:res.splitRequired,chosen:chosen,cart:c};
}
function methodState(m){
var c=cart(); if(!c.items.length) return {ok:false};
var r=E.rankLocations(ctx({method:m}));
var ok=r.ranked.some(function(x){return x.eligible;});
var why=ok?"":((r.splitRequired&&r.splitRequired.message)||(r.ranked[0]&&r.ranked[0].reasons[0]&&r.ranked[0].reasons[0].message)||"Unavailable");
var first=null; r.ranked.forEach(function(x){ if(x.eligible&&x.first&&(!first||x.first.date<first.date)) first=x.first; });
return {ok:ok,why:why,first:first};
}
function revalidate(sh){
if(!S.sel) return;
var reason=null;
if(!sh.chosen || sh.chosen.location.id!==S.sel.locationId || S.method!==S.sel.method) reason="Your delivery choice changed, so the time you picked was released.";
else {
var d=sh.chosen.availability.dates.filter(function(x){return x.date===S.sel.date;})[0];
var s=d&&d.slots.filter(function(x){return x.id===S.sel.slot;})[0];
if(!s||!s.available){ var rs=(s&&s.reasons[0])||(d&&d.reasons[0]); reason="Your selected time is no longer available"+(rs?": "+rs.message.charAt(0).toLowerCase()+rs.message.slice(1):"")+". Please choose another."; }
}
if(reason){ if(S.token) store.release(S.token); S.sel=null; S.notice={kind:"bad",text:reason}; }
}
function renderShop(){
if(S.lastOrder && !S.resched) return renderConfirmation();
var sh=computeShop(); var c=cart();
if(!sh.empty) revalidate(sh);
var h='';
if(S.notice) h+='<div class="notice '+S.notice.kind+'" role="status">'+esc(S.notice.text)+'</div>';
h+='<div class="store">';
// cart
h+='<section class="panel" aria-labelledby="cartH"><div class="storehead"><h2 id="cartH">Your cart</h2><span class="muted small">Change items to see dates update</span></div>';
PRODUCTS.forEach(function(p){
var q=S.cart[p.variantId]||0; var tags='';
if(p.tags.indexOf("perishable")>=0) tags+='<span class="tag fresh">Fresh · ships by air from India</span>';
if(p.productType==="Apparel") tags+='<span class="tag">Tailoring +1 day</span>';
if(p.variantId==="v_saree") tags+='<span class="tag">Not stocked at Waterloo</span>';
h+='<div class="item"><div class="thumb" style="background:'+p.color+'">'+p.abbr+'</div><div><div class="t">'+esc(p.title)+'</div><div class="tags"><span class="tag">'+money(p.price)+'</span>'+tags+'</div></div>'+
'<div class="qty"><button aria-label="Remove one '+esc(p.title)+'" data-act="qty" data-v="'+p.variantId+'" data-d="-1">−</button><span>'+q+'</span><button aria-label="Add one '+esc(p.title)+'" data-act="qty" data-v="'+p.variantId+'" data-d="1">+</button></div></div>';
});
var fee=feeQuote(sh);
h+='<div class="totals"><span>Subtotal</span><span class="num">'+money(c.subtotal)+'</span>';
if(fee){ h+='<span>'+esc(METHOD_NAME[S.method])+' fee'+(fee.waived?' (free over '+money(sh.chosen.zone.zone.freeOver)+')':'')+'</span><span class="num">'+money(fee.waived?0:fee.baseFee)+'</span>';
fee.surcharges.forEach(function(x){ h+='<span>'+esc(x.label)+'</span><span class="num">'+money(x.amount)+'</span>'; }); }
h+='<span class="grand">Total</span><span class="grand num">'+money(c.subtotal+(fee?fee.fee:0))+'</span></div>';
if(fee) h+='<p class="muted small">Fees are added to the cart as a “Delivery &amp; handling” line, so they are charged at checkout on any Shopify plan.</p>';
h+='</section>';
// widget
h+='<section class="panel widget" aria-labelledby="wH"><h2 id="wH">When would you like it?</h2>';
if(sh.empty){ h+='<p class="muted">Add an item to choose a date.</p></section></div>'; return h; }
h+='<div class="methods" role="group" aria-label="Delivery method">';
["delivery","pickup","shipping"].forEach(function(m){
var st=methodState(m);
h+='<button class="mbtn" data-act="method" data-v="'+m+'" aria-pressed="'+(S.method===m)+'"'+(st.ok||S.method===m?'':' disabled')+'><b>'+METHOD_NAME[m]+'</b><small>'+(st.ok?(st.first?'From '+dLabel(st.first.date):''):esc(st.why))+'</small></button>';
});
h+='</div>';
if(S.method==="delivery"){
h+='<div class="row"><div class="field" style="flex:1"><label class="label" for="postal">Delivery postal code</label><input type="text" id="postal" value="'+esc(S.postal)+'" autocomplete="postal-code" placeholder="e.g. N2H 5Z6"></div><button class="btn" data-act="postal" style="align-self:end">Check</button></div>';
h+='<p class="muted small" style="margin:6px 0 0">Try N2L 3G1 (Waterloo), N3C 2V4 (Cambridge, radius zone), or M5V 2T6 (Toronto, outside area).</p>';
}
if(sh.split) h+='<div class="notice bad" style="margin-top:12px">'+esc(sh.split.message)+'</div>';
if(S.method!=="shipping"){
h+='<div class="label" style="margin-top:14px">'+(S.method==="pickup"?"Pickup location":"Delivering from")+'</div><div class="loclist">';
sh.ranked.forEach(function(r){
var sel=sh.chosen&&sh.chosen.location.id===r.location.id;
var right=r.eligible?(r.fee?(r.fee.waived?'Free':money(r.fee.baseFee)):(r.distanceKm!=null?r.distanceKm.toFixed(1)+' km':'')):'';
h+='<button class="loc" data-act="loc" data-v="'+r.location.id+'" aria-pressed="'+sel+'"'+(r.eligible?'':' disabled')+'><span class="dot"></span><span><b>'+esc(r.location.name)+'</b><br><span class="small muted">'+(r.eligible?(r.first?'Next available '+dLabel(r.first.date):'')+(r.zone&&r.zone.zone?' · '+esc(r.zone.zone.name):''):esc(r.reasons[0]&&r.reasons[0].message))+'</span></span><span class="small num">'+right+'</span></button>';
});
h+='</div>';
}
if(!sh.chosen){ h+='<div class="notice bad" style="margin-top:12px">No location can take this order with '+esc(METHOD_NAME[S.method].toLowerCase())+'. Try another method or change your cart.</div></section></div>'; return h; }
var av=sh.chosen.availability;
var cons=E.evaluateRules(c,RULES,SETTINGS.mixedPrepPolicy);
if(cons.prepDriver) h+='<p class="small muted" style="margin:10px 0 0">Earliest times include '+fmtMins(av.prepMinutes)+' preparation, set by <b>'+esc(cons.prepDriver.itemTitle)+'</b>.</p>';
var peakDates=av.dates.filter(function(d){return d.peak&&d.peak.message;});
if(peakDates.length) h+='<div class="notice peak" style="margin:10px 0 0">'+esc(peakDates[0].peak.message)+(peakDates[0].surcharge?'. Peak-day handling +'+money(peakDates[0].surcharge)+'.':'')+'</div>';
h+=renderCalendar(av);
h+=renderSlots(av,sh);
h+='</section></div>';
return h;
}
function fmtMins(m){ if(m>=1440&&m%60===0) return Math.round(m/60)+' h'; if(m>=60) return (m/60).toFixed(m%60?1:0)+' h'; return m+' min'; }
function feeQuote(sh){
if(!sh||sh.empty||!sh.chosen) return null;
var extras=[];
if(S.sel){
var d=sh.chosen.availability.dates.filter(function(x){return x.date===S.sel.date;})[0];
var s=d&&d.slots.filter(function(x){return x.id===S.sel.slot;})[0];
if(s&&s.surcharge) extras.push({label:"Evening slot",amount:s.surcharge});
if(d&&d.peak&&d.surcharge) extras.push({label:d.peak.name+" peak handling",amount:d.surcharge});
}
if(S.method==="delivery") return E.quoteFee(sh.chosen.zone.zone,sh.cart.subtotal,extras);
if(extras.length) return E.quoteFee(null,sh.cart.subtotal,extras);
return null;
}
function renderCalendar(av){
var map={}; av.dates.forEach(function(d){map[d.date]=d;});
var today=E.localParts(now(),TZ).date;
if(!S.month) S.month=today.slice(0,7);
var first=S.month+"-01", wd=E.weekdayOf(first);
var h='<div class="cal"><div class="calhead"><button class="btn ghost" data-act="month" data-d="-1" aria-label="Previous month">‹</button><b>'+new Date(first+"T12:00:00Z").toLocaleDateString("en-CA",{month:"long",year:"numeric",timeZone:"UTC"})+'</b><button class="btn ghost" data-act="month" data-d="1" aria-label="Next month">›</button></div><div class="grid7">';
["S","M","T","W","T","F","S"].forEach(function(x,i){ h+='<div class="dow" aria-hidden="true">'+["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][i]+'</div>'; });
for(var i=0;i<wd;i++) h+='<div></div>';
for(var day=1;day<=31;day++){
var date=S.month+"-"+(day<10?"0":"")+day;
if(E.addDays(first,day-1)!==date) break;
var d=map[date]; var cls="day", lab=dLabel(date,{weekday:"long",month:"long"});
var extra='';
if(!d){ cls+=" out"; lab+=", not bookable"; }
else if(d.available){ cls+=" avail"; lab+=", available"; if(d.remaining!=null&&d.remaining<=5) extra='<span class="lft">'+d.remaining+' left</span>'; }
else if(d.waitlist||(d.reasons[0]&&/FULL/.test(d.reasons[0].code))){ cls+=" full"; lab+=", fully booked"; extra='<span class="lft">full</span>'; }
else { cls+=" closed"; lab+=", "+(d.reasons[0]?d.reasons[0].message:"unavailable"); }
if(S.date===date) cls+=" sel";
var pk=d&&d.peak?'<span class="pk" title="'+esc(d.peak.name)+'"></span>':'';
h+='<button class="'+cls+'" data-act="date" data-v="'+date+'" aria-label="'+esc(lab)+'" title="'+esc(d&&!d.available&&d.reasons[0]?d.reasons[0].message:"")+'"'+(d?'':' disabled')+'>'+day+pk+extra+'</button>';
}
h+='</div><div class="legend"><span><i style="background:var(--accent-soft)"></i>Available</span><span><i style="background:var(--warn-soft)"></i>Fully booked</span><span><i style="background:var(--peak);border-radius:50%"></i>Festival day</span></div></div>';
return h;
}
function renderSlots(av,sh){
if(!S.date) return '<p class="muted small" style="margin-top:12px">Pick a highlighted date.</p>';
var d=av.dates.filter(function(x){return x.date===S.date;})[0];
if(!d) return '';
var h='<div style="margin-top:14px"><div class="sectionhead"><h3>'+dLabel(d.date,{weekday:"long",month:"long"})+(d.peak?' <span class="pill peak">'+esc(d.peak.name)+'</span>':'')+'</h3>';
if(av.dateOnly) h+='<span class="small muted">Dispatch date, not an arrival promise</span>';
h+='</div>';
if(!d.available){
h+='<div class="notice '+(d.waitlist?'peak':'bad')+'" style="margin-top:8px">'+esc(d.reasons.map(function(r){return r.message;}).join(". "))+'</div>';
if(d.waitlist){
h+='<div class="row"><input type="email" id="waitEmail" placeholder="Email for a spot" value="'+esc(S.waitEmail)+'" style="flex:1"><button class="btn" data-act="waitlist">Join the waitlist</button></div><p class="small muted">We hold the next freed place for you for 30 minutes and message you first.</p>';
}
return h+'</div>';
}
var mine=myHold();
h+='<div class="slots" role="group" aria-label="Time slots">';
d.slots.forEach(function(s){
var selected=S.sel&&S.sel.date===d.date&&S.sel.slot===s.id;
var cap=s.remaining; var meter='';
if(cap!=null){ var capTot=E.capsFor(sh.chosen.location,S.method,d.date,s.id,PEAKS); var tot=Math.min(capTot.slotCap||cap,capTot.dayCap||cap)||cap; var pct=tot?Math.round(100*cap/Math.max(tot,cap)):0; meter='<div class="meter'+(cap<=2?' low':'')+'"><i style="width:'+pct+'%"></i></div>'; }
var sub=s.available?(cap!=null?cap+' left':'Open')+(s.surcharge?' · +'+money(s.surcharge):''):(s.reasons[0]?s.reasons[0].message:'');
if(selected&&mine) sub='Held for you';
h+='<button class="slot" data-act="slot" data-v="'+s.id+'" aria-pressed="'+selected+'"'+(s.available||selected?'':' disabled')+'><b>'+(av.dateOnly?'Ships this day':slotLabel(s.start,s.end))+'</b>'+meter+'<small>'+esc(sub)+'</small></button>';
});
h+='</div>';
if(S.sel){
var held=myHold();
h+='<div class="hold"><span>'+(held?'Your time is held for <b class="mono" id="holdLeft"></b>':'Your hold expired. Tap the time again to re-check it.')+'</span>'+(held?'<span class="small muted">Other shoppers see this place as taken.</span>':'')+'</div>';
var loc=sh.chosen.location;
h+='<dl class="summary"><dt>Method</dt><dd>'+METHOD_NAME[S.method]+'</dd><dt>'+(S.method==="pickup"?"Pick up at":"From")+'</dt><dd>'+esc(loc.name)+'</dd>';
h+='<dt>'+(av.dateOnly?'Dispatch':'Date')+'</dt><dd>'+dLabel(S.sel.date,{weekday:"long",month:"long"})+'</dd>';
if(!av.dateOnly) h+='<dt>Time</dt><dd>'+slotLabel(S.sel.slot.split("-")[0],S.sel.slot.split("-")[1])+'</dd>';
if(S.method==="pickup"&&loc.pickupInstructions) h+='<dt>Note</dt><dd>'+esc(loc.pickupInstructions)+'</dd>';
h+='</dl>';
h+='<div class="co"><input type="text" id="cName" aria-label="Name" value="'+esc(S.customer.name)+'"><input type="email" id="cEmail" aria-label="Email" value="'+esc(S.customer.email)+'"><input type="tel" id="cPhone" aria-label="Phone" value="'+esc(S.customer.phone)+'"></div>';
h+='<button class="btn primary" style="width:100%;margin-top:10px;padding:11px" data-act="checkout">Check out (demo order)</button>';
h+='<div class="demo"><span class="label">Try the edge cases</span><div class="row"><button class="btn sm" data-act="rush">Other shoppers take every remaining place in this slot</button><button class="btn sm" data-act="skip">Wait 11 minutes</button></div><span class="small muted">While your hold is live, the rush can’t take your place. After it expires, it can.</span></div>';
}
return h+'</div>';
}
function renderConfirmation(){
var b=store.bookings.get(S.lastOrder);
var loc=locById(b.locationId);
var rem=E.planReminders(b,SETTINGS.reminders,now(),{date:dLabel(b.date,{weekday:"long",month:"long"}),time:b.slotStart?slotLabel(b.slotStart,b.slotEnd):""});
var can=E.canCustomerReschedule(b,SETTINGS.reschedule,now());
var h='<div class="notice ok">Order '+esc(b.orderName)+' placed. This is what the customer sees on the thank-you and order status pages.</div>';
h+='<div class="store"><section class="panel confirm"><span class="label">Order status page</span><h2>'+METHOD_NAME[b.method]+', '+dLabel(b.date,{weekday:"long",month:"long"})+'</h2>';
h+='<dl class="summary"><dt>Time</dt><dd>'+(b.slotStart&&b.method!=="shipping"?slotLabel(b.slotStart,b.slotEnd):'Dispatch day')+'</dd><dt>Location</dt><dd>'+esc(loc.name)+'</dd><dt>Status</dt><dd><span class="pill '+b.status+'">'+b.status.replace("_"," ")+'</span>'+(b.rescheduleCount?' · changed '+b.rescheduleCount+'×':'')+'</dd></dl>';
if(can.ok){ h+='<div><button class="btn primary" data-act="resched-open">Change date or time</button> <span class="small muted">You can change it until '+nowLabel(can.deadline)+'.</span></div>'; }
else h+='<div class="notice info">'+esc(can.reason)+'</div>';
h+='<div class="row"><button class="btn" data-act="neworder">Start a new order</button></div></section>';
h+='<section class="panel"><span class="label">Scheduled reminders</span><div class="timeline" style="margin-top:8px">';
if(!rem.length) h+='<p class="muted small">No reminders: the slot is too soon, or there are no contact details for the enabled channels.</p>';
rem.forEach(function(r){ h+='<div><span class="mono small">'+nowLabel(r.sendAt)+'</span><span><span class="pill confirmed">'+r.channel+'</span> '+esc(r.body)+'</span></div>'; });
h+='</div><p class="small muted">Reminders are planned again automatically whenever the booking is rescheduled.</p></section></div>';
if(S.resched) h+=renderResched(b);
return h;
}
function renderResched(b){
var loc=locById(b.locationId);
var av=E.computeAvailability({location:loc,method:b.method,cart:b.cart||{items:[]},rules:RULES,peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
var opts=''; av.dates.forEach(function(d){ if(d.available) opts+='<option value="'+d.date+'"'+(S.resched.date===d.date?' selected':'')+'>'+dLabel(d.date)+'</option>'; });
if(!S.resched.date){ var f=av.dates.filter(function(d){return d.available;})[0]; S.resched.date=f&&f.date; }
var d=av.dates.filter(function(x){return x.date===S.resched.date;})[0];
var sl=''; (d?d.slots:[]).forEach(function(s){ if(s.available) sl+='<option value="'+s.id+'">'+slotLabel(s.start,s.end)+' · '+(s.remaining==null?'open':s.remaining+' left')+'</option>'; });
return '<section class="panel" style="margin-top:18px"><h3>Change your '+b.method+' time</h3><p class="small muted">Same rules as checkout: only dates your items can make are shown.</p><div class="row"><select id="rsDate">'+opts+'</select><select id="rsSlot">'+sl+'</select><button class="btn primary" data-act="resched-save">Save new time</button><button class="btn" data-act="resched-cancel">Cancel</button></div></section>';
}
/* ---------- admin ---------- */
var TABS=[["overview","Today"],["bookings","Bookings"],["peak","Rush control"],["fresh","Products & freshness"],["run","Driver run sheet"],["diag","Why unavailable?"],["settings","Reminders & policy"]];
function renderAdmin(){
var review=Array.from(store.bookings.values()).filter(function(b){return b.status==="needs_review";}).length;
var h='<div class="admin"><nav class="nav" aria-label="Admin sections">';
TABS.forEach(function(t){ h+='<button data-act="tab" data-v="'+t[0]+'" aria-current="'+(S.tab===t[0])+'"><span>'+t[1]+'</span>'+(t[0]==="bookings"&&review?'<span class="badge">'+review+'</span>':'')+'</button>'; });
h+='</nav><div class="stack">'+({overview:adminOverview,bookings:adminBookings,peak:adminPeak,fresh:adminFresh,run:adminRun,diag:adminDiag,settings:adminSettings}[S.tab])()+'</div></div>';
return h;
}
function allBookings(){ return Array.from(store.bookings.values()); }
function adminOverview(){
var today=E.localParts(now(),TZ).date, wk=E.addDays(today,7);
var bs=allBookings();
var tdy=bs.filter(function(b){return b.date===today&&b.status==="confirmed";}).length;
var next=bs.filter(function(b){return b.date>today&&b.date<=wk&&b.status==="confirmed";}).length;
var rev=bs.filter(function(b){return b.status==="needs_review";}).length;
var wl=store.waitlist.filter(function(w){return !w.notifiedAt;}).length;
var h='<div class="sectionhead"><h2>Today, '+dLabel(today,{weekday:"long",month:"long"})+'</h2><span class="small muted">Kitchener — Guelph St</span></div>';
h+='<div class="kpis"><div class="kpi"><div class="label">Bookings today</div><div class="v num">'+tdy+'</div></div><div class="kpi"><div class="label">Next 7 days</div><div class="v num">'+next+'</div></div><div class="kpi'+(rev?' alert':'')+'"><div class="label">Needs review</div><div class="v num">'+rev+'</div></div><div class="kpi"><div class="label">On waitlist</div><div class="v num">'+wl+'</div></div></div>';
var u=store.usage(now()); var loc=LOCS[0];
h+='<section class="panel"><div class="sectionhead"><h3>Delivery capacity, next 28 days</h3><div class="legend" style="margin:0"><span><i style="background:var(--accent)"></i>Booked</span><span><i style="background:var(--peak)"></i>Festival day</span><span><i style="background:var(--bad)"></i>Full</span></div></div><div class="capchart" role="img" aria-label="Booked delivery orders against daily capacity per day">';
for(var i=0;i<28;i++){
var d=E.addDays(today,i); var hours=E.hoursFor(loc,"delivery",d).hours;
var caps=E.capsFor(loc,"delivery",d,"10:00-13:00",PEAKS); var used=(u["kit|delivery|"+d]||{day:0}).day;
var cap=caps.dayCap||12; var pct=hours.length?Math.min(100,Math.round(100*used/cap)):0;
var pk=E.peakFor(PEAKS,"kit","delivery",d);
h+='<div class="cbar'+(pk?' peak':'')+(hours.length&&used>=cap?' full':'')+'" title="'+dLabel(d)+': '+used+' of '+cap+'"><div class="track">'+(hours.length?'<div class="fill" style="height:'+pct+'%"></div>':'')+'</div><span>'+(hours.length?used+'/'+cap:'—')+'</span><span>'+d.slice(8)+'</span></div>';
}
h+='</div></section>';
var pk=PEAKS[0]; var pd=pk.dates[pk.dates.length-1];
var used8=(u["kit|delivery|"+pd]||{day:0}).day;
var deadline=E.zonedToUtc(E.addDays(pd,-pk.cutoff.daysBefore),pk.cutoff.time,TZ);
h+='<section class="panel"><div class="sectionhead"><h3>Next festival: '+esc(pk.name)+'</h3><button class="btn sm" data-act="tab" data-v="peak">Adjust rush settings</button></div><p>'+dLabel(pd,{weekday:"long",month:"long"})+' delivery: <b class="num">'+used8+' of '+pk.dailyCapacity+'</b> booked. '+(now()<deadline?'Orders close '+nowLabel(deadline)+'.':'Orders closed '+nowLabel(deadline)+'.')+' '+store.waitlist.length+' customers on the waitlist.</p></section>';
return h;
}
function adminBookings(){
var f=S.bf; var today=E.localParts(now(),TZ).date;
var rows=allBookings().filter(function(b){
if(f.status!=="all"&&b.status!==f.status) return false;
if(f.method!=="all"&&b.method!==f.method) return false;
if(f.loc!=="all"&&b.locationId!==f.loc) return false;
if(f.from&&b.date&&b.date<f.from) return false;
if(!f.from&&f.status==="all"&&b.date&&b.date<today) return false;
return true;
}).sort(function(a,b){ if(a.status!==b.status){ if(a.status==="needs_review") return -1; if(b.status==="needs_review") return 1; } return (a.date+(a.slotStart||"")).localeCompare(b.date+(b.slotStart||"")); });
var h='<div class="sectionhead"><h2>Bookings</h2><button class="btn sm" data-act="csv">Copy filtered CSV</button></div>';
h+='<div class="filters"><div class="field"><label class="label" for="fStatus">Status</label><select id="fStatus">'+opt(["all","confirmed","needs_review","cancelled"],f.status)+'</select></div><div class="field"><label class="label" for="fMethod">Method</label><select id="fMethod">'+opt(["all","delivery","pickup","shipping"],f.method)+'</select></div><div class="field"><label class="label" for="fLoc">Location</label><select id="fLoc"><option value="all">all</option>'+LOCS.map(function(l){return '<option value="'+l.id+'"'+(l.id===f.loc?' selected':'')+'>'+esc(l.name)+'</option>';}).join("")+'</select></div><div class="field"><label class="label" for="fFrom">From date</label><input type="date" id="fFrom" value="'+f.from+'"></div><span class="small muted">'+rows.length+' bookings</span></div>';
h+='<div class="tablewrap"><table><thead><tr><th>Order</th><th>Date</th><th>Time</th><th>Method</th><th>Customer</th><th>Status</th><th>Action</th></tr></thead><tbody>';
rows.slice(0,80).forEach(function(b){
h+='<tr><td class="mono">'+esc(b.orderName)+'</td><td>'+(b.date?dLabel(b.date):'<span class="muted">missing</span>')+'</td><td class="mono">'+(b.slotStart?slotLabel(b.slotStart,b.slotEnd):'—')+'</td><td>'+b.method+(b.peakDayId?' <span class="pill peak">Diwali</span>':'')+'</td><td>'+esc(b.customer&&b.customer.name)+'</td><td><span class="pill '+b.status+'">'+b.status.replace("_"," ")+'</span>'+(b.note?'<div class="small muted" style="white-space:normal;max-width:240px">'+esc(b.note)+'</div>':'')+'</td><td>'+(b.status==="confirmed"?'<button class="btn sm" data-act="admin-move" data-v="'+b.id+'">Move</button>':b.status==="needs_review"?'<button class="btn sm" data-act="admin-move" data-v="'+b.id+'">Pick a time</button>':'')+'</td></tr>';
if(S.moving===b.id) h+='<tr><td colspan="7" class="wrap">'+adminMoveForm(b)+'</td></tr>';
});
h+='</tbody></table></div><textarea id="csvOut" aria-label="CSV export" hidden></textarea>';
return h;
}
function adminMoveForm(b){
var loc=locById(b.locationId||"kit"); var method=b.method||"pickup";
var av=E.computeAvailability({location:loc,method:method,cart:{items:[]},rules:[],peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
var o=''; var cnt=0;
av.dates.forEach(function(d){ d.slots.forEach(function(s){ if(s.available&&cnt<60){ cnt++; o+='<option value="'+d.date+'|'+s.id+'">'+dLabel(d.date)+' · '+(av.dateOnly?'dispatch':slotLabel(s.start,s.end))+' · '+(s.remaining==null?'open':s.remaining+' left')+'</option>'; } }); });
return '<div class="row"><span class="small">Move '+esc(b.orderName)+' to</span><select id="mvSel">'+o+'</select><button class="btn primary sm" data-act="admin-move-save" data-v="'+b.id+'">Save</button><button class="btn sm" data-act="admin-move-cancel">Cancel</button><span class="small muted">The old place is released only if the new one saves.</span></div>';
}
function opt(list,v){ return list.map(function(x){return '<option'+(x===v?' selected':'')+'>'+x+'</option>';}).join(""); }
function adminPeak(){
var p=PEAKS[0];
var h='<div class="sectionhead"><h2>Rush control</h2><span class="small muted">Changes apply to the customer view immediately</span></div>';
h+='<section class="panel stack"><div class="sectionhead"><h3>'+esc(p.name)+'</h3><span class="mono small">'+p.dates.map(function(d){return dLabel(d);}).join(", ")+'</span></div><div class="form">'+
num("pkDay","Daily order limit",p.dailyCapacity)+num("pkSlot","Per-slot limit",p.slotCapacity)+num("pkPrep","Extra prep (minutes)",p.extraPrepMinutes)+num("pkDays","Close orders N days before",p.cutoff.daysBefore)+
'<div class="field"><label class="label" for="pkTime">Cutoff time</label><input type="time" id="pkTime" value="'+p.cutoff.time+'"></div>'+num("pkSur","Peak surcharge (cents)",p.surcharge)+
'<div class="field"><span class="label">When full</span><label class="switch"><input type="checkbox" id="pkWait"'+(p.waitlist?' checked':'')+'> Offer a waitlist</label></div></div>';
h+='<div class="field"><label class="label" for="pkMsg">Customer banner</label><input type="text" id="pkMsg" value="'+esc(p.message)+'"></div><div><button class="btn primary" data-act="peak-save">Save rush settings</button></div></section>';
h+='<section class="panel"><h3>Waitlist</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>Joined</th><th>Customer</th><th>Wants</th><th>Status</th></tr></thead><tbody>';
store.waitlist.forEach(function(w){ h+='<tr><td class="mono small">'+nowLabel(w.createdAt)+'</td><td>'+esc(w.contact.name||w.contact.email||w.contact.phone)+'</td><td>'+dLabel(w.date)+' · '+w.method+' · '+(w.slot==="*"?"any time":slotLabel(w.slot.split("-")[0],w.slot.split("-")[1]))+'</td><td>'+(w.notifiedAt?'<span class="pill confirmed">Offered a place</span>':'<span class="pill peak">Waiting</span>')+'</td></tr>'; });
h+='</tbody></table></div><p class="small muted">When an order on a full day is cancelled or moved, the first person waiting is offered the place automatically.</p></section>';
return h;
}
function num(id,label,v){ return '<div class="field"><label class="label" for="'+id+'">'+label+'</label><input type="number" id="'+id+'" value="'+(v==null?"":v)+'"></div>'; }
function adminFresh(){
var fr=RULES.filter(function(r){return r.id==="r_fresh";})[0];
var today=E.localParts(now(),TZ).date;
var h='<div class="sectionhead"><h2>Products & freshness</h2></div><section class="panel"><h3>Rules</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>On</th><th>Rule</th><th>Applies to</th><th>Effect</th></tr></thead><tbody>';
RULES.forEach(function(r){ h+='<tr><td><input type="checkbox" aria-label="Enable '+esc(r.name)+'" data-act="rule-toggle" data-v="'+r.id+'"'+(r.enabled?' checked':'')+'></td><td>'+esc(r.name)+'</td><td><code>'+r.target.type+': '+esc(r.target.values.join(", "))+'</code></td><td class="wrap">'+esc(describeAction(r.action))+'</td></tr>'; });
h+='</tbody></table></div><p class="small muted">If rules disagree, a prohibition beats an allowance, and a mixed cart uses its longest preparation time. Customers see which item caused a restriction.</p></section>';
var a=fr.action;
h+='<section class="panel stack"><div class="sectionhead"><h3>Fresh Marigold Garland: stock arrivals</h3><span class="small muted">Sellable from arrival + '+a.minDaysAfterArrival+' day to arrival + '+a.shelfLifeDays+' days</span></div><div class="chips">';
a.arrivals.forEach(function(x,i){ h+='<span class="chip">'+dLabel(x.date)+'<button aria-label="Remove arrival '+x.date+'" data-act="arr-del" data-v="'+i+'">×</button></span>'; });
h+='</div><div class="row"><input type="date" id="arrNew"><button class="btn sm" data-act="arr-add">Add arrival</button>'+'<label class="small">Ready after <input type="number" id="frMin" value="'+a.minDaysAfterArrival+'" style="width:60px"> day(s)</label><label class="small">Shelf life <input type="number" id="frShelf" value="'+a.shelfLifeDays+'" style="width:60px"> days</label><button class="btn sm" data-act="fr-save">Update</button></div>';
h+='<div class="label">Dates a garland can be delivered, next 28 days</div><div class="strip">';
var cons=E.evaluateRules({items:[Object.assign({},PRODUCTS[0],{quantity:1})]},RULES);
for(var i=0;i<28;i++){ var d=E.addDays(today,i); var ok=!E.checkFreshness(cons.freshness,d,"kit"); h+='<div class="'+(ok?'y':'n')+'" title="'+dLabel(d)+'">'+d.slice(8)+'</div>'; }
h+='</div></section>';
return h;
}
function describeAction(a){
switch(a.type){ case "prohibitMethods": return "Blocks "+a.methods.join(", "); case "allowOnlyMethods": return "Only "+a.methods.join(", ");
case "addPrep": return "Adds "+fmtMins(a.minutes)+" preparation"; case "freshness": return a.arrivals.length+" stock arrivals; sellable for "+(a.shelfLifeDays-a.minDaysAfterArrival+1)+" days each";
case "restrictLocations": return "Only from "+a.locationIds.join(", "); case "blockWeekdays": return "Blocks weekdays "+a.weekdays.join(","); default: return a.type; }
}
function adminRun(){
var today=E.localParts(now(),TZ).date;
var dates=[]; allBookings().forEach(function(b){ if(b.method==="delivery"&&b.status==="confirmed"&&b.date>=today&&dates.indexOf(b.date)<0) dates.push(b.date); }); dates.sort();
if(!S.run.date||dates.indexOf(S.run.date)<0) S.run.date=dates[0];
var sheet=E.buildRunSheet(allBookings(),LOCS[0],S.run.date);
var h='<div class="sectionhead"><h2>Driver run sheet</h2><div class="row"><select id="runDate" aria-label="Run date">'+dates.slice(0,20).map(function(d){return '<option value="'+d+'"'+(d===S.run.date?' selected':'')+'>'+dLabel(d)+'</option>';}).join("")+'</select><button class="btn sm" data-act="run-csv">Copy CSV</button></div></div>';
h+='<div class="kpis"><div class="kpi"><div class="label">Stops</div><div class="v num">'+sheet.stops.length+'</div></div><div class="kpi"><div class="label">Est. distance</div><div class="v num">'+(sheet.totalKm||0)+' km</div></div><div class="kpi"><div class="label">Windows</div><div class="v num">'+new Set(sheet.stops.map(function(s){return s.window;})).size+'</div></div><div class="kpi"><div class="label">Missing address</div><div class="v num">'+sheet.unlocated.length+'</div></div></div>';
h+='<section class="panel"><div class="sectionhead"><h3>Route links</h3><span class="small muted">Promised windows first, nearest stop next. Straight-line estimate.</span></div><div class="row" style="margin-top:8px">';
sheet.mapsUrls.forEach(function(u,i){ h+='<a class="btn sm" href="'+esc(u)+'" target="_blank" rel="noopener">Open leg '+(i+1)+' in Google Maps</a>'; });
h+='</div></section><div class="tablewrap"><table><thead><tr><th>#</th><th>Window</th><th>Order</th><th>Customer</th><th>Address</th><th>Leg</th><th>Items</th></tr></thead><tbody>';
sheet.stops.forEach(function(s){ var b=s.booking; h+='<tr><td class="num">'+s.seq+'</td><td class="mono">'+slotLabel(b.slotStart,b.slotEnd)+'</td><td class="mono">'+esc(b.orderName)+'</td><td>'+esc(b.customer&&b.customer.name)+'</td><td>'+esc(b.address&&b.address.line1)+'</td><td class="num">'+(s.legKm!=null?s.legKm.toFixed(1)+' km':'—')+'</td><td>'+esc((b.items||[]).map(function(i){return i.quantity+"× "+i.title;}).join(", "))+'</td></tr>'; });
h+='</tbody></table></div><textarea id="csvOut" aria-label="CSV export" hidden></textarea>';
return h;
}
function adminDiag(){
var today=E.localParts(now(),TZ).date; var D=S.diag; if(!D.date) D.date=E.addDays(today,5);
var loc=locById(D.loc); var c=cart();
var av=E.computeAvailability({location:loc,method:D.method,cart:c,rules:RULES,peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
var cons=E.evaluateRules(c,RULES,SETTINGS.mixedPrepPolicy);
var h='<div class="sectionhead"><h2>Why is a date unavailable?</h2><span class="small muted">Uses the cart from the customer view</span></div>';
h+='<div class="filters"><div class="field"><label class="label" for="dgM">Method</label><select id="dgM">'+opt(["delivery","pickup","shipping"],D.method)+'</select></div><div class="field"><label class="label" for="dgL">Location</label><select id="dgL">'+LOCS.map(function(l){return '<option value="'+l.id+'"'+(l.id===D.loc?' selected':'')+'>'+esc(l.name)+'</option>';}).join("")+'</select></div><div class="field"><label class="label" for="dgD">Date</label><input type="date" id="dgD" value="'+D.date+'"></div></div>';
h+='<section class="panel stack"><div><span class="label">Cart</span><p style="margin:4px 0">'+esc(c.items.map(function(i){return i.quantity+"× "+i.title;}).join(", ")||"Empty")+'</p><p class="small muted" style="margin:0">Matched rules: '+(cons.matched.length?esc(cons.matched.map(function(m){return m.ruleName+" ("+m.itemTitle+")";}).join("; ")):"none")+'. Preparation used: '+fmtMins(av.prepMinutes)+'.</p></div><div class="reasons">';
if(av.blockers.length) av.blockers.forEach(function(r){ h+='<div class="reason"><code>'+r.code+'</code><span>'+esc(r.message)+(r.ruleId?' · rule <code>'+r.ruleId+'</code>':'')+'</span></div>'; });
else { var d=av.dates.filter(function(x){return x.date===D.date;})[0];
if(!d) h+='<div class="reason"><code>BEYOND_HORIZON</code><span>Outside the '+loc.methods[D.method].horizonDays+'-day booking window, or before today.</span></div>';
else { d.reasons.forEach(function(r){ h+='<div class="reason"><code>'+r.code+'</code><span>'+esc(r.message)+(r.ruleId?' · rule <code>'+r.ruleId+'</code>':'')+'</span></div>'; });
d.slots.forEach(function(s){ h+='<div class="reason'+(s.available?' ok':'')+'"><span class="mono">'+(av.dateOnly?'Dispatch':slotLabel(s.start,s.end))+'</span><span>'+(s.available?'Available'+(s.remaining!=null?', '+s.remaining+' left':''):s.reasons.map(function(r){return r.code+": "+r.message;}).join("; "))+'</span></div>'; }); } }
h+='</div></section>';
return h;
}
function adminSettings(){
var R=SETTINGS.reschedule;
var h='<div class="sectionhead"><h2>Reminders & policy</h2></div><section class="panel stack"><h3>Customer self-reschedule</h3><div class="form"><div class="field"><span class="label">Allowed</span><label class="switch"><input type="checkbox" id="rsOn"'+(R.enabled?' checked':'')+'> Customers can change their time</label></div>'+num("rsHrs","Up to N hours before",R.minHoursBefore)+num("rsMax","Max changes per order",R.maxChanges)+num("holdMin","Hold a picked slot (minutes)",SETTINGS.holdMinutes)+'</div><div><button class="btn primary" data-act="pol-save">Save policy</button></div></section>';
h+='<section class="panel"><h3>Reminders</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>On</th><th>Channel</th><th>When</th><th>Methods</th><th>Message</th></tr></thead><tbody>';
SETTINGS.reminders.forEach(function(r){ h+='<tr><td><input type="checkbox" data-act="rem-toggle" data-v="'+r.id+'" aria-label="Enable reminder"'+(r.enabled?' checked':'')+'></td><td>'+r.channel+'</td><td>'+fmtMins(r.minutesBefore)+' before</td><td>'+(r.methods?r.methods.join(", "):"all")+'</td><td class="wrap small">'+esc(r.template)+'</td></tr>'; });
h+='</tbody></table></div><p class="small muted">WhatsApp uses the Meta Cloud API with approved templates; SMS uses Twilio; email uses your sending domain. Customers who opt out are skipped.</p></section>';
return h;
}
/* ---------- render + events ---------- */
var app=document.getElementById("app");
function render(){
document.getElementById("v-shop").setAttribute("aria-pressed",S.view==="shop");
document.getElementById("v-admin").setAttribute("aria-pressed",S.view==="admin");
document.getElementById("nowLabel").textContent=nowLabel(now());
app.innerHTML=S.view==="shop"?renderShop():renderAdmin();
tick();
}
function tick(){
document.getElementById("nowLabel").textContent=nowLabel(now());
var el=document.getElementById("holdLeft"); var h=myHold();
if(el&&h){ var s=Math.max(0,Math.round((h.expiresAt-now())/1000)); el.textContent=Math.floor(s/60)+":"+String(s%60).padStart(2,"0"); }
if(el&&!h&&S.view==="shop") render();
}
setInterval(tick,1000);
function holdSlot(date,slot){
var sh=computeShop(); if(!sh.chosen) return;
if(!S.token) S.token="tok_"+Math.random().toString(36).slice(2);
var sel={locationId:sh.chosen.location.id,method:S.method,date:date,slot:slot};
var v=E.validateSelection(ctx({location:sh.chosen.location}),date,slot);
if(!v.ok){ S.notice={kind:"bad",text:v.reasons[0].message}; S.sel=null; return; }
var r=store.hold(S.token,sel,E.capsFor(sh.chosen.location,S.method,date,slot,PEAKS),now(),SETTINGS.holdMinutes);
if(r.ok){ S.sel=sel; S.notice=null; } else { S.notice={kind:"bad",text:r.message+". Please choose another time."}; S.sel=null; }
}
function val(id){ var e=document.getElementById(id); return e?e.value:null; }
document.addEventListener("click",function(e){
var t=e.target.closest("[data-act]"); if(!t||t.tagName==="SELECT"||t.type==="checkbox") return;
var a=t.dataset.act, v=t.dataset.v;
if(a==="view"){ S.view=v; }
else if(a==="tab"){ S.view="admin"; S.tab=v; S.moving=null; }
else if(a==="skip"){ setClock(now()+11*60000); }
else if(a==="qty"){ S.cart[v]=Math.max(0,(S.cart[v]||0)+(+t.dataset.d)); }
else if(a==="method"){ S.method=v; S.date=null; }
else if(a==="loc"){ S.locId=v; S.date=null; }
else if(a==="postal"){ S.postal=val("postal"); S.date=null; }
else if(a==="month"){ var p=S.month.split("-"); var m=+p[1]+(+t.dataset.d); var y=+p[0]; if(m<1){m=12;y--;} if(m>12){m=1;y++;} S.month=y+"-"+String(m).padStart(2,"0"); }
else if(a==="date"){ S.date=v; S.notice=null; }
else if(a==="slot"){ holdSlot(S.date,v); }
else if(a==="rush"){ if(S.sel){ var sh=computeShop(); var caps=E.capsFor(sh.chosen.location,S.sel.method,S.sel.date,S.sel.slot,PEAKS); for(var i=0;i<40;i++){ var r=store.hold("rush_"+Date.now()+"_"+i,S.sel,caps,now(),SETTINGS.holdMinutes); if(!r.ok) break; } S.notice={kind:"info",text:"Other shoppers took every remaining place in this slot. "+(myHold()?"Yours is still held.":"")}; } }
else if(a==="waitlist"){ var em=val("waitEmail"); var sh2=computeShop(); if(em&&sh2.chosen){ store.joinWaitlist({locationId:sh2.chosen.location.id,method:S.method,date:S.date,slot:"*"},{email:em,name:em},now()); S.notice={kind:"ok",text:"You're on the waitlist for "+dLabel(S.date)+". We'll message "+em+" if a place opens."}; S.waitEmail=""; } }
else if(a==="checkout"){
S.customer={name:val("cName"),email:val("cEmail"),phone:val("cPhone")};
var sh3=computeShop(); var c=cart(); var loc=sh3.chosen.location; var caps2=E.capsFor(loc,S.sel.method,S.sel.date,S.sel.slot,PEAKS);
var g=geocode(S.postal); var fee=feeQuote(sh3);
var res=store.confirm("o"+(++orderNo),S.token,S.sel,caps2,now(),{orderName:"#"+orderNo,timezone:TZ,customer:S.customer,address:S.method==="delivery"?{line1:"Customer address",postal:S.postal,lat:g.lat,lng:g.lng}:undefined,items:c.items.map(function(i){return {title:i.title,quantity:i.quantity};}),fee:fee?fee.fee:0,peakDayId:(E.peakFor(PEAKS,loc.id,S.sel.method,S.sel.date)||{}).id||null});
res.booking.cart=c;
S.lastOrder=res.booking.id; S.sel=null; S.token=null; S.date=null;
S.notice=res.ok?(res.reReserved?{kind:"info",text:"Your hold had expired, but the place was still free, so the booking went through."}:null):{kind:"bad",text:res.message+". The order is in the merchant's review queue."};
}
else if(a==="neworder"){ S.lastOrder=null; S.notice=null; }
else if(a==="resched-open"){ S.resched={date:null}; }
else if(a==="resched-cancel"){ S.resched=null; }
else if(a==="resched-save"){ var b=store.bookings.get(S.lastOrder); var d=val("rsDate"), s=val("rsSlot"); if(d&&s){ var r2=store.reschedule(b.id,{locationId:b.locationId,method:b.method,date:d,slot:s},E.capsFor(locById(b.locationId),b.method,d,s,PEAKS),now(),"customer"); S.notice=r2.ok?{kind:"ok",text:"Changed to "+dLabel(d)+", "+slotLabel(s.split("-")[0],s.split("-")[1])+". Reminders were re-planned."}:{kind:"bad",text:r2.message}; offerWaitlist(r2); S.resched=null; } }
else if(a==="admin-move"){ S.moving=v; }
else if(a==="admin-move-cancel"){ S.moving=null; }
else if(a==="admin-move-save"){ var bk=store.bookings.get(v); var pv=val("mvSel"); if(pv){ var p2=pv.split("|"); var loc2=locById(bk.locationId||"kit");
if(bk.status==="needs_review"){ bk.status="confirmed"; bk.locationId=loc2.id; bk.method=bk.method||"pickup"; var caps3=E.capsFor(loc2,bk.method,p2[0],p2[1],PEAKS); var u=store.usage(now())[loc2.id+"|"+bk.method+"|"+p2[0]]||{day:0,slots:{}}; if((caps3.dayCap!=null&&u.day>=caps3.dayCap)||(caps3.slotCap!=null&&(u.slots[p2[1]]||0)>=caps3.slotCap)){ bk.status="needs_review"; S.flash="That slot just filled."; } else { bk.date=p2[0]; bk.slotStart=p2[1].split("-")[0]; bk.slotEnd=p2[1].split("-")[1]; bk.note="Resolved by merchant"; store.audit.push({at:now(),actor:"merchant",bookingId:bk.id,action:"resolved"}); } }
else { var r3=store.reschedule(bk.id,{locationId:bk.locationId,method:bk.method,date:p2[0],slot:p2[1]},E.capsFor(loc2,bk.method,p2[0],p2[1],PEAKS),now(),"merchant"); offerWaitlist(r3); }
S.moving=null; } }
else if(a==="csv"||a==="run-csv"){ var ta=document.getElementById("csvOut"); var csv=a==="csv"?bookingsCsv():runCsv(); ta.hidden=false; ta.value=csv; try{ navigator.clipboard.writeText(csv).then(function(){t.textContent="Copied";},function(){ta.select();}); }catch(_){ ta.select(); } return; }
else if(a==="peak-save"){ var p=PEAKS[0]; p.dailyCapacity=+val("pkDay")||null; p.slotCapacity=+val("pkSlot")||null; p.extraPrepMinutes=+val("pkPrep")||0; p.cutoff={daysBefore:+val("pkDays")||0,time:val("pkTime")||"18:00"}; p.surcharge=+val("pkSur")||0; p.waitlist=document.getElementById("pkWait").checked; p.message=val("pkMsg"); t.textContent="Saved"; return; }
else if(a==="arr-add"){ var nd=val("arrNew"); if(nd){ RULES[3].action.arrivals.push({date:nd}); RULES[3].action.arrivals.sort(function(x,y){return x.date<y.date?-1:1;}); } }
else if(a==="arr-del"){ RULES[3].action.arrivals.splice(+v,1); }
else if(a==="fr-save"){ RULES[3].action.minDaysAfterArrival=+val("frMin")||0; RULES[3].action.shelfLifeDays=+val("frShelf")||1; }
else if(a==="pol-save"){ SETTINGS.reschedule.enabled=document.getElementById("rsOn").checked; SETTINGS.reschedule.minHoursBefore=+val("rsHrs")||0; SETTINGS.reschedule.maxChanges=+val("rsMax")||0; SETTINGS.holdMinutes=+val("holdMin")||10; t.textContent="Saved"; return; }
else return;
render();
});
function offerWaitlist(r){
if(!r||!r.ok||!r.freed) return;
var w=store.nextWaitlisted(r.freed)||store.nextWaitlisted(Object.assign({},r.freed,{slot:"*"}));
if(w){ w.notifiedAt=now(); }
}
document.addEventListener("change",function(e){
var t=e.target, id=t.id;
if(t.dataset.act==="rule-toggle"){ RULES.forEach(function(r){ if(r.id===t.dataset.v) r.enabled=t.checked; }); render(); return; }
if(t.dataset.act==="rem-toggle"){ SETTINGS.reminders.forEach(function(r){ if(r.id===t.dataset.v) r.enabled=t.checked; }); return; }
if(id==="preset"){ if(t.value==="live") setClock(Date.now()); else { var p=t.value.split("T"); setClock(E.zonedToUtc(p[0],p[1],TZ)); } S.month=null; S.date=null; if(S.token) store.release(S.token); S.sel=null; S.notice=null; render(); return; }
if(id==="fStatus"){ S.bf.status=t.value; } else if(id==="fMethod"){ S.bf.method=t.value; } else if(id==="fLoc"){ S.bf.loc=t.value; } else if(id==="fFrom"){ S.bf.from=t.value; }
else if(id==="runDate"){ S.run.date=t.value; }
else if(id==="dgM"){ S.diag.method=t.value; } else if(id==="dgL"){ S.diag.loc=t.value; } else if(id==="dgD"){ S.diag.date=t.value; }
else if(id==="rsDate"){ S.resched.date=t.value; }
else if(id==="postal"){ S.postal=t.value; S.date=null; }
else return;
render();
});
document.addEventListener("keydown",function(e){ if(e.key==="Enter"&&e.target.id==="postal"){ S.postal=e.target.value; S.date=null; render(); } });
function bookingsCsv(){
var rows=allBookings().map(function(b){ var loc=locById(b.locationId); return {order:b.orderName,status:b.status,method:b.method,location:loc?loc.name:"",date:b.date,slot_start:b.slotStart||"",slot_end:b.slotEnd||"",timezone:b.timezone,start_utc:b.date&&b.slotStart?new Date(E.zonedToUtc(b.date,b.slotStart,b.timezone)).toISOString():"",customer:b.customer&&b.customer.name,email:b.customer&&b.customer.email,items:(b.items||[]).map(function(i){return i.quantity+"x "+i.title;}).join("; ")}; });
return E.toCsv(rows);
}
function runCsv(){
var sheet=E.buildRunSheet(allBookings(),LOCS[0],S.run.date);
return E.toCsv(sheet.stops.map(function(s){ var b=s.booking; return {stop:s.seq,window:b.slotStart+"-"+b.slotEnd,order:b.orderName,customer:b.customer&&b.customer.name,phone:b.customer&&b.customer.phone,address:b.address&&b.address.line1,lat:b.address.lat.toFixed(5),lng:b.address.lng.toFixed(5),leg_km:s.legKm!=null?s.legKm.toFixed(1):""}; }));
}
setClock(E.zonedToUtc("2026-11-05","10:00",TZ));
document.getElementById("preset").value="2026-11-05T10:00";
render();
})();
</script>

5
react-router.config.ts Normal file
View File

@ -0,0 +1,5 @@
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;

52
shopify.app.toml Normal file
View File

@ -0,0 +1,52 @@
# Learn more: https://shopify.dev/docs/apps/tools/cli/configuration
# Run `shopify app config link` to fill client_id and URLs from your Partner app.
client_id = "REPLACE_WITH_CLIENT_ID"
name = "Datewise: Delivery & Pickup"
handle = "datewise"
application_url = "https://example.com"
embedded = true
[build]
automatically_update_urls_on_dev = true
include_config_on_deploy = true
[access_scopes]
# Each scope is used by a shipped feature — see README "Permissions".
scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations"
[auth]
redirect_urls = ["https://example.com/auth/callback"]
[webhooks]
api_version = "2026-01"
[[webhooks.subscriptions]]
topics = ["app/uninstalled", "app/scopes_update"]
uri = "/webhooks/app"
[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "/webhooks/orders/create"
[[webhooks.subscriptions]]
topics = ["orders/cancelled"]
uri = "/webhooks/orders/cancelled"
[[webhooks.subscriptions]]
topics = ["orders/fulfilled"]
uri = "/webhooks/orders/fulfilled"
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "/webhooks/compliance"
# Storefront widget talks to the app through the shop's own domain:
# https://{shop}/apps/datewise/availability → {application_url}/proxy/availability
[app_proxy]
url = "https://example.com/proxy"
subpath = "datewise"
prefix = "apps"
[pos]
embedded = false

Some files were not shown because too many files have changed in this diff Show More