import type { LoaderFunctionArgs } from "@remix-run/node"; import { DateTime } from "luxon"; import type { Method } from "@prisma/client"; import { authenticate } from "../shopify.server"; import db from "../db.server"; import { getAvailability } from "../services/scheduling.server"; import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "../services/zones.server"; import { resolveRate } from "../services/rates.server"; import { formatPriceLabel } from "../lib/currency"; // Public endpoint, reachable only through Shopify's App Proxy (signature // verified by authenticate.public.appProxy) — this is what the storefront // Theme App Extension calls. Requests to https://{shop}/apps/scheduling/* // forward here because shopify.app.toml's [app_proxy].url already includes // the /apps/scheduling prefix, so this file's path (apps.scheduling.*) // mirrors the shop-facing URL exactly. const VALID_METHODS = new Set(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]); const MAX_DAYS = 60; const DEFAULT_DAYS = 14; function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } /** `getAvailability`'s consumed-capacity map key — must match scheduling.server.ts's slotKey() exactly. */ function slotKey(date: string, startMin: number): string { return `${date}|${startMin}`; } export const loader = async ({ request }: LoaderFunctionArgs) => { const { session } = await authenticate.public.appProxy(request); if (!session) { return Response.json({ error: "Shop not found" }, { status: 404 }); } const url = new URL(request.url); const methodParam = url.searchParams.get("method"); const locationIdParam = url.searchParams.get("locationId"); const postalCode = url.searchParams.get("postalCode") || undefined; const address = url.searchParams.get("address") || undefined; const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS); const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS; if (!methodParam || !VALID_METHODS.has(methodParam as Method)) { return Response.json({ error: "Invalid or missing method" }, { status: 400 }); } const method = methodParam as Method; let location: Awaited> = null; let zoneId: string | null = null; let distanceKm: number | null = null; // Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to // the nearest eligible, density-qualified zone for the shopper's address. // Falls through to the plain location lookup below for PICKUP/SHIPPING, // or when no address was supplied yet (e.g. the widget hasn't asked for // one), so the picker can still show something before that input exists. if (method === "LOCAL_DELIVERY" && (postalCode || address)) { const matches = await findEligibleLocationsForDelivery(session.shop, { postalCode, address }); const zones = matches.length ? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } }) : []; const zoneById = new Map(zones.map((z) => [z.id, z])); for (const match of matches) { if (locationIdParam && match.locationId !== locationIdParam) continue; const zone = zoneById.get(match.zoneId); if (!zone) continue; // eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies if (!(await meetsDeliveryDensity(session.shop, zone))) continue; zoneId = match.zoneId; distanceKm = match.distanceKm; location = await db.location.findFirst({ where: { id: match.locationId, shopDomain: session.shop, active: true } }); break; } if (!location) { return Response.json({ locationId: null, method, dates: {}, zoneId: null, rate: null, error: "This address is outside our delivery area right now.", }); } } else { location = locationIdParam ? await db.location.findFirst({ where: { id: locationIdParam, shopDomain: session.shop, active: true }, }) : await db.location.findFirst({ where: { shopDomain: session.shop, active: true }, orderBy: { createdAt: "asc" }, }); } if (!location) { return Response.json({ error: "No active location configured" }, { status: 404 }); } const now = DateTime.now().setZone(location.timezone); const startDate = now.toISODate()!; const endDate = now.plus({ days }).toISODate()!; const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate(); const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate(); // Bookings are keyed by exact instant; widen the window by a day on each // side so a slot near midnight UTC-offset boundaries isn't miscounted. const bookingRangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate(); const bookingRangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate(); const [slotTemplates, overrides, blackouts, bookings, rates] = await Promise.all([ db.slotTemplate.findMany({ where: { shopDomain: session.shop, locationId: location.id, method }, }), db.slotOverride.findMany({ where: { shopDomain: session.shop, locationId: location.id, method, date: { gte: rangeStart, lte: rangeEnd }, }, }), db.blackoutDate.findMany({ where: { shopDomain: session.shop, date: { gte: rangeStart, lte: rangeEnd }, AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }], }, }), db.booking.findMany({ where: { shopDomain: session.shop, locationId: location.id, method, status: { in: ["confirmed", "fulfilled"] }, slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd }, }, select: { slotStart: true }, }), db.rate.findMany({ where: { shopDomain: session.shop, method } }), ]); const consumed = new Map(); for (const booking of bookings) { const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone); const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute); consumed.set(key, (consumed.get(key) ?? 0) + 1); } const availability = getAvailability({ timezone: location.timezone, dateRange: { startDate, endDate }, slotTemplates: slotTemplates.map((t) => ({ weekday: t.weekday, startMin: t.startMin, endMin: t.endMin, capacity: t.capacity, cutoffMin: t.cutoffMin, leadTimeMin: t.leadTimeMin, })), overrides: overrides.map((o) => ({ date: toIsoDate(o.date), closed: o.closed, startMin: o.startMin, endMin: o.endMin, capacity: o.capacity, })), blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })), now, consumed, }); const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined }); return Response.json({ locationId: location.id, locationName: location.name, locationLat: location.lat, locationLng: location.lng, timezone: location.timezone, method, dates: availability, zoneId, distanceKm, rate: matchedRate ? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) } : null, }); };