datewise/app/routes/app._index.tsx
metatroncubeswdev 21f16a6eeb feat(listing): all six screenshots; setup guide notes Shopify's pickup estimate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:02:33 -04:00

235 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`,
},
shippingSettings: `https://${cfg.shop.domain}/admin/settings/shipping`,
};
};
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} shippingSettings={d.shippingSettings} />
{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, shippingSettings }: { setup: Setup; themeEditor: { embed: string; block: string }; shippingSettings: 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. For store pickup, also turn on Local pickup for that location in Shopify's Shipping and delivery settings so checkout offers it, and set its expected pickup time to the shortest option so Shopify's estimate doesn't contradict the chosen time.",
action: { label: "Open Locations & hours", href: "/app/locations" },
secondary: { label: "Shopify pickup settings", href: shippingSettings },
},
{
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>
);
}