datewise/app/routes/app._index.tsx
metatroncubeswdev 67b86cf411 feat(admin): setup guide on Today page with live step status
Shared checkout-guard lookup moved to app/lib/guard.server.ts.

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

222 lines
11 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,
themeEditor: `https://${cfg.shop.domain}/admin/themes/current/editor?context=apps&activateAppId=${process.env.SHOPIFY_API_KEY}/datewise-embed`,
};
};
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: 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" },
},
{
id: "widget",
done: setup.widgetLive,
title: "Add the scheduler to your cart",
body: "In the theme editor, add the Delivery date & time block to the cart page and switch on the Datewise app embed so it also shows in the cart drawer. This step is marked done after the first shopper picks a time.",
action: { label: "Open theme editor", href: themeEditor, external: true },
},
{
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" },
},
];
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>
</s-stack>
)}
</s-stack>
</s-box>
)}
</s-box>
</s-box>
))}
</s-box>
</s-grid>
</s-section>
);
}