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>
234 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|