datewise/app/routes/app._index.tsx
metatroncubeswdev 54734e8938 feat(billing): switch to Shopify managed pricing; show chosen time on thank-you page
App Store review 1.2.2 ("fails to register the plan"): follow Offerlyst's approved
managed-pricing setup. The plan is read from the Partner API on every app load,
shops without a plan (new install or reinstall) go to Shopify's plan selection
page, and app_subscriptions/update keeps it in sync. Billing API code removed.

Reviewer's checkout video: Shopify's checkout shows pickup by location name, so
the delivery label rename never reached the shopper. New checkout-time extension
shows the chosen date and time on the thank-you page (all plans) and next to
pickup/shipping options (Plus). Checkout display copy now matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 -05: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/plans">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>
);
}