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(); 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: { 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(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} {step.secondary && ( {step.secondary.label} )} )} )} ))} ); }