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>
This commit is contained in:
metatroncubeswdev 2026-11-03 16:52:00 -05:00
parent 19b403f98f
commit 54734e8938
19 changed files with 657 additions and 97 deletions

View File

@ -10,6 +10,13 @@ POSTGRES_PASSWORD=change-me-to-a-long-random-string
# Back it up in the password manager; never change it (existing data can't be decrypted without it). # Back it up in the password manager; never change it (existing data can't be decrypted without it).
APP_ENCRYPTION_KEY= APP_ENCRYPTION_KEY=
# Managed pricing (Partner Dashboard). Partner API client token with the "Manage apps" permission
# reads the shop's plan. Without these the app keeps the stored plan and never redirects to plan selection.
SHOPIFY_APP_HANDLE=datewise-delivery
SHOPIFY_PARTNER_ORG_ID=2451848
SHOPIFY_PARTNER_API_ACCESS_TOKEN=
SHOPIFY_APP_GID=gid://shopify/App/435762528257
# Optional: radius / driving-distance zones (Geocoding + Distance Matrix APIs) # Optional: radius / driving-distance zones (Geocoding + Distance Matrix APIs)
GOOGLE_MAPS_API_KEY= GOOGLE_MAPS_API_KEY=
# Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend. # Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend.

View File

@ -1,12 +0,0 @@
import { type AdminGraphql, gql } from "./guard.server";
/**
* Billing API charges are test charges outside production and on development stores
* (including App Store reviewers' stores), which can't take real charges. Real shops pay.
*/
export async function billingIsTest(admin: AdminGraphql): Promise<boolean> {
if (process.env.NODE_ENV !== "production") return true;
const d = await gql<{ shop: { plan: { partnerDevelopment: boolean } } }>(admin, `#graphql
query DatewiseBillingMode { shop { plan { partnerDevelopment } } }`).catch(() => null);
return !!d?.shop.plan.partnerDevelopment;
}

67
app/lib/partner.server.ts Normal file
View File

@ -0,0 +1,67 @@
// Shopify App Pricing (managed pricing): the shop's plan comes from the Partner API's activeSubscription query,
// not from the Billing API, which App Pricing apps can't use. Same approach as Offerlyst (approved).
// https://shopify.dev/docs/apps/launch/billing/shopify-app-pricing/redirect-plan-selection-page
const PARTNER_API_VERSION = "2026-07";
const QUERY = `query DatewiseActiveSubscription($appId: ID!, $shopId: ID!) {
activeSubscription(appId: $appId, shopId: $shopId) { cancelAtEndOfCycle items { handle } }
}`;
export interface ActiveSubscription {
/** plan handles of the subscription's items, e.g. ["growth"] */
handles: string[];
/** the subscription was cancelled (app uninstalled, or cancelled in the admin) and only runs out its paid period */
cancelled: boolean;
}
// Only confirmed subscriptions are cached, briefly: a merchant who just approved a plan is checked at once,
// and a cancellation is picked up when the entry expires.
const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { at: number; sub: ActiveSubscription }>();
/** Called on app/uninstalled: the subscription is cancelled then, so a quick reinstall must not see a cached plan. */
export function forgetSubscriptions() {
cache.clear();
}
export function partnerApiConfigured() {
return !!(process.env.SHOPIFY_PARTNER_ORG_ID && process.env.SHOPIFY_PARTNER_API_ACCESS_TOKEN && process.env.SHOPIFY_APP_GID);
}
/** Returns the shop's active subscription, or null when it has none. Throws when the Partner API fails,
* so callers never treat an outage or throttling as "no plan". */
export async function fetchActiveSubscription(shopGid: string, { fresh = false } = {}): Promise<ActiveSubscription | null> {
const hit = cache.get(shopGid);
if (!fresh && hit && Date.now() - hit.at < CACHE_MS) return hit.sub;
const res = await fetch(`https://partners.shopify.com/${process.env.SHOPIFY_PARTNER_ORG_ID}/api/${PARTNER_API_VERSION}/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": process.env.SHOPIFY_PARTNER_API_ACCESS_TOKEN || "" },
body: JSON.stringify({ query: QUERY, variables: { appId: process.env.SHOPIFY_APP_GID, shopId: shopGid } }),
});
const json = (await res.json().catch(() => ({}))) as {
data?: { activeSubscription: { cancelAtEndOfCycle: boolean; items: { handle: string | null }[] } | null };
errors?: unknown;
};
if (!res.ok || json.errors || !json.data) {
throw new Error(`Partner API request failed: ${JSON.stringify(json.errors ?? res.status)}`);
}
const active = json.data.activeSubscription;
if (!active) {
cache.delete(shopGid);
return null;
}
const sub = {
handles: (active.items || []).map((i) => i.handle || "").filter(Boolean),
cancelled: !!active.cancelAtEndOfCycle,
};
cache.set(shopGid, { at: Date.now(), sub });
return sub;
}
/** Shopify's hosted plan selection page for this app. */
export function pricingUrl(shopDomain: string) {
const store = shopDomain.replace(".myshopify.com", "");
return `https://admin.shopify.com/store/${store}/charges/${process.env.SHOPIFY_APP_HANDLE || "datewise-delivery"}/pricing_plans`;
}

View File

@ -74,7 +74,7 @@ export default function Dashboard() {
{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>
New orders are still recorded, so no customer is turned away. <s-link href="/app/settings#plan">Upgrade</s-link> to keep reminders and capacity planning running. 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-paragraph>
</s-banner> </s-banner>
)} )}

View File

@ -83,7 +83,7 @@ export default function Freshness() {
return ( return (
<s-page heading="Fresh stock"> <s-page heading="Fresh stock">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>} {result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{!allowed && <s-banner tone="info" heading="Growth plan feature">Tie bookable dates to when perishable stock arrives. <s-link href="/app/settings#plan">See plans</s-link></s-banner>} {!allowed && <s-banner tone="info" heading="Growth plan feature">Tie bookable dates to when perishable stock arrives. <s-link href="/app/plans">See plans</s-link></s-banner>}
<s-section> <s-section>
<s-paragraph> <s-paragraph>
For perishables like flowers, produce and sweets: customers can only pick dates from arrival + "ready after" days up to arrival + shelf life. If no batch covers a date, the calendar tells the customer when the next fresh batch is ready. For perishables like flowers, produce and sweets: customers can only pick dates from arrival + "ready after" days up to arrival + shelf life. If no batch covers a date, the calendar tells the customer when the next fresh batch is ready.

View File

@ -68,7 +68,7 @@ export default function Peak() {
return ( return (
<s-page heading="Rush control"> <s-page heading="Rush control">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>} {result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{!allowed && <s-banner tone="info" heading="Growth plan feature">Separate limits, earlier cutoffs and waitlists for festival days. <s-link href="/app/settings#plan">See plans</s-link></s-banner>} {!allowed && <s-banner tone="info" heading="Growth plan feature">Separate limits, earlier cutoffs and waitlists for festival days. <s-link href="/app/plans">See plans</s-link></s-banner>}
<s-section> <s-section>
<s-paragraph> <s-paragraph>
On festival days demand jumps and mistakes are expensive. Set a separate order limit, close orders earlier, add a peak-handling fee, and let customers join a waitlist instead of leaving when you're full. When an order is cancelled, the first person waiting is offered the place for a limited time. On festival days demand jumps and mistakes are expensive. Set a separate order limit, close orders earlier, add a peak-handling fee, and let customers join a waitlist instead of leaving when you're full. When an order is cancelled, the first person waiting is offered the place for a limited time.

46
app/routes/app.plans.tsx Normal file
View File

@ -0,0 +1,46 @@
// Plans use Shopify managed pricing: Shopify hosts the plan page, trials, approval and proration.
// The app layout loader keeps the stored plan in sync with the subscription.
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { adminContext } from "../lib/admin.server";
import { pricingUrl } from "../lib/partner.server";
import { PLAN_COPY } from "../services/plans";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, session } = await adminContext(request, { fresh: true });
return { plan: cfg.shop.plan, pricingUrl: pricingUrl(session.shop) };
};
export default function Plans() {
const { plan, pricingUrl } = useLoaderData<typeof loader>();
const current = PLAN_COPY.find((p) => p.id === plan) ?? PLAN_COPY[0];
return (
<s-page heading="Plans">
<s-button slot="primary-action" variant="primary" href={pricingUrl} target="_top">Change plan</s-button>
<s-section>
<s-paragraph>
You're on the <s-text type="strong">{current.name}</s-text> plan. Paid plans include a 14-day free trial; development stores are free.
</s-paragraph>
</s-section>
<s-section heading="Compare">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
{PLAN_COPY.map((p) => (
<s-box key={p.id} padding="base" border="base" borderRadius="base" background={plan === p.id ? "subdued" : "base"}>
<s-stack gap="small">
<s-heading>{p.name}</s-heading>
<s-text type="strong">{p.price}</s-text>
<s-unordered-list>
{p.points.map((x) => <s-list-item key={x}>{x}</s-list-item>)}
</s-unordered-list>
{plan === p.id ? <s-badge tone="success">Current plan</s-badge> : null}
</s-stack>
</s-box>
))}
</s-grid>
</s-section>
</s-page>
);
}
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);

View File

@ -58,7 +58,7 @@ export default function RunSheet() {
return ( return (
<s-page heading={`Run sheet · ${niceDate(d.date, true)}`}> <s-page heading={`Run sheet · ${niceDate(d.date, true)}`}>
{d.allowed && <s-button slot="secondary-actions" href={d.csvHref} target="_blank">Download CSV</s-button>} {d.allowed && <s-button slot="secondary-actions" href={d.csvHref} target="_blank">Download CSV</s-button>}
{!d.allowed && <s-banner tone="info" heading="Growth plan feature">Run sheets and route links. <s-link href="/app/settings#plan">See plans</s-link></s-banner>} {!d.allowed && <s-banner tone="info" heading="Growth plan feature">Run sheets and route links. <s-link href="/app/plans">See plans</s-link></s-banner>}
<s-section> <s-section>
<Form method="get"> <Form method="get">
<s-stack direction="inline" gap="base" alignItems="end"> <s-stack direction="inline" gap="base" alignItems="end">

View File

@ -1,26 +1,16 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { useEffect } from "react";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server"; import prisma from "../db.server";
import type { ReminderPolicy } from "../engine/types"; import type { ReminderPolicy } from "../engine/types";
import { adminContext, formBool, formInt, formString } from "../lib/admin.server"; import { adminContext, formBool, formInt, formString } from "../lib/admin.server";
import { billingIsTest } from "../lib/billing.server";
import { PLAN_GROWTH, PLAN_PRO } from "../shopify.server";
import { bumpConfig } from "../services/config.server"; import { bumpConfig } from "../services/config.server";
import { encrypt } from "../services/crypto.server"; import { encrypt } from "../services/crypto.server";
import { PLAN_COPY } from "../services/plans";
import { type AppSettings, withDefaults } from "../services/settings"; import { type AppSettings, withDefaults } from "../services/settings";
import { type CustomizationKind, findCustomizations, findGuard, gql, setCustomization, setGuard } from "../lib/guard.server"; import { type CustomizationKind, findCustomizations, findGuard, gql, setCustomization, setGuard } from "../lib/guard.server";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, admin, billing } = await adminContext(request); const { cfg, admin } = await adminContext(request);
// Keep the stored plan in sync with Shopify billing.
// isTest: true counts test subscriptions too. Shopify makes the charge a test on stores that can't be billed
// (including App Store review stores), and those must still unlock the plan.
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: true });
const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free";
if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } });
const guard = await findGuard(admin).catch(() => null); const guard = await findGuard(admin).catch(() => null);
const custom = await findCustomizations(admin).catch(() => null); const custom = await findCustomizations(admin).catch(() => null);
const s = cfg.settings; const s = cfg.settings;
@ -35,7 +25,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
} }
const off = { deployed: false, enabled: false }; const off = { deployed: false, enabled: false };
return { return {
plan,
s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } }, s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } },
guard: { deployed: !!guard?.fn, enabled: !!guard?.validation?.enabled }, guard: { deployed: !!guard?.fn, enabled: !!guard?.validation?.enabled },
custom: { custom: {
@ -46,7 +35,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
}; };
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg, admin, billing, features } = await adminContext(request); const { cfg, admin, features } = await adminContext(request);
const fd = await request.formData(); const fd = await request.formData();
const intent = formString(fd, "intent"); const intent = formString(fd, "intent");
const s: AppSettings = withDefaults(cfg.shop.settings); const s: AppSettings = withDefaults(cfg.shop.settings);
@ -55,28 +44,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await bumpConfig(cfg.shop.id, cfg.shop.domain); await bumpConfig(cfg.shop.id, cfg.shop.domain);
}; };
if (intent.startsWith("plan:")) {
const plan = intent.slice(5);
const isTest = await billingIsTest(admin);
if (plan === "free") {
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: true });
for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest: sub.test, prorate: true });
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } });
return { ok: true, message: "Switched to Starter" };
}
// billing.request throws a 401 carrying Shopify's approval URL for App Bridge to follow. A React Router
// form submission surfaces that 401 as an error page instead, so hand the URL to the page to open.
try {
await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest, returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` });
} catch (e) {
if (!(e instanceof Response) || e.headers.has("X-Shopify-Retry-Invalid-Session-Request")) throw e;
const confirmationUrl = e.headers.get("X-Shopify-API-Request-Failure-Reauthorize-Url");
if (confirmationUrl) return { ok: true, message: "Opening Shopify to approve the plan…", confirmationUrl };
console.error(`[datewise] billing request failed: ${e.status} ${[...e.headers.keys()].join(",")}`);
return { ok: false, message: "Shopify couldn't start the plan change. Reload the page and try again." };
}
}
if (intent === "guard") { if (intent === "guard") {
const turnOn = formBool(fd, "on"); const turnOn = formBool(fd, "on");
const error = await setGuard(admin, turnOn, s.requireScheduling).catch((e: Error) => e.message); const error = await setGuard(admin, turnOn, s.requireScheduling).catch((e: Error) => e.message);
@ -179,13 +146,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}; };
export default function Settings() { export default function Settings() {
const { plan, s, guard, custom } = useLoaderData<typeof loader>(); const { s, guard, custom } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>(); const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle"; const busy = useNavigation().state !== "idle";
const confirmationUrl = result && "confirmationUrl" in result ? result.confirmationUrl : null;
useEffect(() => {
if (confirmationUrl) window.open(confirmationUrl, "_top");
}, [confirmationUrl]);
return ( return (
<s-page heading="Settings"> <s-page heading="Settings">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>} {result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
@ -212,7 +175,7 @@ export default function Settings() {
intent="custom:delivery" intent="custom:delivery"
state={custom.delivery} state={custom.delivery}
title="Show the chosen date and time at checkout" title="Show the chosen date and time at checkout"
body="Adds the customer's date and time to the matching delivery option, for example “Local Delivery — Sat, Nov 7, 10 AM–1 PM”, so it appears in the order summary and confirmation." body="Adds the customer's date and time to the order's delivery method, for example “Local Delivery — Sat, Nov 7, 10 AM–1 PM”, so staff see it on the order. Shoppers always see their date and time on the thank-you page, and on Shopify Plus also next to the pickup and shipping options in checkout."
/> />
<s-divider /> <s-divider />
<CheckoutToggle <CheckoutToggle
@ -338,27 +301,7 @@ export default function Settings() {
</s-section> </s-section>
<s-section heading="Plan" id="plan"> <s-section heading="Plan" id="plan">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base"> <s-button href="/app/plans">See plans</s-button>
{PLAN_COPY.map((p) => (
<s-box key={p.id} padding="base" border="base" borderRadius="base" background={plan === p.id ? "subdued" : "base"}>
<s-stack gap="small">
<s-heading>{p.name}</s-heading>
<s-text type="strong">{p.price}</s-text>
<s-unordered-list>
{p.points.map((x) => <s-list-item key={x}>{x}</s-list-item>)}
</s-unordered-list>
{plan === p.id ? (
<s-badge tone="success">Current plan</s-badge>
) : (
<Form method="post">
<input type="hidden" name="intent" value={`plan:${p.id}`} />
<s-button type="submit">{p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`}</s-button>
</Form>
)}
</s-stack>
</s-box>
))}
</s-grid>
</s-section> </s-section>
</s-page> </s-page>
); );

View File

@ -2,10 +2,41 @@ import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Outlet, useLoaderData, useRouteError } from "react-router"; import { Outlet, useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server"; import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react"; import { AppProvider } from "@shopify/shopify-app-react-router/react";
import prisma from "../db.server";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { fetchActiveSubscription, partnerApiConfigured, pricingUrl } from "../lib/partner.server";
import { bumpConfig } from "../services/config.server";
import { planFromSubscription } from "../services/plans";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request); const { admin, redirect, session } = await authenticate.admin(request);
// Shopify App Pricing: a shop without a plan (new install), or whose subscription was cancelled (uninstall,
// then reinstall), goes to Shopify's plan selection page to approve a plan again. Otherwise the stored plan
// follows the subscription. A Partner API failure never redirects a paying merchant.
if (partnerApiConfigured()) {
// the welcome link adds plan_handle after a plan is approved: skip the cache so the new plan shows at once
const fresh = new URL(request.url).searchParams.has("plan_handle");
let sub: Awaited<ReturnType<typeof fetchActiveSubscription>> | undefined;
try {
const r = await admin.graphql(`#graphql
query DatewiseShopId { shop { id } }`);
const { data } = (await r.json()) as { data: { shop: { id: string } } };
sub = await fetchActiveSubscription(data.shop.id, { fresh });
} catch (e) {
console.error("[datewise] subscription check failed", e);
}
if (sub === null || sub?.cancelled) throw redirect(pricingUrl(session.shop), { target: "_top" });
if (sub) {
const plan = planFromSubscription(sub.handles.join(" "));
const row = await prisma.shop.findUnique({ where: { domain: session.shop }, select: { id: true, plan: true } });
if (row && row.plan !== plan) {
await prisma.shop.update({ where: { id: row.id }, data: { plan } });
await bumpConfig(row.id, session.shop);
}
}
}
return { apiKey: process.env.SHOPIFY_API_KEY || "" }; return { apiKey: process.env.SHOPIFY_API_KEY || "" };
}; };
@ -23,6 +54,7 @@ export default function App() {
<s-link href="/app/runsheet">Driver run sheet</s-link> <s-link href="/app/runsheet">Driver run sheet</s-link>
<s-link href="/app/diagnostics">Why unavailable?</s-link> <s-link href="/app/diagnostics">Why unavailable?</s-link>
<s-link href="/app/settings">Settings</s-link> <s-link href="/app/settings">Settings</s-link>
<s-link href="/app/plans">Plans</s-link>
</s-app-nav> </s-app-nav>
<Outlet /> <Outlet />
</AppProvider> </AppProvider>

View File

@ -1,6 +1,9 @@
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
import prisma from "../db.server"; import prisma from "../db.server";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { forgetSubscriptions } from "../lib/partner.server";
import { bumpConfig } from "../services/config.server";
import { planFromSubscription } from "../services/plans";
/** /**
* app/uninstalled: storefront behaviour stops immediately (proxy calls fail without a * app/uninstalled: storefront behaviour stops immediately (proxy calls fail without a
@ -10,11 +13,22 @@ import { authenticate } from "../shopify.server";
export const action = async ({ request }: ActionFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, session, payload } = await authenticate.webhook(request); const { shop, topic, session, payload } = await authenticate.webhook(request);
if (topic === "APP_UNINSTALLED") { if (topic === "APP_UNINSTALLED") {
forgetSubscriptions();
if (session) await prisma.session.deleteMany({ where: { shop } }); if (session) await prisma.session.deleteMany({ where: { shop } });
await prisma.shop.updateMany({ where: { domain: shop }, data: { uninstalledAt: new Date(), plan: "free" } }); // Shopify cancels app subscriptions on uninstall await prisma.shop.updateMany({ where: { domain: shop }, data: { uninstalledAt: new Date(), plan: "free" } }); // Shopify cancels app subscriptions on uninstall
await prisma.hold.deleteMany({ where: { shop: { domain: shop } } }); await prisma.hold.deleteMany({ where: { shop: { domain: shop } } });
await prisma.reminderJob.updateMany({ where: { shop: { domain: shop }, status: "pending" }, data: { status: "cancelled" } }); await prisma.reminderJob.updateMany({ where: { shop: { domain: shop }, status: "pending" }, data: { status: "cancelled" } });
} }
// Managed pricing plan changes (upgrade, downgrade, cancellation), including ones made while the app is closed.
if (topic === "APP_SUBSCRIPTIONS_UPDATE") {
const sub = (payload as { app_subscription?: { name?: string; status?: string } }).app_subscription;
const plan = sub?.status?.toUpperCase() === "ACTIVE" ? planFromSubscription(sub?.name) : "free";
const row = await prisma.shop.findUnique({ where: { domain: shop }, select: { id: true, plan: true } });
if (row && row.plan !== plan) {
await prisma.shop.update({ where: { id: row.id }, data: { plan } });
await bumpConfig(row.id, shop);
}
}
if (topic === "APP_SCOPES_UPDATE" && session) { if (topic === "APP_SCOPES_UPDATE" && session) {
const current = (payload as { current?: string[] }).current; const current = (payload as { current?: string[] }).current;
if (current) await prisma.session.update({ where: { id: session.id }, data: { scope: current.toString() } }); if (current) await prisma.session.update({ where: { id: session.id }, data: { scope: current.toString() } });

View File

@ -1,6 +1,6 @@
/** /**
* Feature gates per billing plan. "free" = no active subscription. * Feature gates per billing plan. "free" = Starter or no active subscription.
* Keep in sync with the App Store listing and shopify.server.ts billing config. * Prices live in the Partner Dashboard (managed pricing); keep plan handles and copy in sync with it.
*/ */
export interface Features { export interface Features {
maxLocations: number; maxLocations: number;
@ -25,6 +25,14 @@ export function features(plan: string): Features {
return PLANS[plan] ?? PLANS.free; return PLANS[plan] ?? PLANS.free;
} }
/** Maps managed-pricing plan handles or names (Partner Dashboard: starter / growth / pro) to a plan key. */
export function planFromSubscription(text: string | null | undefined): string {
const n = (text || "").toLowerCase();
if (n.includes("pro")) return "Pro";
if (n.includes("growth")) return "Growth";
return "free";
}
export const PLAN_COPY = [ export const PLAN_COPY = [
{ id: "free", name: "Starter", price: "Free", points: ["1 location", "50 scheduled orders a month", "Capacity holds and cutoffs", "Email reminders"] }, { id: "free", name: "Starter", price: "Free", points: ["1 location", "50 scheduled orders a month", "Capacity holds and cutoffs", "Email reminders"] },
{ id: "Growth", name: "Growth", price: "$19/month", points: ["Up to 3 locations", "Unlimited orders", "Festival rush control and waitlist", "Fresh-stock windows", "Driver run sheets"] }, { id: "Growth", name: "Growth", price: "$19/month", points: ["Up to 3 locations", "Unlimited orders", "Festival rush control and waitlist", "Fresh-stock windows", "Driver run sheets"] },

View File

@ -2,17 +2,12 @@ import "@shopify/shopify-app-react-router/adapters/node";
import { import {
ApiVersion, ApiVersion,
AppDistribution, AppDistribution,
BillingInterval,
shopifyApp, shopifyApp,
} from "@shopify/shopify-app-react-router/server"; } from "@shopify/shopify-app-react-router/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server"; import prisma from "./db.server";
import { ensureShop } from "./services/config.server"; import { ensureShop } from "./services/config.server";
/** Plan names are referenced by billing checks and feature gates (services/plans.ts). */
export const PLAN_GROWTH = "Growth";
export const PLAN_PRO = "Pro";
const shopify = shopifyApp({ const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY, apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "", apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
@ -23,16 +18,6 @@ const shopify = shopifyApp({
sessionStorage: new PrismaSessionStorage(prisma), sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore, distribution: AppDistribution.AppStore,
future: { expiringOfflineAccessTokens: true }, future: { expiringOfflineAccessTokens: true },
billing: {
[PLAN_GROWTH]: {
trialDays: 14,
lineItems: [{ amount: 19, currencyCode: "USD", interval: BillingInterval.Every30Days }],
},
[PLAN_PRO]: {
trialDays: 14,
lineItems: [{ amount: 49, currencyCode: "USD", interval: BillingInterval.Every30Days }],
},
},
hooks: { hooks: {
afterAuth: async ({ session, admin }) => { afterAuth: async ({ session, admin }) => {
await ensureShop(session.shop, admin); await ensureShop(session.shop, admin);

View File

@ -13,6 +13,11 @@ x-app-env: &app-env
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-}
RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM_DOMAIN: ${EMAIL_FROM_DOMAIN:-} EMAIL_FROM_DOMAIN: ${EMAIL_FROM_DOMAIN:-}
# Managed pricing: the shop's plan is read from the Partner API
SHOPIFY_APP_HANDLE: ${SHOPIFY_APP_HANDLE:-datewise-delivery}
SHOPIFY_PARTNER_ORG_ID: ${SHOPIFY_PARTNER_ORG_ID:-}
SHOPIFY_PARTNER_API_ACCESS_TOKEN: ${SHOPIFY_PARTNER_API_ACCESS_TOKEN:-}
SHOPIFY_APP_GID: ${SHOPIFY_APP_GID:-}
services: services:
db: db:

371
extensions/checkout-time/package-lock.json generated Normal file
View File

@ -0,0 +1,371 @@
{
"name": "datewise-checkout-time",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datewise-checkout-time",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.11.3",
"@shopify/ui-extensions": "2026.1.x",
"preact": "^10.26.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@preact/signals": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.11.4.tgz",
"integrity": "sha512-Rb+5RPo9gWSaHtzJeauhHxiC8KP/IF2DEPkHPEOGOnuy2wnJzRqyeLRankUCYd6ADqtxS6GnWGv6Ie343d8aYw==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.14.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact": ">= 10.25.0 || >=11.0.0-0"
}
},
"node_modules/@preact/signals-core": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.5.tgz",
"integrity": "sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@shopify/ui-extensions": {
"version": "2026.1.5",
"resolved": "https://registry.npmjs.org/@shopify/ui-extensions/-/ui-extensions-2026.1.5.tgz",
"integrity": "sha512-vDWYOtEcMT/Jm4s8Fe8iZVoRiLNwG5DbC0kqfrFRTcPD2D6mvkL+e3MYc4LfrTV4dPCCqBjSvfMpa8fK33fDFA==",
"license": "MIT",
"dependencies": {
"ts-morph": "^25.0.1"
},
"peerDependencies": {
"@preact/signals": "*",
"preact": "*"
},
"peerDependenciesMeta": {
"@preact/signals": {
"optional": true
},
"preact": {
"optional": true
}
}
},
"node_modules/@ts-morph/common": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz",
"integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==",
"license": "MIT",
"dependencies": {
"fast-glob": "^3.3.2",
"minimatch": "^9.0.4",
"path-browserify": "^1.0.1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fastq": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
"integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-morph": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz",
"integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==",
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.26.0",
"code-block-writer": "^13.0.3"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"name": "datewise-checkout-time",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.11.3",
"@shopify/ui-extensions": "2026.1.x",
"preact": "^10.26.0"
}
}

View File

@ -0,0 +1,20 @@
api_version = "2026-01"
[[extensions]]
name = "Datewise: chosen time"
handle = "datewise-checkout-time"
type = "ui_extension"
# Thank-you page: all plans. Renders automatically (static target).
[[extensions.targeting]]
module = "./src/TimeNote.jsx"
target = "purchase.thank-you.customer-information.render-after"
# Checkout steps: Shopify Plus only.
[[extensions.targeting]]
module = "./src/TimeNote.jsx"
target = "purchase.checkout.pickup-location-list.render-after"
[[extensions.targeting]]
module = "./src/TimeNote.jsx"
target = "purchase.checkout.shipping-option-list.render-after"

View File

@ -0,0 +1,63 @@
/**
* Shows the date and time the customer chose in the cart (the `_datewise` cart attribute).
* Shopify's checkout lists pickup by location name and address, so the delivery label function's
* rename never reaches the shopper there; this note does.
* - thank-you page: every plan
* - pickup / shipping option lists: Shopify Plus only (other plans skip these targets)
*/
import "@shopify/ui-extensions/preact";
import { render } from "preact";
export default async () => {
render(<Extension />, document.body);
};
const LIST_METHODS = {
"purchase.checkout.pickup-location-list.render-after": ["pickup"],
"purchase.checkout.shipping-option-list.render-after": ["delivery", "shipping"],
};
function selection() {
const attrs = shopify.attributes.value || [];
const get = (k) => attrs.find((a) => a.key === k)?.value;
let s;
try {
s = JSON.parse(get("_datewise") || "");
} catch {
return null;
}
if (!s || typeof s.d !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(s.d)) return null;
const slot = typeof s.s === "string" ? /^(\d{2}:\d{2})-(\d{2}:\d{2})$/.exec(s.s) : null;
return {
method: s.m,
date: s.d,
start: slot ? slot[1] : "",
end: slot ? slot[2] : "",
label: get("Fulfillment method") || "",
place: get("Pickup location") || "",
};
}
function Extension() {
const sel = selection();
if (!sel) return null;
const only = LIST_METHODS[shopify.extension.target];
if (only && !only.includes(sel.method)) return null;
const locale = shopify.localization.language.value?.isoCode || undefined;
const date = new Date(`${sel.date}T12:00:00Z`).toLocaleDateString(locale, {
weekday: "long", month: "long", day: "numeric", timeZone: "UTC",
});
const time = (t) =>
new Date(Date.UTC(2000, 0, 1, +t.slice(0, 2), +t.slice(3, 5))).toLocaleTimeString(locale, {
hour: "numeric", minute: "2-digit", timeZone: "UTC",
});
const when = sel.start && sel.end ? `${date}, ${time(sel.start)}–${time(sel.end)}` : date;
const heading = [sel.label, sel.place].filter(Boolean).join(" · ");
return (
<s-banner tone="info" heading={heading || when}>
{heading ? <s-text>{when}</s-text> : null}
</s-banner>
);
}

View File

@ -21,7 +21,7 @@ redirect_urls = ["https://datewise.metatronlabz.com/auth/callback"]
api_version = "2026-01" api_version = "2026-01"
[[webhooks.subscriptions]] [[webhooks.subscriptions]]
topics = ["app/uninstalled", "app/scopes_update"] topics = ["app/uninstalled", "app/scopes_update", "app_subscriptions/update"]
uri = "/webhooks/app" uri = "/webhooks/app"
[[webhooks.subscriptions]] [[webhooks.subscriptions]]