datewise/app/routes/app._index.tsx
metatroncubeswdev 893b0f6ada fix(admin): theme editor deep links use block file handles
activateAppId pointed at a non-existent 'datewise-embed' handle; the embed is 'embed'.
Adds an addAppBlockId link that places the scheduler block on the cart template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:21 -04:00

234 lines
12 KiB
TypeScript

import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { useState } from "react";
import prisma from "../db.server";
import { capsFor } from "../engine/capacity";
import { hoursFor } from "../engine/availability";
import { addDays, localParts } from "../engine/time";
import { METHODS } from "../engine/types";
import { adminContext } from "../lib/admin.server";
import { findGuard } from "../lib/guard.server";
import { METHOD_LABEL, niceDate } from "../lib/format";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features, admin } = await adminContext(request);
const shopId = cfg.shop.id;
const today = localParts(Date.now(), cfg.shop.timezone).date;
const end = addDays(today, 13);
const [todayCount, weekCount, review, waiting, counters, monthCount, holdsSeen, guard] = await Promise.all([
prisma.booking.count({ where: { shopId, date: today, status: "confirmed" } }),
prisma.booking.count({ where: { shopId, date: { gt: today, lte: addDays(today, 7) }, status: "confirmed" } }),
prisma.booking.count({ where: { shopId, OR: [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }] } }),
prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }),
prisma.capacityCounter.findMany({ where: { shopId, slot: "*", date: { gte: today, lte: end } } }),
prisma.booking.count({ where: { shopId, createdAt: { gte: new Date(`${today.slice(0, 7)}-01T00:00:00Z`) } } }),
// The widget is live once any shopper has held a time (holds are swept, bookings are not).
prisma.hold.count({ where: { shopId } }).then(async (n) => n + (await prisma.booking.count({ where: { shopId } }))),
findGuard(admin).catch(() => ({ fn: undefined, validation: undefined })),
]);
const rows = cfg.locations
.filter((l) => l.enabled)
.flatMap((l) =>
METHODS.filter((m) => l.methods[m]?.enabled && m !== "shipping").map((m) => ({
key: `${l.id}|${m}`,
label: `${l.name} · ${METHOD_LABEL[m]}`,
days: Array.from({ length: 14 }, (_, i) => {
const date = addDays(today, i);
const open = hoursFor(l, m, date).hours.length > 0;
const cap = capsFor(l, m, date, "00:00-00:00", features.peakDays ? cfg.peakDays : []).dayCap;
const used = counters.find((c) => c.locationId === l.id && c.method === m && c.date === date)?.count ?? 0;
const peak = cfg.peakDays.find((p) => p.dates.includes(date) && (!p.locationIds?.length || p.locationIds.includes(l.id)));
return { date, open, cap, used, peak: peak?.name ?? null };
}),
})),
);
const peaks = cfg.peakDays
.flatMap((p) => p.dates.filter((d) => d >= today).map((d) => ({ name: p.name, date: d, cutoff: p.cutoff })))
.sort((a, b) => (a.date < b.date ? -1 : 1))
.slice(0, 3);
const setup = {
anyLocation: cfg.locations.some((l) => l.enabled),
widgetLive: holdsSeen > 0,
guardOn: !!guard.validation?.enabled,
};
return {
today, todayCount, weekCount, review, waiting, rows, peaks, setup,
plan: cfg.shop.plan, monthCount, monthlyLimit: features.monthlyBookings,
// Deep links: handles are the Liquid file names in extensions/datewise-widget/blocks/.
themeEditor: {
embed: `https://${cfg.shop.domain}/admin/themes/current/editor?context=apps&activateAppId=${process.env.SHOPIFY_API_KEY}/embed`,
block: `https://${cfg.shop.domain}/admin/themes/current/editor?template=cart&addAppBlockId=${process.env.SHOPIFY_API_KEY}/scheduler&target=newAppsSection`,
},
};
};
export default function Dashboard() {
const d = useLoaderData<typeof loader>();
const overLimit = d.monthlyLimit != null && d.monthCount >= d.monthlyLimit;
return (
<s-page heading={`Today, ${niceDate(d.today, true)}`}>
<s-button slot="primary-action" href="/app/bookings">View bookings</s-button>
<SetupGuide setup={d.setup} themeEditor={d.themeEditor} />
{overLimit && (
<s-banner tone="warning" heading="You've reached this month's Starter limit">
<s-paragraph>
New orders are still recorded, so no customer is turned away. <s-link href="/app/settings#plan">Upgrade</s-link> to keep reminders and capacity planning running.
</s-paragraph>
</s-banner>
)}
<s-section>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap="base">
<Kpi label="Bookings today" value={d.todayCount} />
<Kpi label="Next 7 days" value={d.weekCount} />
<Kpi label="Needs review" value={d.review} href="/app/bookings?status=review" alert={d.review > 0} />
<Kpi label="On waitlist" value={d.waiting} href="/app/peak" />
</s-grid>
</s-section>
<s-section heading="Capacity, next 14 days">
{d.rows.length === 0 && <s-paragraph>No pickup or delivery locations are switched on yet.</s-paragraph>}
{d.rows.map((r) => (
<s-stack key={r.key} gap="small-200">
<s-text type="strong">{r.label}</s-text>
<div style={{ display: "grid", gridTemplateColumns: "repeat(14, minmax(28px, 1fr))", gap: 4, overflowX: "auto", alignItems: "end" }}>
{r.days.map((x) => {
const pct = x.cap ? Math.min(100, Math.round((100 * x.used) / x.cap)) : 0;
const full = x.cap != null && x.used >= x.cap;
return (
<div key={x.date} title={`${niceDate(x.date)}: ${x.open ? `${x.used}${x.cap != null ? ` of ${x.cap}` : ""} booked` : "closed"}${x.peak ? ` · ${x.peak}` : ""}`} style={{ display: "grid", gap: 2, textAlign: "center", fontSize: 11 }}>
<div style={{ height: 70, background: "var(--s-color-bg-surface-secondary, #f1f1f1)", borderRadius: 6, position: "relative", overflow: "hidden" }}>
{x.open && <div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: `${x.cap ? pct : Math.min(100, x.used * 8)}%`, background: full ? "#c5280c" : x.peak ? "#b98900" : "#008060" }} />}
</div>
<span>{x.open ? (x.cap != null ? `${x.used}/${x.cap}` : x.used) : "—"}</span>
<span style={{ opacity: 0.7 }}>{x.date.slice(8)}</span>
</div>
);
})}
</div>
</s-stack>
))}
</s-section>
<s-section heading="Upcoming festivals">
{d.peaks.length === 0 ? (
<s-paragraph>
No peak days set. Add Diwali, Mother's Day or Valentine's in <s-link href="/app/peak">Rush control</s-link> to cap orders, close earlier and open a waitlist.
</s-paragraph>
) : (
<s-unordered-list>
{d.peaks.map((p) => (
<s-list-item key={p.name + p.date}>
{p.name}, {niceDate(p.date, true)}
{p.cutoff ? `. Orders close ${p.cutoff.daysBefore} day(s) before at ${p.cutoff.time}.` : "."}
</s-list-item>
))}
</s-unordered-list>
)}
</s-section>
</s-page>
);
}
function Kpi({ label, value, href, alert }: { label: string; value: number; href?: string; alert?: boolean }) {
const body = (
<s-box padding="base" border="base" borderRadius="base" background="base">
<s-stack gap="small-200">
<s-text color="subdued">{label}</s-text>
<s-heading>
<span style={{ fontSize: 28, color: alert ? "#c5280c" : undefined }}>{value}</span>
</s-heading>
</s-stack>
</s-box>
);
return href ? <s-clickable href={href}>{body}</s-clickable> : body;
}
type Setup = { anyLocation: boolean; widgetLive: boolean; guardOn: boolean };
function SetupGuide({ setup, themeEditor }: { setup: Setup; themeEditor: { embed: string; block: string } }) {
const steps = [
{
id: "location",
done: setup.anyLocation,
title: "Turn on a pickup or delivery location",
body: "Your Shopify locations were imported switched off. Check the opening hours, time slots and capacity, then turn one on.",
action: { label: "Open Locations & hours", href: "/app/locations" },
secondary: null,
},
{
id: "widget",
done: setup.widgetLive,
title: "Add the scheduler to your cart",
body: "Add the Delivery date & time block to your cart page, then switch on the Datewise app embed so the scheduler also appears in the cart drawer and checkout buttons wait for a time. Save in the theme editor after each. This step is marked done after the first shopper picks a time.",
action: { label: "Add block to cart page", href: themeEditor.block, external: true },
secondary: { label: "Turn on app embed", href: themeEditor.embed },
},
{
id: "guard",
done: setup.guardOn,
title: "Require a time at checkout",
body: "The checkout guard stops orders without a valid date and time, including Buy it now and express checkouts. It works on every Shopify plan.",
action: { label: "Open Settings", href: "/app/settings" },
secondary: null,
},
];
const doneCount = steps.filter((x) => x.done).length;
const firstOpen = steps.find((x) => !x.done)?.id ?? null;
const [open, setOpen] = useState<string | null>(firstOpen);
const [hidden, setHidden] = useState(false);
if (hidden || doneCount === steps.length) return null;
return (
<s-section>
<s-grid gap="base">
<s-grid gap="small-200">
<s-grid gridTemplateColumns="1fr auto" gap="small-300" alignItems="center">
<s-heading>Set up Datewise</s-heading>
<s-button accessibilityLabel="Hide setup guide" variant="tertiary" tone="neutral" icon="x" onClick={() => setHidden(true)} />
</s-grid>
<s-paragraph>Three steps and your customers can choose a delivery or pickup time.</s-paragraph>
<s-paragraph color="subdued">
{doneCount} of {steps.length} steps completed
</s-paragraph>
</s-grid>
<s-box borderRadius="base" border="base" background="base">
{steps.map((step, i) => (
<s-box key={step.id}>
{i > 0 && <s-divider />}
<s-box padding="small">
<s-grid gridTemplateColumns="auto 1fr auto" gap="small-300" alignItems="center">
<s-icon type={step.done ? "check-circle" : "circle"} tone={step.done ? "success" : "neutral"} />
<s-text type={open === step.id ? "strong" : undefined}>{step.title}</s-text>
<s-button
accessibilityLabel={`Toggle details for ${step.title}`}
variant="tertiary"
icon={open === step.id ? "chevron-up" : "chevron-down"}
onClick={() => setOpen(open === step.id ? null : step.id)}
/>
</s-grid>
{open === step.id && (
<s-box padding="small" paddingBlockStart="none">
<s-stack gap="base">
<s-paragraph color="subdued">{step.body}</s-paragraph>
{!step.done && (
<s-stack direction="inline" gap="small-200">
<s-button variant="primary" href={step.action.href} target={step.action.external ? "_blank" : undefined}>
{step.action.label}
</s-button>
{step.secondary && (
<s-button href={step.secondary.href} target="_blank">
{step.secondary.label}
</s-button>
)}
</s-stack>
)}
</s-stack>
</s-box>
)}
</s-box>
</s-box>
))}
</s-box>
</s-grid>
</s-section>
);
}