feat: Phase 1 — core data model & admin CRUD
Adds the scheduling core to Prisma (Shop, Location, Method enum, SlotTemplate, SlotOverride, BlackoutDate — IMPLEMENTATION_PLAN.md §4) and Polaris admin screens to manage them: - /app/locations: list/create/edit/delete locations, one-click vertical template seeding (bakery/florist/grocer) - /app/slots: weekly slot template editor, scoped per location - /app/blackouts: blackout dates, scoped to one location or all of them services/templates.server.ts keeps the vertical presets as a pure, unit-tested function (getVerticalTemplate) separate from the thin I/O wrapper (seedVerticalTemplate) that does the actual Prisma writes, per CLAUDE.md's "pure functions, inject data, no I/O in the math" rule. Switched dev DB from SQLite to Postgres (docker-compose.yml, ports 5433/6380 to avoid clashing with other local projects already on 5432/6379): SQLite doesn't support Prisma enums at all, and IMPLEMENTATION_PLAN.md's schema relies on them (Method) plus Postgres-only array fields in later phases (Zone.postalCodes, ProductRule.allowedLocationIds). Only one throwaway migration existed, so switching now avoids compounding the rework later. Also fixed a Remix/Polaris integration issue hit while building these forms: this Polaris version's TextField/Checkbox are fully controlled (no defaultValue/defaultChecked), and `data()` imported from `@remix-run/react` (rather than `@remix-run/node`) breaks useActionData's type inference — both are now handled correctly across the new routes. Verified: lint, typecheck, unit tests (incl. template-seeding fixtures), build, and a live end-to-end run of seedVerticalTemplate against the Postgres container all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0303eba07a
commit
21f0ee704b
10
.env.example
10
.env.example
@ -6,11 +6,13 @@ SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read
|
|||||||
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
|
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
|
||||||
SHOP_CUSTOM_DOMAIN=
|
SHOP_CUSTOM_DOMAIN=
|
||||||
|
|
||||||
# App DB (Prisma). SQLite locally, Postgres in staging/prod.
|
# App DB (Prisma/Postgres — same engine dev through prod so enum/array
|
||||||
DATABASE_URL="file:dev.sqlite"
|
# schema features behave identically everywhere). `docker compose up -d`
|
||||||
|
# starts a local instance on the port below.
|
||||||
|
DATABASE_URL="postgresql://app:app@localhost:5433/delivery_datetime_dev"
|
||||||
|
|
||||||
# Redis (slot-holds TTL, BullMQ queues).
|
# Redis (slot-holds TTL, BullMQ queues). `docker compose up -d` starts this too.
|
||||||
REDIS_URL=redis://127.0.0.1:6379
|
REDIS_URL=redis://127.0.0.1:6380
|
||||||
|
|
||||||
# Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display).
|
# Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display).
|
||||||
GOOGLE_MAPS_API_KEY=
|
GOOGLE_MAPS_API_KEY=
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { Page, Layout, Text, Card, BlockStack, EmptyState } from "@shopify/polaris";
|
import { useLoaderData } from "@remix-run/react";
|
||||||
|
import { Page, Layout, Text, Card, BlockStack, EmptyState, InlineStack, Badge } from "@shopify/polaris";
|
||||||
import { TitleBar } from "@shopify/app-bridge-react";
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
await authenticate.admin(request);
|
const { session } = await authenticate.admin(request);
|
||||||
|
|
||||||
return null;
|
const locations = await db.location.findMany({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
include: { _count: { select: { slotTemplates: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { locations };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
|
const { locations } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<TitleBar title="Delivery Date & Time" />
|
<TitleBar title="Delivery Date & Time" />
|
||||||
@ -17,6 +26,7 @@ export default function Index() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<Card>
|
<Card>
|
||||||
|
{locations.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
heading="No locations configured yet"
|
heading="No locations configured yet"
|
||||||
action={{ content: "Add a location", url: "/app/locations" }}
|
action={{ content: "Add a location", url: "/app/locations" }}
|
||||||
@ -27,6 +37,19 @@ export default function Index() {
|
|||||||
taking scheduled Shipping, Local Delivery, or Pickup orders.
|
taking scheduled Shipping, Local Delivery, or Pickup orders.
|
||||||
</Text>
|
</Text>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text as="h2" variant="headingMd">
|
||||||
|
{locations.length} location{locations.length === 1 ? "" : "s"} configured
|
||||||
|
</Text>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<InlineStack key={location.id} align="space-between">
|
||||||
|
<Text as="span">{location.name}</Text>
|
||||||
|
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
|
||||||
|
</InlineStack>
|
||||||
|
))}
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
170
app/routes/app.blackouts._index.tsx
Normal file
170
app/routes/app.blackouts._index.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
IndexTable,
|
||||||
|
EmptyState,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const [locations, blackouts] = await Promise.all([
|
||||||
|
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
|
||||||
|
db.blackoutDate.findMany({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
include: { location: true },
|
||||||
|
orderBy: { date: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { locations, blackouts };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
const id = String(formData.get("id") || "");
|
||||||
|
await db.blackoutDate.deleteMany({ where: { id, shopDomain: session.shop } });
|
||||||
|
return data({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = String(formData.get("date") || "");
|
||||||
|
const locationId = String(formData.get("locationId") || "") || null;
|
||||||
|
const method = (String(formData.get("method") || "") || null) as Method | null;
|
||||||
|
const reason = String(formData.get("reason") || "").trim() || null;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return data({ errors: { date: "Date is required" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.blackoutDate.create({
|
||||||
|
data: {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
date: new Date(`${date}T00:00:00.000Z`),
|
||||||
|
locationId,
|
||||||
|
method,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BlackoutsIndex() {
|
||||||
|
const { locations, blackouts } = useLoaderData<typeof loader>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Blackout dates" />
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
heading="Add a location first"
|
||||||
|
action={{ content: "Add location", url: "/app/locations/new" }}
|
||||||
|
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||||
|
>
|
||||||
|
<Text as="p">Blackout dates can be scoped to one location, or left blank for all locations.</Text>
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Blackout dates" />
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Card padding="0">
|
||||||
|
{blackouts.length === 0 ? (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
No blackout dates yet.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IndexTable
|
||||||
|
itemCount={blackouts.length}
|
||||||
|
headings={[
|
||||||
|
{ title: "Date" },
|
||||||
|
{ title: "Location" },
|
||||||
|
{ title: "Method" },
|
||||||
|
{ title: "Reason" },
|
||||||
|
{ title: "" },
|
||||||
|
]}
|
||||||
|
selectable={false}
|
||||||
|
>
|
||||||
|
{blackouts.map((b, index) => (
|
||||||
|
<IndexTable.Row id={b.id} key={b.id} position={index}>
|
||||||
|
<IndexTable.Cell>{b.date.slice(0, 10)}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{b.location?.name ?? "All locations"}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{b.method?.replace("_", " ") ?? "All methods"}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{b.reason ?? "—"}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<input type="hidden" name="id" value={b.id} />
|
||||||
|
<Button submit variant="plain" tone="critical">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</IndexTable.Cell>
|
||||||
|
</IndexTable.Row>
|
||||||
|
))}
|
||||||
|
</IndexTable>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text as="h3" variant="headingSm">
|
||||||
|
Add a blackout date
|
||||||
|
</Text>
|
||||||
|
<InlineStack gap="300" wrap>
|
||||||
|
<TextField label="Date" name="date" type="date" autoComplete="off" />
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
name="locationId"
|
||||||
|
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Method"
|
||||||
|
name="method"
|
||||||
|
options={[
|
||||||
|
{ label: "All methods", value: "" },
|
||||||
|
...METHODS.map((m) => ({ label: m.replace("_", " "), value: m })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TextField label="Reason (optional)" name="reason" autoComplete="off" />
|
||||||
|
</InlineStack>
|
||||||
|
<div>
|
||||||
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
Add blackout date
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</BlockStack>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/routes/app.locations.$id.tsx
Normal file
141
app/routes/app.locations.$id.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
FormLayout,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
InlineStack,
|
||||||
|
Checkbox,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const location = await db.location.findFirst({
|
||||||
|
where: { id: params.id, shopDomain: session.shop },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
throw new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
await db.location.deleteMany({ where: { id: params.id, shopDomain: session.shop } });
|
||||||
|
return redirect("/app/locations");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(formData.get("name") || "").trim();
|
||||||
|
const address = String(formData.get("address") || "").trim();
|
||||||
|
const timezone = String(formData.get("timezone") || "").trim();
|
||||||
|
const active = formData.get("active") === "true";
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!name) errors.name = "Name is required";
|
||||||
|
if (!timezone) errors.timezone = "Timezone is required";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return { errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.location.updateMany({
|
||||||
|
where: { id: params.id, shopDomain: session.shop },
|
||||||
|
data: { name, address, timezone, active },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditLocation() {
|
||||||
|
const { location } = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page backAction={{ url: "/app/locations" }}>
|
||||||
|
<TitleBar title={location.name} />
|
||||||
|
<LocationForm key={location.id} location={location} errors={actionData?.errors} isSubmitting={isSubmitting} />
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationForm({
|
||||||
|
location,
|
||||||
|
errors,
|
||||||
|
isSubmitting,
|
||||||
|
}: {
|
||||||
|
location: { id: string; name: string; address: string; timezone: string; active: boolean };
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(location.name);
|
||||||
|
const [address, setAddress] = useState(location.address);
|
||||||
|
const [timezone, setTimezone] = useState(location.timezone);
|
||||||
|
const [active, setActive] = useState(location.active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<FormLayout>
|
||||||
|
<TextField
|
||||||
|
label="Location name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="off"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
error={errors?.name}
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
|
||||||
|
<TextField
|
||||||
|
label="Timezone (IANA)"
|
||||||
|
name="timezone"
|
||||||
|
autoComplete="off"
|
||||||
|
value={timezone}
|
||||||
|
onChange={setTimezone}
|
||||||
|
error={errors?.timezone}
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
||||||
|
<InlineStack gap="200">
|
||||||
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</Button>
|
||||||
|
<Button url={`/app/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
|
||||||
|
</InlineStack>
|
||||||
|
</FormLayout>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<InlineStack align="space-between" blockAlign="center">
|
||||||
|
<BlockStack gap="050">
|
||||||
|
<strong>Delete this location</strong>
|
||||||
|
<span>Removes all its slot templates and blackout dates.</span>
|
||||||
|
</BlockStack>
|
||||||
|
<Button submit tone="critical" variant="secondary">
|
||||||
|
Delete location
|
||||||
|
</Button>
|
||||||
|
</InlineStack>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</BlockStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
app/routes/app.locations._index.tsx
Normal file
152
app/routes/app.locations._index.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
ResourceList,
|
||||||
|
ResourceItem,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
Select,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { listVerticalTemplates, seedVerticalTemplate, type VerticalKey } from "../services/templates.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const locations = await db.location.findMany({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
include: { _count: { select: { slotTemplates: true, blackouts: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { locations, verticals: listVerticalTemplates() };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "seed-template") {
|
||||||
|
const vertical = formData.get("vertical") as VerticalKey;
|
||||||
|
await seedVerticalTemplate(session.shop, vertical);
|
||||||
|
return data({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "delete-location") {
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await db.location.deleteMany({ where: { id, shopDomain: session.shop } });
|
||||||
|
return data({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return data({ ok: false }, { status: 400 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationsIndex() {
|
||||||
|
const { locations, verticals } = useLoaderData<typeof loader>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
const [vertical, setVertical] = useState<string>(verticals[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Locations" />
|
||||||
|
<BlockStack gap="500">
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
{locations.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
heading="Set up your first location"
|
||||||
|
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||||
|
>
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Text as="p" variant="bodyMd">
|
||||||
|
Start from a vertical template — it seeds a location with a
|
||||||
|
realistic weekly Pickup/Delivery schedule you can then edit,
|
||||||
|
or add a location from scratch.
|
||||||
|
</Text>
|
||||||
|
<InlineStack gap="300" blockAlign="center">
|
||||||
|
<div style={{ minWidth: 180 }}>
|
||||||
|
<Select
|
||||||
|
label="Vertical"
|
||||||
|
labelHidden
|
||||||
|
options={verticals.map((v) => ({ label: capitalize(v), value: v }))}
|
||||||
|
value={vertical}
|
||||||
|
onChange={setVertical}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="seed-template" />
|
||||||
|
<input type="hidden" name="vertical" value={vertical} />
|
||||||
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
Seed {capitalize(vertical)} template
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Button url="/app/locations/new">Add location manually</Button>
|
||||||
|
</InlineStack>
|
||||||
|
</BlockStack>
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card padding="0">
|
||||||
|
<ResourceList
|
||||||
|
items={locations}
|
||||||
|
resourceName={{ singular: "location", plural: "locations" }}
|
||||||
|
renderItem={(location) => (
|
||||||
|
<ResourceItem
|
||||||
|
id={location.id}
|
||||||
|
url={`/app/locations/${location.id}`}
|
||||||
|
accessibilityLabel={`View ${location.name}`}
|
||||||
|
>
|
||||||
|
<InlineStack align="space-between" blockAlign="center">
|
||||||
|
<BlockStack gap="100">
|
||||||
|
<Text as="h3" variant="bodyMd" fontWeight="bold">
|
||||||
|
{location.name}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" variant="bodySm" tone="subdued">
|
||||||
|
{location.address || "No address set"} · {location.timezone}
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
<InlineStack gap="200" blockAlign="center">
|
||||||
|
<Badge>{`${location._count.slotTemplates} slot templates`}</Badge>
|
||||||
|
<Badge tone={location.active ? "success" : "critical"}>
|
||||||
|
{location.active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</InlineStack>
|
||||||
|
</InlineStack>
|
||||||
|
</ResourceItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Layout.Section>
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<Layout.Section>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button url="/app/locations/new" variant="primary">
|
||||||
|
Add location
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s: string) {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
70
app/routes/app.locations.new.tsx
Normal file
70
app/routes/app.locations.new.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||||
|
import { Page, Card, BlockStack, FormLayout, TextField, Button } from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const name = String(formData.get("name") || "").trim();
|
||||||
|
const address = String(formData.get("address") || "").trim();
|
||||||
|
const timezone = String(formData.get("timezone") || "").trim();
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!name) errors.name = "Name is required";
|
||||||
|
if (!timezone) errors.timezone = "Timezone is required";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return { errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await db.location.create({
|
||||||
|
data: { shopDomain: session.shop, name, address, timezone },
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(`/app/locations/${location.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewLocation() {
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Add location" />
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<FormLayout>
|
||||||
|
<TextField
|
||||||
|
label="Location name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="off"
|
||||||
|
error={actionData?.errors?.name}
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<TextField label="Address" name="address" autoComplete="off" multiline={2} />
|
||||||
|
<TextField
|
||||||
|
label="Timezone (IANA, e.g. America/Toronto)"
|
||||||
|
name="timezone"
|
||||||
|
autoComplete="off"
|
||||||
|
value={timezone}
|
||||||
|
onChange={setTimezone}
|
||||||
|
error={actionData?.errors?.timezone}
|
||||||
|
requiredIndicator
|
||||||
|
helpText="All slot math for this location is computed in this timezone."
|
||||||
|
/>
|
||||||
|
<BlockStack>
|
||||||
|
<Button submit variant="primary" loading={navigation.state === "submitting"}>
|
||||||
|
Create location
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
</FormLayout>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
app/routes/app.slots._index.tsx
Normal file
268
app/routes/app.slots._index.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
IndexTable,
|
||||||
|
EmptyState,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
|
||||||
|
|
||||||
|
function minutesToTime(min: number): string {
|
||||||
|
const h = Math.floor(min / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const m = (min % 60).toString().padStart(2, "0");
|
||||||
|
return `${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(time: string): number {
|
||||||
|
const [h, m] = time.split(":").map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const locationId = url.searchParams.get("locationId");
|
||||||
|
|
||||||
|
const locations = await db.location.findMany({
|
||||||
|
where: { shopDomain: session.shop },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLocationId = locationId || locations[0]?.id || null;
|
||||||
|
|
||||||
|
const slotTemplates = activeLocationId
|
||||||
|
? await db.slotTemplate.findMany({
|
||||||
|
where: { shopDomain: session.shop, locationId: activeLocationId },
|
||||||
|
orderBy: [{ weekday: "asc" }, { startMin: "asc" }],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { locations, activeLocationId, slotTemplates };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const { session } = await authenticate.admin(request);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
const locationId = String(formData.get("locationId") || "");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
const id = String(formData.get("id") || "");
|
||||||
|
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
|
||||||
|
return data({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = formData.get("method") as Method;
|
||||||
|
const weekday = Number(formData.get("weekday"));
|
||||||
|
const startMin = timeToMinutes(String(formData.get("startTime")));
|
||||||
|
const endMin = timeToMinutes(String(formData.get("endTime")));
|
||||||
|
const capacity = Number(formData.get("capacity"));
|
||||||
|
const cutoffMin = Number(formData.get("cutoffMin") || 0);
|
||||||
|
const leadTimeMin = Number(formData.get("leadTimeMin") || 0);
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (!locationId) errors.locationId = "Choose a location";
|
||||||
|
if (startMin >= endMin) errors.endTime = "End time must be after start time";
|
||||||
|
if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return data({ errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.slotTemplate.create({
|
||||||
|
data: {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
locationId,
|
||||||
|
method,
|
||||||
|
weekday,
|
||||||
|
startMin,
|
||||||
|
endMin,
|
||||||
|
capacity,
|
||||||
|
cutoffMin,
|
||||||
|
leadTimeMin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SlotsIndex() {
|
||||||
|
const { locations, activeLocationId, slotTemplates } = useLoaderData<typeof loader>();
|
||||||
|
const [, setSearchParams] = useSearchParams();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === "submitting";
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Weekly slots" />
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
heading="Add a location first"
|
||||||
|
action={{ content: "Add location", url: "/app/locations/new" }}
|
||||||
|
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
|
||||||
|
>
|
||||||
|
<Text as="p">Weekly slot templates belong to a location.</Text>
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Weekly slots" />
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Card>
|
||||||
|
<Select
|
||||||
|
label="Location"
|
||||||
|
options={locations.map((l) => ({ label: l.name, value: l.id }))}
|
||||||
|
value={activeLocationId ?? undefined}
|
||||||
|
onChange={(value) => setSearchParams({ locationId: value })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="0">
|
||||||
|
{slotTemplates.length === 0 ? (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Text as="p" tone="subdued">
|
||||||
|
No slot templates yet for this location. Add one below.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IndexTable
|
||||||
|
itemCount={slotTemplates.length}
|
||||||
|
headings={[
|
||||||
|
{ title: "Weekday" },
|
||||||
|
{ title: "Method" },
|
||||||
|
{ title: "Window" },
|
||||||
|
{ title: "Capacity" },
|
||||||
|
{ title: "Cutoff (min)" },
|
||||||
|
{ title: "Lead time (min)" },
|
||||||
|
{ title: "" },
|
||||||
|
]}
|
||||||
|
selectable={false}
|
||||||
|
>
|
||||||
|
{slotTemplates.map((slot, index) => (
|
||||||
|
<IndexTable.Row id={slot.id} key={slot.id} position={index}>
|
||||||
|
<IndexTable.Cell>{WEEKDAY_NAMES[slot.weekday]}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{slot.method.replace("_", " ")}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>
|
||||||
|
{minutesToTime(slot.startMin)}–{minutesToTime(slot.endMin)}
|
||||||
|
</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<input type="hidden" name="id" value={slot.id} />
|
||||||
|
<Button submit variant="plain" tone="critical">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</IndexTable.Cell>
|
||||||
|
</IndexTable.Row>
|
||||||
|
))}
|
||||||
|
</IndexTable>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AddSlotForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
|
||||||
|
</BlockStack>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
|
||||||
|
const [startTime, setStartTime] = useState("09:00");
|
||||||
|
const [endTime, setEndTime] = useState("17:00");
|
||||||
|
const [capacity, setCapacity] = useState("10");
|
||||||
|
const [cutoffMin, setCutoffMin] = useState("60");
|
||||||
|
const [leadTimeMin, setLeadTimeMin] = useState("0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="locationId" value={locationId} />
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text as="h3" variant="headingSm">
|
||||||
|
Add a slot template
|
||||||
|
</Text>
|
||||||
|
<InlineStack gap="300" wrap>
|
||||||
|
<Select
|
||||||
|
label="Weekday"
|
||||||
|
name="weekday"
|
||||||
|
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Method"
|
||||||
|
name="method"
|
||||||
|
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Start time"
|
||||||
|
name="startTime"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={setStartTime}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="End time"
|
||||||
|
name="endTime"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={setEndTime}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Capacity"
|
||||||
|
name="capacity"
|
||||||
|
type="number"
|
||||||
|
value={capacity}
|
||||||
|
onChange={setCapacity}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Cutoff (min)"
|
||||||
|
name="cutoffMin"
|
||||||
|
type="number"
|
||||||
|
value={cutoffMin}
|
||||||
|
onChange={setCutoffMin}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Lead time (min)"
|
||||||
|
name="leadTimeMin"
|
||||||
|
type="number"
|
||||||
|
value={leadTimeMin}
|
||||||
|
onChange={setLeadTimeMin}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</InlineStack>
|
||||||
|
<div>
|
||||||
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
Add slot template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</BlockStack>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,6 +24,9 @@ export default function App() {
|
|||||||
<Link to="/app" rel="home">
|
<Link to="/app" rel="home">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/app/locations">Locations</Link>
|
||||||
|
<Link to="/app/slots">Weekly slots</Link>
|
||||||
|
<Link to="/app/blackouts">Blackout dates</Link>
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
|
|||||||
186
app/services/templates.server.ts
Normal file
186
app/services/templates.server.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import db from "../db.server";
|
||||||
|
|
||||||
|
export type VerticalKey = "bakery" | "florist" | "grocer";
|
||||||
|
|
||||||
|
export interface SlotTemplateInput {
|
||||||
|
method: Method;
|
||||||
|
weekday: number; // 0 = Sunday .. 6 = Saturday
|
||||||
|
startMin: number;
|
||||||
|
endMin: number;
|
||||||
|
capacity: number;
|
||||||
|
cutoffMin: number;
|
||||||
|
leadTimeMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerticalTemplate {
|
||||||
|
vertical: VerticalKey;
|
||||||
|
location: {
|
||||||
|
name: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
slotTemplates: SlotTemplateInput[];
|
||||||
|
widgetCopy: {
|
||||||
|
pickupLabel: string;
|
||||||
|
deliveryLabel: string;
|
||||||
|
shippingLabel: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR = 60;
|
||||||
|
|
||||||
|
// Tue-Sat, pickup + local delivery. Bakers need same-day cutoff buffer for
|
||||||
|
// remaining-batch prep (strategy §3.2 resource capacity), hence the shorter
|
||||||
|
// weekday window and a same-day cutoff.
|
||||||
|
function bakeryTemplate(): VerticalTemplate {
|
||||||
|
const weekdays = [2, 3, 4, 5, 6]; // Tue-Sat
|
||||||
|
return {
|
||||||
|
vertical: "bakery",
|
||||||
|
location: { name: "Main Bakery", timezone: "America/Toronto" },
|
||||||
|
slotTemplates: weekdays.flatMap((weekday) => [
|
||||||
|
{
|
||||||
|
method: "PICKUP" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 9 * HOUR,
|
||||||
|
endMin: 17 * HOUR,
|
||||||
|
capacity: 20,
|
||||||
|
cutoffMin: 2 * HOUR,
|
||||||
|
leadTimeMin: 24 * HOUR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "LOCAL_DELIVERY" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 11 * HOUR,
|
||||||
|
endMin: 16 * HOUR,
|
||||||
|
capacity: 10,
|
||||||
|
cutoffMin: 3 * HOUR,
|
||||||
|
leadTimeMin: 24 * HOUR,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
widgetCopy: {
|
||||||
|
pickupLabel: "Pickup date",
|
||||||
|
deliveryLabel: "Delivery date",
|
||||||
|
shippingLabel: "Shipping date",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mon-Sat, pickup + local delivery. Shorter lead time than bakery (florists
|
||||||
|
// typically make up arrangements same-day) but tighter per-slot capacity
|
||||||
|
// (single arranger/driver bottleneck).
|
||||||
|
function floristTemplate(): VerticalTemplate {
|
||||||
|
const weekdays = [1, 2, 3, 4, 5, 6]; // Mon-Sat
|
||||||
|
return {
|
||||||
|
vertical: "florist",
|
||||||
|
location: { name: "Main Shop", timezone: "America/Toronto" },
|
||||||
|
slotTemplates: weekdays.flatMap((weekday) => [
|
||||||
|
{
|
||||||
|
method: "PICKUP" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 9 * HOUR,
|
||||||
|
endMin: 18 * HOUR,
|
||||||
|
capacity: 15,
|
||||||
|
cutoffMin: HOUR,
|
||||||
|
leadTimeMin: 4 * HOUR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "LOCAL_DELIVERY" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 10 * HOUR,
|
||||||
|
endMin: 17 * HOUR,
|
||||||
|
capacity: 8,
|
||||||
|
cutoffMin: 2 * HOUR,
|
||||||
|
leadTimeMin: 4 * HOUR,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
widgetCopy: {
|
||||||
|
pickupLabel: "Pickup date",
|
||||||
|
deliveryLabel: "Delivery date",
|
||||||
|
shippingLabel: "Shipping date",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every day, delivery + pickup + shipping, longer hours and higher
|
||||||
|
// per-slot capacity (route-based, strategy §3.2 delivery-density).
|
||||||
|
function grocerTemplate(): VerticalTemplate {
|
||||||
|
const weekdays = [0, 1, 2, 3, 4, 5, 6]; // every day
|
||||||
|
return {
|
||||||
|
vertical: "grocer",
|
||||||
|
location: { name: "Main Store", timezone: "America/Toronto" },
|
||||||
|
slotTemplates: weekdays.flatMap((weekday) => [
|
||||||
|
{
|
||||||
|
method: "LOCAL_DELIVERY" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 8 * HOUR,
|
||||||
|
endMin: 20 * HOUR,
|
||||||
|
capacity: 40,
|
||||||
|
cutoffMin: 2 * HOUR,
|
||||||
|
leadTimeMin: 2 * HOUR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "PICKUP" as Method,
|
||||||
|
weekday,
|
||||||
|
startMin: 8 * HOUR,
|
||||||
|
endMin: 20 * HOUR,
|
||||||
|
capacity: 60,
|
||||||
|
cutoffMin: HOUR,
|
||||||
|
leadTimeMin: HOUR,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
widgetCopy: {
|
||||||
|
pickupLabel: "Pickup date",
|
||||||
|
deliveryLabel: "Delivery date",
|
||||||
|
shippingLabel: "Shipping date",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES: Record<VerticalKey, () => VerticalTemplate> = {
|
||||||
|
bakery: bakeryTemplate,
|
||||||
|
florist: floristTemplate,
|
||||||
|
grocer: grocerTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getVerticalTemplate(vertical: VerticalKey): VerticalTemplate {
|
||||||
|
return TEMPLATES[vertical]();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listVerticalTemplates(): VerticalKey[] {
|
||||||
|
return Object.keys(TEMPLATES) as VerticalKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// I/O wrapper: applies a vertical's preset by creating a Location + its
|
||||||
|
// SlotTemplates and merging widgetCopy into Shop.settings, all in one
|
||||||
|
// transaction. Keeps the actual preset shape (above) pure and unit-testable.
|
||||||
|
export async function seedVerticalTemplate(shopDomain: string, vertical: VerticalKey) {
|
||||||
|
const template = getVerticalTemplate(vertical);
|
||||||
|
|
||||||
|
return db.$transaction(async (tx) => {
|
||||||
|
// Only seed widgetCopy for a brand-new shop — never clobber a merchant's
|
||||||
|
// existing customized settings by re-running/switching templates.
|
||||||
|
await tx.shop.upsert({
|
||||||
|
where: { shopDomain },
|
||||||
|
create: { shopDomain, settings: { widgetCopy: template.widgetCopy } },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = await tx.location.create({
|
||||||
|
data: {
|
||||||
|
shopDomain,
|
||||||
|
name: template.location.name,
|
||||||
|
address: "",
|
||||||
|
timezone: template.location.timezone,
|
||||||
|
slotTemplates: {
|
||||||
|
create: template.slotTemplates.map((slot) => ({
|
||||||
|
shopDomain,
|
||||||
|
...slot,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { slotTemplates: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return location;
|
||||||
|
});
|
||||||
|
}
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: delivery-datetime-app
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: app
|
||||||
|
POSTGRES_DB: delivery_datetime_dev
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@ -1,20 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Session" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"shop" TEXT NOT NULL,
|
|
||||||
"state" TEXT NOT NULL,
|
|
||||||
"isOnline" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"scope" TEXT,
|
|
||||||
"expires" DATETIME,
|
|
||||||
"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" DATETIME
|
|
||||||
);
|
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Method" AS ENUM ('SHIPPING', 'LOCAL_DELIVERY', 'PICKUP');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Shop" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"plan" TEXT NOT NULL DEFAULT 'basic',
|
||||||
|
"tier" TEXT NOT NULL DEFAULT 'free',
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'UTC',
|
||||||
|
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Shop_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"lat" DOUBLE PRECISION,
|
||||||
|
"lng" DOUBLE PRECISION,
|
||||||
|
"timezone" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SlotTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"method" "Method" NOT NULL,
|
||||||
|
"weekday" INTEGER NOT NULL,
|
||||||
|
"startMin" INTEGER NOT NULL,
|
||||||
|
"endMin" INTEGER NOT NULL,
|
||||||
|
"capacity" INTEGER NOT NULL,
|
||||||
|
"cutoffMin" INTEGER,
|
||||||
|
"leadTimeMin" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SlotTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SlotOverride" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"method" "Method" NOT NULL,
|
||||||
|
"closed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"startMin" INTEGER,
|
||||||
|
"endMin" INTEGER,
|
||||||
|
"capacity" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SlotOverride_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BlackoutDate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shopDomain" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT,
|
||||||
|
"method" "Method",
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BlackoutDate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Shop_shopDomain_key" ON "Shop"("shopDomain");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_shopDomain_idx" ON "Location"("shopDomain");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SlotTemplate_shopDomain_locationId_method_weekday_idx" ON "SlotTemplate"("shopDomain", "locationId", "method", "weekday");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SlotOverride_shopDomain_locationId_date_idx" ON "SlotOverride"("shopDomain", "locationId", "date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BlackoutDate_shopDomain_date_idx" ON "BlackoutDate"("shopDomain", "date");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SlotTemplate" ADD CONSTRAINT "SlotTemplate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SlotOverride" ADD CONSTRAINT "SlotOverride_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BlackoutDate" ADD CONSTRAINT "BlackoutDate_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@ -9,8 +9,8 @@ generator client {
|
|||||||
// enough when changing adapters.
|
// enough when changing adapters.
|
||||||
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
|
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "postgresql"
|
||||||
url = "file:dev.sqlite"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@ -32,3 +32,88 @@ model Session {
|
|||||||
refreshToken String?
|
refreshToken String?
|
||||||
refreshTokenExpires DateTime?
|
refreshTokenExpires DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Scheduling domain (IMPLEMENTATION_PLAN.md §4) -------------------------
|
||||||
|
// Every model below is scoped by shopDomain (multi-tenant, per CLAUDE.md).
|
||||||
|
// Built incrementally per phase; see the plan for models not yet added
|
||||||
|
// (CapacityResource, Zone, ProductRule, Rate, SlotHold, Booking, ...).
|
||||||
|
|
||||||
|
enum Method {
|
||||||
|
SHIPPING
|
||||||
|
LOCAL_DELIVERY
|
||||||
|
PICKUP
|
||||||
|
}
|
||||||
|
|
||||||
|
model Shop {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String @unique
|
||||||
|
plan String @default("basic") // basic|shopify|advanced|plus
|
||||||
|
tier String @default("free") // free|starter|growth|pro
|
||||||
|
timezone String @default("UTC")
|
||||||
|
settings Json @default("{}") // widget copy, i18n, feature flags
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Location {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String
|
||||||
|
name String
|
||||||
|
address String
|
||||||
|
lat Float?
|
||||||
|
lng Float?
|
||||||
|
timezone String
|
||||||
|
active Boolean @default(true)
|
||||||
|
slotTemplates SlotTemplate[]
|
||||||
|
overrides SlotOverride[]
|
||||||
|
blackouts BlackoutDate[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([shopDomain])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SlotTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String
|
||||||
|
locationId String
|
||||||
|
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
method Method
|
||||||
|
weekday Int // 0-6 (0 = Sunday), location-local
|
||||||
|
startMin Int // minutes from midnight, local
|
||||||
|
endMin Int
|
||||||
|
capacity Int // default order capacity if no resources
|
||||||
|
cutoffMin Int? // cutoff before slot (minutes)
|
||||||
|
leadTimeMin Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([shopDomain, locationId, method, weekday])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SlotOverride {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String
|
||||||
|
locationId String
|
||||||
|
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime // date-only, location-local
|
||||||
|
method Method
|
||||||
|
closed Boolean @default(false)
|
||||||
|
startMin Int?
|
||||||
|
endMin Int?
|
||||||
|
capacity Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([shopDomain, locationId, date])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BlackoutDate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopDomain String
|
||||||
|
locationId String? // null = all locations
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||||
|
method Method? // null = all methods
|
||||||
|
date DateTime
|
||||||
|
reason String?
|
||||||
|
source String @default("manual") // manual|holiday-import
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([shopDomain, date])
|
||||||
|
}
|
||||||
|
|||||||
40
tests/unit/templates.test.ts
Normal file
40
tests/unit/templates.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getVerticalTemplate, listVerticalTemplates } from "../../app/services/templates.server";
|
||||||
|
|
||||||
|
describe("getVerticalTemplate", () => {
|
||||||
|
it("lists exactly the three Phase 1 vertical presets", () => {
|
||||||
|
expect(listVerticalTemplates().sort()).toEqual(["bakery", "florist", "grocer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(listVerticalTemplates())("%s: every slot has a valid weekday and start < end", (vertical) => {
|
||||||
|
const template = getVerticalTemplate(vertical);
|
||||||
|
expect(template.slotTemplates.length).toBeGreaterThan(0);
|
||||||
|
for (const slot of template.slotTemplates) {
|
||||||
|
expect(slot.weekday).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(slot.weekday).toBeLessThanOrEqual(6);
|
||||||
|
expect(slot.startMin).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(slot.endMin).toBeLessThanOrEqual(24 * 60);
|
||||||
|
expect(slot.startMin).toBeLessThan(slot.endMin);
|
||||||
|
expect(slot.capacity).toBeGreaterThan(0);
|
||||||
|
expect(slot.cutoffMin).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(slot.leadTimeMin).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakery covers Tue-Sat only (closed Sun/Mon)", () => {
|
||||||
|
const weekdays = new Set(getVerticalTemplate("bakery").slotTemplates.map((s) => s.weekday));
|
||||||
|
expect(weekdays.has(0)).toBe(false);
|
||||||
|
expect(weekdays.has(1)).toBe(false);
|
||||||
|
expect(weekdays.has(2)).toBe(true);
|
||||||
|
expect(weekdays.has(6)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grocer is the only preset open every day of the week", () => {
|
||||||
|
const weekdays = new Set(getVerticalTemplate("grocer").slotTemplates.map((s) => s.weekday));
|
||||||
|
expect(weekdays.size).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic (calling twice yields an equivalent template)", () => {
|
||||||
|
expect(getVerticalTemplate("florist")).toEqual(getVerticalTemplate("florist"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user