From 67b86cf4116cb2bc87a752629272d9ac22624878 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Tue, 13 Oct 2026 21:18:42 -0400 Subject: [PATCH] feat(admin): setup guide on Today page with live step status Shared checkout-guard lookup moved to app/lib/guard.server.ts. Co-Authored-By: Claude Opus 5.5 --- app/lib/guard.server.ts | 23 ++++++++ app/routes/app._index.tsx | 104 +++++++++++++++++++++++++++++++----- app/routes/app.settings.tsx | 22 +------- 3 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 app/lib/guard.server.ts diff --git a/app/lib/guard.server.ts b/app/lib/guard.server.ts new file mode 100644 index 0000000..9eda107 --- /dev/null +++ b/app/lib/guard.server.ts @@ -0,0 +1,23 @@ +/** Checkout guard (cart & checkout validation function) lookup, shared by Settings and the Today setup guide. */ +export const GUARD_HANDLE = "datewise-checkout-guard"; + +export type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; + +export async function gql(admin: AdminGraphql, q: string, variables?: Record) { + const r = await admin.graphql(q, { variables }); + return ((await r.json()) as { data: T }).data; +} + +export async function findGuard(admin: AdminGraphql) { + const d = await gql<{ + shopifyFunctions: { nodes: { id: string; title: string; apiType: string }[] }; + validations: { nodes: { id: string; enabled: boolean; shopifyFunction: { id: string } }[] }; + }>(admin, `#graphql + query { + shopifyFunctions(first: 50) { nodes { id title apiType } } + validations(first: 25) { nodes { id enabled shopifyFunction { id } } } + }`); + const fn = d.shopifyFunctions.nodes.find((f) => f.title === GUARD_HANDLE || /datewise/i.test(f.title)); + const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined; + return { fn, validation }; +} diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index b25535c..cc387f0 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -1,25 +1,30 @@ 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 } = await adminContext(request); + 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] = await Promise.all([ + 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 @@ -44,7 +49,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { .slice(0, 3); const setup = { anyLocation: cfg.locations.some((l) => l.enabled), - feeProduct: !!cfg.shop.feeProductId, + widgetLive: holdsSeen > 0, + guardOn: !!guard.validation?.enabled, }; return { today, todayCount, weekCount, review, waiting, rows, peaks, setup, @@ -59,15 +65,7 @@ export default function Dashboard() { return ( View bookings - {!d.setup.anyLocation && ( - - - 1. Review hours and turn on a location in Locations & hours. 2. Add the Datewise - block to your cart page and switch on the app embed in the theme editor. - 3. Turn on the checkout guard in Settings. - - - )} + {overLimit && ( @@ -139,3 +137,85 @@ function Kpi({ label, value, href, alert }: { label: string; value: number; href ); 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} + + + )} + + + )} + + + ))} + + + + ); +} diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 4070570..03a61f0 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -9,27 +9,7 @@ import { encrypt } from "../services/crypto.server"; import { PLAN_COPY } from "../services/plans"; import { type AppSettings, withDefaults } from "../services/settings"; -const GUARD_HANDLE = "datewise-checkout-guard"; - -type AdminGraphql = { graphql: (q: string, o?: { variables?: Record }) => Promise }; -async function gql(admin: AdminGraphql, q: string, variables?: Record) { - const r = await admin.graphql(q, { variables }); - return ((await r.json()) as { data: T }).data; -} - -async function findGuard(admin: AdminGraphql) { - const d = await gql<{ - shopifyFunctions: { nodes: { id: string; title: string; apiType: string }[] }; - validations: { nodes: { id: string; enabled: boolean; shopifyFunction: { id: string } }[] }; - }>(admin, `#graphql - query { - shopifyFunctions(first: 50) { nodes { id title apiType } } - validations(first: 25) { nodes { id enabled shopifyFunction { id } } } - }`); - const fn = d.shopifyFunctions.nodes.find((f) => f.title === GUARD_HANDLE || /datewise/i.test(f.title)); - const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined; - return { fn, validation }; -} +import { GUARD_HANDLE, findGuard, gql } from "../lib/guard.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { const { cfg, admin, billing } = await adminContext(request);