diff --git a/.env.example b/.env.example index 45e5723..2ad14a8 100644 --- a/.env.example +++ b/.env.example @@ -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). 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) GOOGLE_MAPS_API_KEY= # Optional: reminder + waitlist emails (resend.com). The domain must be verified in Resend. diff --git a/app/lib/billing.server.ts b/app/lib/billing.server.ts deleted file mode 100644 index 87b396c..0000000 --- a/app/lib/billing.server.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/app/lib/partner.server.ts b/app/lib/partner.server.ts new file mode 100644 index 0000000..6c7ed7c --- /dev/null +++ b/app/lib/partner.server.ts @@ -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(); + +/** 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 { + 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`; +} diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index 724949c..2c1e9ac 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -74,7 +74,7 @@ export default function Dashboard() { {overLimit && ( - New orders are still recorded, so no customer is turned away. Upgrade to keep reminders and capacity planning running. + New orders are still recorded, so no customer is turned away. Upgrade to keep reminders and capacity planning running. )} diff --git a/app/routes/app.freshness.tsx b/app/routes/app.freshness.tsx index 8df9507..bcd1db1 100644 --- a/app/routes/app.freshness.tsx +++ b/app/routes/app.freshness.tsx @@ -83,7 +83,7 @@ export default function Freshness() { return ( {result && {result.message}} - {!allowed && Tie bookable dates to when perishable stock arrives. See plans} + {!allowed && Tie bookable dates to when perishable stock arrives. See plans} 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. diff --git a/app/routes/app.peak.tsx b/app/routes/app.peak.tsx index 12058ff..a3c071b 100644 --- a/app/routes/app.peak.tsx +++ b/app/routes/app.peak.tsx @@ -68,7 +68,7 @@ export default function Peak() { return ( {result && {result.message}} - {!allowed && Separate limits, earlier cutoffs and waitlists for festival days. See plans} + {!allowed && Separate limits, earlier cutoffs and waitlists for festival days. See plans} 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. diff --git a/app/routes/app.plans.tsx b/app/routes/app.plans.tsx new file mode 100644 index 0000000..764e88e --- /dev/null +++ b/app/routes/app.plans.tsx @@ -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(); + const current = PLAN_COPY.find((p) => p.id === plan) ?? PLAN_COPY[0]; + return ( + + Change plan + + + You're on the {current.name} plan. Paid plans include a 14-day free trial; development stores are free. + + + + + {PLAN_COPY.map((p) => ( + + + {p.name} + {p.price} + + {p.points.map((x) => {x})} + + {plan === p.id ? Current plan : null} + + + ))} + + + + ); +} + +export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs); diff --git a/app/routes/app.runsheet.tsx b/app/routes/app.runsheet.tsx index aeb402c..de92e43 100644 --- a/app/routes/app.runsheet.tsx +++ b/app/routes/app.runsheet.tsx @@ -58,7 +58,7 @@ export default function RunSheet() { return ( {d.allowed && Download CSV} - {!d.allowed && Run sheets and route links. See plans} + {!d.allowed && Run sheets and route links. See plans}
diff --git a/app/routes/app.settings.tsx b/app/routes/app.settings.tsx index 5ba42f1..9633a11 100644 --- a/app/routes/app.settings.tsx +++ b/app/routes/app.settings.tsx @@ -1,26 +1,16 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; -import { useEffect } from "react"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import prisma from "../db.server"; import type { ReminderPolicy } from "../engine/types"; 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 { encrypt } from "../services/crypto.server"; -import { PLAN_COPY } from "../services/plans"; import { type AppSettings, withDefaults } from "../services/settings"; import { type CustomizationKind, findCustomizations, findGuard, gql, setCustomization, setGuard } from "../lib/guard.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const { cfg, admin, billing } = 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 { cfg, admin } = await adminContext(request); const guard = await findGuard(admin).catch(() => null); const custom = await findCustomizations(admin).catch(() => null); const s = cfg.settings; @@ -35,7 +25,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } const off = { deployed: false, enabled: false }; 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 } }, guard: { deployed: !!guard?.fn, enabled: !!guard?.validation?.enabled }, custom: { @@ -46,7 +35,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; 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 intent = formString(fd, "intent"); 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); }; - 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") { const turnOn = formBool(fd, "on"); 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() { - const { plan, s, guard, custom } = useLoaderData(); + const { s, guard, custom } = useLoaderData(); const result = useActionData(); const busy = useNavigation().state !== "idle"; - const confirmationUrl = result && "confirmationUrl" in result ? result.confirmationUrl : null; - useEffect(() => { - if (confirmationUrl) window.open(confirmationUrl, "_top"); - }, [confirmationUrl]); return ( {result && {result.message}} @@ -212,7 +175,7 @@ export default function Settings() { intent="custom:delivery" state={custom.delivery} 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." /> - - {PLAN_COPY.map((p) => ( - - - {p.name} - {p.price} - - {p.points.map((x) => {x})} - - {plan === p.id ? ( - Current plan - ) : ( - - - {p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`} - - )} - - - ))} - + See plans ); diff --git a/app/routes/app.tsx b/app/routes/app.tsx index bb0caae..98851a2 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -2,10 +2,41 @@ import type { HeadersFunction, LoaderFunctionArgs } from "react-router"; import { Outlet, useLoaderData, useRouteError } from "react-router"; import { boundary } from "@shopify/shopify-app-react-router/server"; import { AppProvider } from "@shopify/shopify-app-react-router/react"; +import prisma from "../db.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) => { - 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> | 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 || "" }; }; @@ -23,6 +54,7 @@ export default function App() { Driver run sheet Why unavailable? Settings + Plans diff --git a/app/routes/webhooks.app.tsx b/app/routes/webhooks.app.tsx index aba53ed..449690a 100644 --- a/app/routes/webhooks.app.tsx +++ b/app/routes/webhooks.app.tsx @@ -1,6 +1,9 @@ import type { ActionFunctionArgs } from "react-router"; import prisma from "../db.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 @@ -10,11 +13,22 @@ import { authenticate } from "../shopify.server"; export const action = async ({ request }: ActionFunctionArgs) => { const { shop, topic, session, payload } = await authenticate.webhook(request); if (topic === "APP_UNINSTALLED") { + forgetSubscriptions(); 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.hold.deleteMany({ where: { shop: { domain: shop } } }); 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) { const current = (payload as { current?: string[] }).current; if (current) await prisma.session.update({ where: { id: session.id }, data: { scope: current.toString() } }); diff --git a/app/services/plans.ts b/app/services/plans.ts index 657cbf9..73b16c5 100644 --- a/app/services/plans.ts +++ b/app/services/plans.ts @@ -1,6 +1,6 @@ /** - * Feature gates per billing plan. "free" = no active subscription. - * Keep in sync with the App Store listing and shopify.server.ts billing config. + * Feature gates per billing plan. "free" = Starter or no active subscription. + * Prices live in the Partner Dashboard (managed pricing); keep plan handles and copy in sync with it. */ export interface Features { maxLocations: number; @@ -25,6 +25,14 @@ export function features(plan: string): Features { 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 = [ { 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"] }, diff --git a/app/shopify.server.ts b/app/shopify.server.ts index db2aa49..1d8167d 100644 --- a/app/shopify.server.ts +++ b/app/shopify.server.ts @@ -2,17 +2,12 @@ import "@shopify/shopify-app-react-router/adapters/node"; import { ApiVersion, AppDistribution, - BillingInterval, shopifyApp, } from "@shopify/shopify-app-react-router/server"; import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma"; import prisma from "./db.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({ apiKey: process.env.SHOPIFY_API_KEY, apiSecretKey: process.env.SHOPIFY_API_SECRET || "", @@ -23,16 +18,6 @@ const shopify = shopifyApp({ sessionStorage: new PrismaSessionStorage(prisma), distribution: AppDistribution.AppStore, 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: { afterAuth: async ({ session, admin }) => { await ensureShop(session.shop, admin); diff --git a/docker-compose.yml b/docker-compose.yml index b272846..ebb521c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ x-app-env: &app-env GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} RESEND_API_KEY: ${RESEND_API_KEY:-} 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: db: diff --git a/extensions/checkout-time/package-lock.json b/extensions/checkout-time/package-lock.json new file mode 100644 index 0000000..cff29f8 --- /dev/null +++ b/extensions/checkout-time/package-lock.json @@ -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" + } + } + } +} diff --git a/extensions/checkout-time/package.json b/extensions/checkout-time/package.json new file mode 100644 index 0000000..380101b --- /dev/null +++ b/extensions/checkout-time/package.json @@ -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" + } +} diff --git a/extensions/checkout-time/shopify.extension.toml b/extensions/checkout-time/shopify.extension.toml new file mode 100644 index 0000000..4a8497d --- /dev/null +++ b/extensions/checkout-time/shopify.extension.toml @@ -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" diff --git a/extensions/checkout-time/src/TimeNote.jsx b/extensions/checkout-time/src/TimeNote.jsx new file mode 100644 index 0000000..34ab87a --- /dev/null +++ b/extensions/checkout-time/src/TimeNote.jsx @@ -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(, 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 ( + + {heading ? {when} : null} + + ); +} diff --git a/shopify.app.toml b/shopify.app.toml index 8e221a0..c546042 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -21,7 +21,7 @@ redirect_urls = ["https://datewise.metatronlabz.com/auth/callback"] api_version = "2026-01" [[webhooks.subscriptions]] - topics = ["app/uninstalled", "app/scopes_update"] + topics = ["app/uninstalled", "app/scopes_update", "app_subscriptions/update"] uri = "/webhooks/app" [[webhooks.subscriptions]]