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 <noreply@anthropic.com>
This commit is contained in:
parent
123ca71d42
commit
67b86cf411
23
app/lib/guard.server.ts
Normal file
23
app/lib/guard.server.ts
Normal file
@ -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<string, unknown> }) => Promise<Response> };
|
||||||
|
|
||||||
|
export async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@ -1,25 +1,30 @@
|
|||||||
import type { LoaderFunctionArgs } from "react-router";
|
import type { LoaderFunctionArgs } from "react-router";
|
||||||
import { useLoaderData } from "react-router";
|
import { useLoaderData } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import prisma from "../db.server";
|
import prisma from "../db.server";
|
||||||
import { capsFor } from "../engine/capacity";
|
import { capsFor } from "../engine/capacity";
|
||||||
import { hoursFor } from "../engine/availability";
|
import { hoursFor } from "../engine/availability";
|
||||||
import { addDays, localParts } from "../engine/time";
|
import { addDays, localParts } from "../engine/time";
|
||||||
import { METHODS } from "../engine/types";
|
import { METHODS } from "../engine/types";
|
||||||
import { adminContext } from "../lib/admin.server";
|
import { adminContext } from "../lib/admin.server";
|
||||||
|
import { findGuard } from "../lib/guard.server";
|
||||||
import { METHOD_LABEL, niceDate } from "../lib/format";
|
import { METHOD_LABEL, niceDate } from "../lib/format";
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
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 shopId = cfg.shop.id;
|
||||||
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
||||||
const end = addDays(today, 13);
|
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: today, status: "confirmed" } }),
|
||||||
prisma.booking.count({ where: { shopId, date: { gt: today, lte: addDays(today, 7) }, 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.booking.count({ where: { shopId, OR: [{ status: "needs_review" }, { status: "confirmed", reviewReason: { not: null } }] } }),
|
||||||
prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }),
|
prisma.waitlistEntry.count({ where: { shopId, offeredAt: null, date: { gte: today } } }),
|
||||||
prisma.capacityCounter.findMany({ where: { shopId, slot: "*", date: { gte: today, lte: end } } }),
|
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`) } } }),
|
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
|
const rows = cfg.locations
|
||||||
@ -44,7 +49,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const setup = {
|
const setup = {
|
||||||
anyLocation: cfg.locations.some((l) => l.enabled),
|
anyLocation: cfg.locations.some((l) => l.enabled),
|
||||||
feeProduct: !!cfg.shop.feeProductId,
|
widgetLive: holdsSeen > 0,
|
||||||
|
guardOn: !!guard.validation?.enabled,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
today, todayCount, weekCount, review, waiting, rows, peaks, setup,
|
today, todayCount, weekCount, review, waiting, rows, peaks, setup,
|
||||||
@ -59,15 +65,7 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<s-page heading={`Today, ${niceDate(d.today, true)}`}>
|
<s-page heading={`Today, ${niceDate(d.today, true)}`}>
|
||||||
<s-button slot="primary-action" href="/app/bookings">View bookings</s-button>
|
<s-button slot="primary-action" href="/app/bookings">View bookings</s-button>
|
||||||
{!d.setup.anyLocation && (
|
<SetupGuide setup={d.setup} themeEditor={d.themeEditor} />
|
||||||
<s-banner tone="warning" heading="Finish setup to start taking bookings">
|
|
||||||
<s-paragraph>
|
|
||||||
1. Review hours and turn on a location in <s-link href="/app/locations">Locations & hours</s-link>. 2. Add the Datewise
|
|
||||||
block to your cart page and switch on the app embed in the <s-link href={d.themeEditor} target="_blank">theme editor</s-link>.
|
|
||||||
3. Turn on the checkout guard in <s-link href="/app/settings">Settings</s-link>.
|
|
||||||
</s-paragraph>
|
|
||||||
</s-banner>
|
|
||||||
)}
|
|
||||||
{overLimit && (
|
{overLimit && (
|
||||||
<s-banner tone="warning" heading="You've reached this month's Starter limit">
|
<s-banner tone="warning" heading="You've reached this month's Starter limit">
|
||||||
<s-paragraph>
|
<s-paragraph>
|
||||||
@ -139,3 +137,85 @@ function Kpi({ label, value, href, alert }: { label: string; value: number; href
|
|||||||
);
|
);
|
||||||
return href ? <s-clickable href={href}>{body}</s-clickable> : body;
|
return href ? <s-clickable href={href}>{body}</s-clickable> : 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<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>
|
||||||
|
</s-stack>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
</s-box>
|
||||||
|
)}
|
||||||
|
</s-box>
|
||||||
|
</s-box>
|
||||||
|
))}
|
||||||
|
</s-box>
|
||||||
|
</s-grid>
|
||||||
|
</s-section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -9,27 +9,7 @@ import { encrypt } from "../services/crypto.server";
|
|||||||
import { PLAN_COPY } from "../services/plans";
|
import { PLAN_COPY } from "../services/plans";
|
||||||
import { type AppSettings, withDefaults } from "../services/settings";
|
import { type AppSettings, withDefaults } from "../services/settings";
|
||||||
|
|
||||||
const GUARD_HANDLE = "datewise-checkout-guard";
|
import { GUARD_HANDLE, findGuard, gql } from "../lib/guard.server";
|
||||||
|
|
||||||
type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
|
|
||||||
async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>) {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { cfg, admin, billing } = await adminContext(request);
|
const { cfg, admin, billing } = await adminContext(request);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user