Closes remaining DS-parity gaps from the feature audit: - ProductRule model (product/collection/vendor/type/tag scoping) with real server-side enforcement in hold-request.server.ts, plus shaped availability in availability-request.server.ts. Covers per-product prep time, cart-content-based slot blocking, and product-restricted locations in one mechanism. New /app/rules admin page (Growth+). - Driving-distance delivery zones via Google's Distance Matrix API, cached like existing geocoding results. - SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a meaningless ship-out time slot; carried through to the order metafield write-back. Storefront widget and POS extension now send cart contents (vendor/ type from cart.js, product ids for Admin-API-resolved collection/tag rules) to both availability and hold endpoints. checkout-datetime remains excluded from this deploy pending Shopify's Network Access approval (unrelated to this work) — re-add from ../checkout-datetime-disabled and redeploy once granted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
57 lines
2.4 KiB
TypeScript
57 lines
2.4 KiB
TypeScript
import type { ActionFunctionArgs } from "@remix-run/node";
|
|
import type { Method } from "@prisma/client";
|
|
import { authenticate } from "../shopify.server";
|
|
import { resolveHoldRequest } from "../services/hold-request.server";
|
|
import { resolveProductRefs } from "../services/product-rules.server";
|
|
import { parseCartLinesParam, parseProductIdsParam } from "../lib/cart-rule-params";
|
|
|
|
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
|
// path-mirroring rationale). Called by the widget the moment a shopper
|
|
// picks a slot, before it writes the cart attribute — this is what
|
|
// actually reserves capacity (PRODUCT_STRATEGY.md §3.1, §4.1: "the
|
|
// last-slot race condition"). The cart attribute write alone would just be
|
|
// two shoppers racing to write the same free-text field; nothing would
|
|
// stop both orders from completing. Actual resolution lives in
|
|
// services/hold-request.server.ts, shared with the POS route.
|
|
|
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const { session, admin } = await authenticate.public.appProxy(request);
|
|
if (!session) {
|
|
return Response.json({ error: "Shop not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { intent, locationId, method, date, startMin, cartToken, cartLines, productIds } = body as {
|
|
intent?: "create" | "release";
|
|
locationId?: string;
|
|
method?: string;
|
|
date?: string;
|
|
startMin?: number;
|
|
cartToken?: string;
|
|
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
|
productIds?: string[];
|
|
};
|
|
|
|
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
|
|
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
|
}
|
|
|
|
const resolvedProductIds = parseProductIdsParam(Array.isArray(productIds) ? productIds.join(",") : undefined);
|
|
const productRefs = admin && resolvedProductIds.length > 0 ? await resolveProductRefs(admin, resolvedProductIds) : [];
|
|
|
|
const result = await resolveHoldRequest(session.shop, {
|
|
intent: intent === "release" ? "release" : "create",
|
|
locationId,
|
|
method: method as Method,
|
|
date,
|
|
startMin,
|
|
cartToken,
|
|
cartLines: parseCartLinesParam(Array.isArray(cartLines) ? JSON.stringify(cartLines) : undefined),
|
|
productRefs,
|
|
});
|
|
|
|
return Response.json(result.body, { status: result.status });
|
|
};
|