feat: Phase 3 — storefront widget + cart attributes
Theme App Extension (extensions/datetime-widget/) hand-scaffolded from
Shopify's documented structure, since `shopify app generate extension`
needs the same interactive Partner login `shopify app init`/`dev` do
(unavailable in this session) — user confirmed this approach.
- blocks/app-embed.liquid: site-wide toggle that loads the widget's JS/CSS
once (target: body)
- blocks/datetime-picker.liquid: the actual app block merchants add to a
cart/product page section, with per-method show/hide toggles and an
optional location override, all theme-editor-configurable
- src/datetime-widget.ts: vanilla TS (no framework, ~2kb gzipped) — renders
method -> date -> slot, calls the app-proxy availability endpoint, and on
selection writes to /cart/update.js cart attributes using a *method-
specific* attribute name ("Pickup date" vs "Delivery date" vs "Shipping
date", from locales/en.default.json) rather than one generic label — this
is the direct fix for the "estimated delivery date on a pickup order"
complaint PRODUCT_STRATEGY.md §3.1 calls out. Only collects a selection;
never enforces anything itself (Phase 4's Validation Function does that).
- locales/en.default.json + en.default.schema.json: i18n from day one
- app/routes/apps.scheduling.availability.tsx: the public app-proxy
endpoint the widget calls. Path is `apps.scheduling.availability` (not
the plan's suggested `api.availability`) because shopify.app.toml's
[app_proxy].url already includes the /apps/scheduling prefix, and
Shopify forwards a shop-facing /apps/scheduling/availability request to
{url}/availability against that full url — so the Remix route path has
to mirror the proxy path exactly for the forwarding to land correctly.
Resolves (location, method) -> DB rows -> getAvailability(), scoped by
shopDomain throughout. No consumption wired up (Booking doesn't exist
until Phase 4), so this correctly shows full capacity everywhere for now.
Theme App Extensions have no CLI build step, so `npm run build:widget`
(esbuild, added as a devDependency) bundles src/ into assets/ and is wired
as a predev/predeploy hook so `shopify app dev`/`deploy` never ship a stale
bundle. CI now also runs both `npm run build` and `npm run build:widget`.
Verified: lint, typecheck, unit tests, both builds pass; a live script
exercising the exact DB-query + getAvailability path the availability route
uses (bypassing HTTP, since real app-proxy signature verification needs a
live tunnel) returned correct results against the Postgres container —
correct EDT offset, correct capacity, and exactly the weekday-filtered set
of open dates for the seeded bakery template.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
24980b8e03
commit
6c633af598
@ -4,3 +4,4 @@ public/build
|
||||
shopify-app-remix
|
||||
*/*.yml
|
||||
.shopify
|
||||
extensions/*/assets/*.js
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@ -22,3 +22,7 @@ jobs:
|
||||
run: npm run typecheck
|
||||
- name: Unit tests
|
||||
run: npm test -- --run
|
||||
- name: Build admin app
|
||||
run: npm run build
|
||||
- name: Build storefront widget
|
||||
run: npm run build:widget
|
||||
|
||||
113
app/routes/apps.scheduling.availability.tsx
Normal file
113
app/routes/apps.scheduling.availability.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
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";
|
||||
|
||||
// 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.
|
||||
//
|
||||
// No capacity consumption is wired up yet (Booking/SlotHold don't exist
|
||||
// until Phase 4), so every slot's `consumed` is implicitly 0 here — that's
|
||||
// expected for Phase 3, not a bug to fix in this file.
|
||||
|
||||
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||
const MAX_DAYS = 60;
|
||||
const DEFAULT_DAYS = 14;
|
||||
|
||||
function toIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
const 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();
|
||||
|
||||
const [slotTemplates, overrides, blackouts] = 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 }] }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
locationId: location.id,
|
||||
locationName: location.name,
|
||||
timezone: location.timezone,
|
||||
method,
|
||||
dates: availability,
|
||||
});
|
||||
};
|
||||
57
extensions/datetime-widget/assets/datetime-widget.css
Normal file
57
extensions/datetime-widget/assets/datetime-widget.css
Normal file
@ -0,0 +1,57 @@
|
||||
.dd-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
font-family: inherit;
|
||||
min-height: 3.5rem; /* reserves space before JS renders content, to avoid layout shift */
|
||||
}
|
||||
|
||||
.dd-widget__heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dd-widget__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dd-widget__pill {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dd-widget__pill[aria-pressed="true"] {
|
||||
background: currentColor;
|
||||
color: Canvas;
|
||||
}
|
||||
|
||||
.dd-widget__status {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.75;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dd-widget__confirmation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dd-widget__link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
1
extensions/datetime-widget/assets/datetime-widget.js
Normal file
1
extensions/datetime-widget/assets/datetime-widget.js
Normal file
File diff suppressed because one or more lines are too long
10
extensions/datetime-widget/blocks/app-embed.liquid
Normal file
10
extensions/datetime-widget/blocks/app-embed.liquid
Normal file
@ -0,0 +1,10 @@
|
||||
{{ 'datetime-widget.css' | asset_url | stylesheet_tag }}
|
||||
{{ 'datetime-widget.js' | asset_url | script_tag }}
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "t:app_embed.name",
|
||||
"target": "body",
|
||||
"settings": []
|
||||
}
|
||||
{% endschema %}
|
||||
64
extensions/datetime-widget/blocks/datetime-picker.liquid
Normal file
64
extensions/datetime-widget/blocks/datetime-picker.liquid
Normal file
@ -0,0 +1,64 @@
|
||||
<div
|
||||
class="dd-widget"
|
||||
data-dd-widget
|
||||
data-heading="{{ block.settings.heading | escape }}"
|
||||
data-show-shipping="{{ block.settings.show_shipping }}"
|
||||
data-show-local-delivery="{{ block.settings.show_local_delivery }}"
|
||||
data-show-pickup="{{ block.settings.show_pickup }}"
|
||||
data-label-shipping="{{ 'widget.method_shipping' | t | escape }}"
|
||||
data-label-local-delivery="{{ 'widget.method_local_delivery' | t | escape }}"
|
||||
data-label-pickup="{{ 'widget.method_pickup' | t | escape }}"
|
||||
data-attr-label-shipping="{{ 'widget.date_label_shipping' | t | escape }}"
|
||||
data-attr-label-local-delivery="{{ 'widget.date_label_local_delivery' | t | escape }}"
|
||||
data-attr-label-pickup="{{ 'widget.date_label_pickup' | t | escape }}"
|
||||
data-label-choose-date="{{ 'widget.choose_date' | t | escape }}"
|
||||
data-label-choose-time="{{ 'widget.choose_time' | t | escape }}"
|
||||
data-label-no-dates="{{ 'widget.no_dates' | t | escape }}"
|
||||
data-label-confirmed="{{ 'widget.confirmed' | t | escape }}"
|
||||
data-label-change="{{ 'widget.change' | t | escape }}"
|
||||
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
||||
data-label-error="{{ 'widget.error' | t | escape }}"
|
||||
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
|
||||
{{ block.shopify_attributes }}
|
||||
>
|
||||
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
||||
</div>
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "t:datetime_picker.name",
|
||||
"target": "section",
|
||||
"settings": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "heading",
|
||||
"label": "t:datetime_picker.heading_label",
|
||||
"default": "Choose your delivery date"
|
||||
},
|
||||
{
|
||||
"type": "checkbox",
|
||||
"id": "show_shipping",
|
||||
"label": "t:datetime_picker.show_shipping_label",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"type": "checkbox",
|
||||
"id": "show_local_delivery",
|
||||
"label": "t:datetime_picker.show_local_delivery_label",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"type": "checkbox",
|
||||
"id": "show_pickup",
|
||||
"label": "t:datetime_picker.show_pickup_label",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "location_id",
|
||||
"label": "t:datetime_picker.location_id_label",
|
||||
"info": "t:datetime_picker.location_id_info"
|
||||
}
|
||||
]
|
||||
}
|
||||
{% endschema %}
|
||||
18
extensions/datetime-widget/locales/en.default.json
Normal file
18
extensions/datetime-widget/locales/en.default.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"widget": {
|
||||
"method_shipping": "Shipping",
|
||||
"method_local_delivery": "Local delivery",
|
||||
"method_pickup": "Pickup",
|
||||
"date_label_shipping": "Shipping date",
|
||||
"date_label_local_delivery": "Delivery date",
|
||||
"date_label_pickup": "Pickup date",
|
||||
"choose_date": "Choose a date",
|
||||
"choose_time": "Choose a time",
|
||||
"no_dates": "No dates are available right now.",
|
||||
"confirmed": "Confirmed for",
|
||||
"change": "Change",
|
||||
"loading": "Loading available dates…",
|
||||
"error": "Couldn't load available dates. Please try again.",
|
||||
"enable_js": "Please enable JavaScript to choose a delivery date and time."
|
||||
}
|
||||
}
|
||||
14
extensions/datetime-widget/locales/en.default.schema.json
Normal file
14
extensions/datetime-widget/locales/en.default.schema.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"app_embed": {
|
||||
"name": "Delivery Date & Time"
|
||||
},
|
||||
"datetime_picker": {
|
||||
"name": "Date & Time Picker",
|
||||
"heading_label": "Heading",
|
||||
"show_shipping_label": "Show Shipping",
|
||||
"show_local_delivery_label": "Show Local Delivery",
|
||||
"show_pickup_label": "Show Pickup",
|
||||
"location_id_label": "Location ID (advanced)",
|
||||
"location_id_info": "Leave blank to use the shop's default location."
|
||||
}
|
||||
}
|
||||
2
extensions/datetime-widget/shopify.extension.toml
Normal file
2
extensions/datetime-widget/shopify.extension.toml
Normal file
@ -0,0 +1,2 @@
|
||||
name = "Delivery Date & Time"
|
||||
type = "theme"
|
||||
295
extensions/datetime-widget/src/datetime-widget.ts
Normal file
295
extensions/datetime-widget/src/datetime-widget.ts
Normal file
@ -0,0 +1,295 @@
|
||||
// Storefront widget for the Theme App Extension. Vanilla TS, no framework
|
||||
// (IMPLEMENTATION_PLAN.md §1 sanctions "Preact or vanilla TS" — vanilla
|
||||
// keeps the bundle tiny and avoids a runtime dependency for something this
|
||||
// small). Bundled to ../assets/datetime-widget.js via esbuild
|
||||
// (`npm run build:widget` at the repo root) — Theme App Extensions ship
|
||||
// static assets as-is, there's no CLI build step for this extension type.
|
||||
//
|
||||
// The widget only *collects* a selection and writes it to cart attributes.
|
||||
// It never enforces anything — that's the Validation Function's job
|
||||
// (Phase 4), per CLAUDE.md's non-negotiable that enforcement is
|
||||
// server-side. Losing network, JS, or an ad-blocker here should degrade to
|
||||
// "no slot picked" (which the Function then rejects at checkout), not to a
|
||||
// bypass.
|
||||
|
||||
type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
|
||||
|
||||
interface SlotDto {
|
||||
date: string;
|
||||
startMin: number;
|
||||
endMin: number;
|
||||
capacity: number;
|
||||
remainingCapacity: number;
|
||||
}
|
||||
|
||||
interface AvailabilityResponse {
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
timezone: string;
|
||||
method: Method;
|
||||
dates: Record<string, SlotDto[]>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WidgetConfig {
|
||||
root: HTMLElement;
|
||||
heading: string;
|
||||
locationId: string | null;
|
||||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||
labels: {
|
||||
chooseDate: string;
|
||||
chooseTime: string;
|
||||
noDates: string;
|
||||
confirmed: string;
|
||||
change: string;
|
||||
loading: string;
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PROXY_BASE = "/apps/scheduling";
|
||||
|
||||
function minutesToDisplayTime(minutes: number): string {
|
||||
const h24 = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
const period = h24 < 12 ? "AM" : "PM";
|
||||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateIso: string): string {
|
||||
// Parsed as a plain calendar date (no timezone conversion) — this string
|
||||
// already represents the location-local calendar day from the API.
|
||||
const [year, month, day] = dateIso.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
function readConfig(root: HTMLElement): WidgetConfig {
|
||||
const d = root.dataset;
|
||||
const methods: WidgetConfig["methods"] = [];
|
||||
if (d.showShipping === "true") {
|
||||
methods.push({ value: "SHIPPING", label: d.labelShipping || "Shipping", attrLabel: d.attrLabelShipping || "Shipping date" });
|
||||
}
|
||||
if (d.showLocalDelivery === "true") {
|
||||
methods.push({
|
||||
value: "LOCAL_DELIVERY",
|
||||
label: d.labelLocalDelivery || "Local delivery",
|
||||
attrLabel: d.attrLabelLocalDelivery || "Delivery date",
|
||||
});
|
||||
}
|
||||
if (d.showPickup === "true") {
|
||||
methods.push({ value: "PICKUP", label: d.labelPickup || "Pickup", attrLabel: d.attrLabelPickup || "Pickup date" });
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
heading: d.heading || "",
|
||||
locationId: d.locationId || null,
|
||||
methods,
|
||||
labels: {
|
||||
chooseDate: d.labelChooseDate || "Choose a date",
|
||||
chooseTime: d.labelChooseTime || "Choose a time",
|
||||
noDates: d.labelNoDates || "No dates are available right now.",
|
||||
confirmed: d.labelConfirmed || "Confirmed for",
|
||||
change: d.labelChange || "Change",
|
||||
loading: d.labelLoading || "Loading available dates…",
|
||||
error: d.labelError || "Couldn't load available dates. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAvailability(method: Method, locationId: string | null): Promise<AvailabilityResponse> {
|
||||
const params = new URLSearchParams({ method, days: "14" });
|
||||
if (locationId) params.set("locationId", locationId);
|
||||
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const body = (await res.json()) as AvailabilityResponse;
|
||||
if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function writeCartAttribute(key: string, value: Record<string, string>): Promise<void> {
|
||||
await fetch("/cart/update.js", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ attributes: { [key]: value.display, ...value } }),
|
||||
});
|
||||
}
|
||||
|
||||
class DateTimeWidget {
|
||||
private config: WidgetConfig;
|
||||
private el = {
|
||||
heading: document.createElement("h3"),
|
||||
methodRow: document.createElement("div"),
|
||||
dateRow: document.createElement("div"),
|
||||
timeRow: document.createElement("div"),
|
||||
status: document.createElement("p"),
|
||||
confirmation: document.createElement("div"),
|
||||
};
|
||||
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||||
private selectedDate: string | null = null;
|
||||
private availability: AvailabilityResponse | null = null;
|
||||
|
||||
constructor(config: WidgetConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
mount() {
|
||||
const { root, heading, methods } = this.config;
|
||||
root.classList.add("dd-widget--ready");
|
||||
root.innerHTML = "";
|
||||
|
||||
if (methods.length === 0) return; // merchant disabled every method — render nothing
|
||||
|
||||
if (heading) {
|
||||
this.el.heading.className = "dd-widget__heading";
|
||||
this.el.heading.textContent = heading;
|
||||
root.appendChild(this.el.heading);
|
||||
}
|
||||
|
||||
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
|
||||
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
|
||||
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
||||
this.el.status.className = "dd-widget__status";
|
||||
this.el.confirmation.className = "dd-widget__confirmation";
|
||||
this.el.confirmation.hidden = true;
|
||||
|
||||
root.append(this.el.confirmation, this.el.methodRow, this.el.dateRow, this.el.timeRow, this.el.status);
|
||||
|
||||
if (methods.length === 1) {
|
||||
this.selectMethod(methods[0]);
|
||||
} else {
|
||||
this.renderMethods();
|
||||
}
|
||||
}
|
||||
|
||||
private renderMethods() {
|
||||
this.el.methodRow.innerHTML = "";
|
||||
for (const method of this.config.methods) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "dd-widget__pill";
|
||||
button.textContent = method.label;
|
||||
button.setAttribute("aria-pressed", String(this.selectedMethod?.value === method.value));
|
||||
button.addEventListener("click", () => this.selectMethod(method));
|
||||
this.el.methodRow.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
private async selectMethod(method: WidgetConfig["methods"][number]) {
|
||||
this.selectedMethod = method;
|
||||
this.selectedDate = null;
|
||||
this.el.timeRow.innerHTML = "";
|
||||
this.el.confirmation.hidden = true;
|
||||
if (this.config.methods.length > 1) this.renderMethods();
|
||||
|
||||
this.el.status.textContent = this.config.labels.loading;
|
||||
this.el.dateRow.innerHTML = "";
|
||||
|
||||
try {
|
||||
this.availability = await fetchAvailability(method.value, this.config.locationId);
|
||||
this.renderDates();
|
||||
} catch {
|
||||
this.el.status.textContent = this.config.labels.error;
|
||||
}
|
||||
}
|
||||
|
||||
private renderDates() {
|
||||
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
||||
this.el.dateRow.innerHTML = "";
|
||||
|
||||
if (dates.length === 0) {
|
||||
this.el.status.textContent = this.config.labels.noDates;
|
||||
return;
|
||||
}
|
||||
|
||||
this.el.status.textContent = this.config.labels.chooseDate;
|
||||
for (const date of dates) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "dd-widget__pill";
|
||||
button.textContent = formatDateLabel(date);
|
||||
button.setAttribute("aria-pressed", String(this.selectedDate === date));
|
||||
button.addEventListener("click", () => this.selectDate(date));
|
||||
this.el.dateRow.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
private selectDate(date: string) {
|
||||
this.selectedDate = date;
|
||||
for (const child of Array.from(this.el.dateRow.children)) {
|
||||
child.setAttribute("aria-pressed", String(child.textContent === formatDateLabel(date)));
|
||||
}
|
||||
|
||||
const slots = this.availability?.dates[date] ?? [];
|
||||
this.el.timeRow.innerHTML = "";
|
||||
this.el.status.textContent = this.config.labels.chooseTime;
|
||||
|
||||
for (const slot of slots) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "dd-widget__pill";
|
||||
button.textContent = `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||
button.addEventListener("click", () => this.selectSlot(date, slot));
|
||||
this.el.timeRow.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
private async selectSlot(date: string, slot: SlotDto) {
|
||||
const method = this.selectedMethod!;
|
||||
const availability = this.availability!;
|
||||
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||||
|
||||
this.el.status.textContent = this.config.labels.loading;
|
||||
|
||||
try {
|
||||
await writeCartAttribute(method.attrLabel, {
|
||||
display,
|
||||
dd_method: method.value,
|
||||
dd_date: date,
|
||||
dd_start_min: String(slot.startMin),
|
||||
dd_end_min: String(slot.endMin),
|
||||
dd_location_id: availability.locationId,
|
||||
});
|
||||
|
||||
this.el.status.textContent = "";
|
||||
this.el.methodRow.hidden = true;
|
||||
this.el.dateRow.hidden = true;
|
||||
this.el.timeRow.hidden = true;
|
||||
this.el.confirmation.hidden = false;
|
||||
this.el.confirmation.innerHTML = "";
|
||||
|
||||
const summary = document.createElement("p");
|
||||
summary.textContent = `${this.config.labels.confirmed} ${display}`;
|
||||
const changeButton = document.createElement("button");
|
||||
changeButton.type = "button";
|
||||
changeButton.className = "dd-widget__link";
|
||||
changeButton.textContent = this.config.labels.change;
|
||||
changeButton.addEventListener("click", () => {
|
||||
this.el.methodRow.hidden = false;
|
||||
this.el.dateRow.hidden = false;
|
||||
this.el.timeRow.hidden = false;
|
||||
this.el.confirmation.hidden = true;
|
||||
});
|
||||
|
||||
this.el.confirmation.append(summary, changeButton);
|
||||
} catch {
|
||||
this.el.status.textContent = this.config.labels.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const roots = document.querySelectorAll<HTMLElement>("[data-dd-widget]");
|
||||
roots.forEach((root) => {
|
||||
new DateTimeWidget(readConfig(root)).mount();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
1075
package-lock.json
generated
1075
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"predev": "npm run build:widget",
|
||||
"dev": "shopify app dev",
|
||||
"predeploy": "npm run build:widget",
|
||||
"config:link": "shopify app config link",
|
||||
"generate": "shopify app generate",
|
||||
"deploy": "shopify app deploy",
|
||||
@ -16,6 +18,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build:widget": "esbuild extensions/datetime-widget/src/datetime-widget.ts --bundle --minify --target=es2019 --outfile=extensions/datetime-widget/assets/datetime-widget.js",
|
||||
"worker": "tsx jobs/worker.ts",
|
||||
"shopify": "shopify",
|
||||
"prisma": "prisma",
|
||||
@ -56,6 +59,7 @@
|
||||
"@types/node": "^22.2.0",
|
||||
"@types/react": "^18.2.31",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"prettier": "^3.2.4",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user