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(); const overLimit = d.monthlyLimit != null && d.monthCount >= d.monthlyLimit; return ( View bookings {overLimit && ( New orders are still recorded, so no customer is turned away. Upgrade to keep reminders and capacity planning running. )} 0} /> {d.rows.length === 0 && No pickup or delivery locations are switched on yet.} {d.rows.map((r) => ( {r.label}
{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 (
{x.open &&
}
{x.open ? (x.cap != null ? `${x.used}/${x.cap}` : x.used) : "—"} {x.date.slice(8)}
); })}
))} {d.peaks.length === 0 ? ( No peak days set. Add Diwali, Mother's Day or Valentine's in Rush control to cap orders, close earlier and open a waitlist. ) : ( {d.peaks.map((p) => ( {p.name}, {niceDate(p.date, true)} {p.cutoff ? `. Orders close ${p.cutoff.daysBefore} day(s) before at ${p.cutoff.time}.` : "."} ))} )} ); } function Kpi({ label, value, href, alert }: { label: string; value: number; href?: string; alert?: boolean }) { const body = ( {label} {value} ); return href ? {body} : 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(firstOpen); const [hidden, setHidden] = useState(false); if (hidden || doneCount === steps.length) return null; return ( Set up Datewise setHidden(true)} /> Three steps and your customers can choose a delivery or pickup time. {doneCount} of {steps.length} steps completed {steps.map((step, i) => ( {i > 0 && } {step.title} setOpen(open === step.id ? null : step.id)} /> {open === step.id && ( {step.body} {!step.done && ( {step.action.label} )} )} ))} ); }