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(); 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, 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(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} )} )} )} ))} ); }