feat: delivery-label and payment-rules checkout functions with Settings toggles
- delivery-label renames only options matching the chosen method (en/fr dates) - payment-rules hides COD / pay-in-store for shipped orders - functions matched by handle (fixes guard lookup matching any 'datewise' title) - scopes: write_delivery_customizations, write_payment_customizations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
67b86cf411
commit
85ed1a8f0e
@ -2,7 +2,7 @@
|
||||
SHOPIFY_API_KEY=
|
||||
SHOPIFY_API_SECRET=
|
||||
SHOPIFY_APP_URL=https://your-app.example.com
|
||||
SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations
|
||||
SCOPES=read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations
|
||||
|
||||
# PostgreSQL 14+ (row locks are required for capacity holds; SQLite is not supported)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/datewise
|
||||
|
||||
@ -56,6 +56,8 @@ app/routes/webhooks.* orders/create, cancelled, fulfilled, app/uninstalled
|
||||
app/jobs/worker.ts background worker
|
||||
extensions/datewise-widget theme app extension (cart block + app embed for drawers), en/fr
|
||||
extensions/checkout-guard validation function (JS) + tests
|
||||
extensions/delivery-label delivery customization: chosen date/time on the checkout option (JS) + tests
|
||||
extensions/payment-rules payment customization: hide pay-in-person for shipping (JS) + tests
|
||||
extensions/order-reschedule customer account UI extension (Preact)
|
||||
prisma/ schema + SQL migration
|
||||
prototype/ the clickable demo (runs the real engine)
|
||||
@ -96,6 +98,8 @@ Then, in the dev store:
|
||||
| `read_customers` | verify that the customer rescheduling actually owns the order |
|
||||
| `read_locations`, `read_inventory` | link Shopify locations; stock-aware location choice |
|
||||
| `write_validations` | switch the checkout guard on and off |
|
||||
| `write_delivery_customizations` | show the chosen date and time on the checkout delivery option |
|
||||
| `write_payment_customizations` | hide pay-in-person methods for shipped orders |
|
||||
|
||||
Customer names, emails, phones and addresses are **protected customer data**. Request Level 2 access in the Partner Dashboard before review.
|
||||
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
/** Checkout guard (cart & checkout validation function) lookup, shared by Settings and the Today setup guide. */
|
||||
/**
|
||||
* Datewise's checkout functions and their activation records, shared by Settings and the Today setup guide.
|
||||
* - checkout guard → a cart & checkout validation
|
||||
* - delivery label → a delivery customization
|
||||
* - payment rules → a payment customization
|
||||
* Functions are matched by extension handle, never by title, so one can't be mistaken for another.
|
||||
*/
|
||||
export const GUARD_HANDLE = "datewise-checkout-guard";
|
||||
export const DELIVERY_LABEL_HANDLE = "datewise-delivery-label";
|
||||
export const PAYMENT_RULES_HANDLE = "datewise-payment-rules";
|
||||
|
||||
export type AdminGraphql = { graphql: (q: string, o?: { variables?: Record<string, unknown> }) => Promise<Response> };
|
||||
|
||||
@ -8,16 +16,83 @@ export async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<
|
||||
return ((await r.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
type Fn = { id: string; handle: string };
|
||||
type Activation = { id: string; enabled: boolean; shopifyFunction: { id: string } };
|
||||
|
||||
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 } }
|
||||
const d = await gql<{ shopifyFunctions: { nodes: Fn[] }; validations: { nodes: Activation[] } }>(admin, `#graphql
|
||||
query DatewiseGuard {
|
||||
shopifyFunctions(first: 50) { nodes { id handle } }
|
||||
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 fn = d.shopifyFunctions.nodes.find((f) => f.handle === GUARD_HANDLE);
|
||||
const validation = fn ? d.validations.nodes.find((v) => v.shopifyFunction.id === fn.id) : undefined;
|
||||
return { fn, validation };
|
||||
}
|
||||
|
||||
export type CustomizationKind = "delivery" | "payment";
|
||||
export type CustomizationState = { deployed: boolean; enabled: boolean; id?: string };
|
||||
|
||||
/** Separate from findGuard: needs the delivery/payment customization scopes, which may not be granted yet. */
|
||||
export async function findCustomizations(admin: AdminGraphql): Promise<Record<CustomizationKind, CustomizationState>> {
|
||||
const d = await gql<{
|
||||
shopifyFunctions: { nodes: Fn[] };
|
||||
deliveryCustomizations: { nodes: Activation[] };
|
||||
paymentCustomizations: { nodes: Activation[] };
|
||||
}>(admin, `#graphql
|
||||
query DatewiseCustomizations {
|
||||
shopifyFunctions(first: 50) { nodes { id handle } }
|
||||
deliveryCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } }
|
||||
paymentCustomizations(first: 25) { nodes { id enabled shopifyFunction { id } } }
|
||||
}`);
|
||||
const state = (handle: string, list: Activation[]): CustomizationState => {
|
||||
const fn = d.shopifyFunctions.nodes.find((f) => f.handle === handle);
|
||||
const act = fn ? list.find((c) => c.shopifyFunction.id === fn.id) : undefined;
|
||||
return { deployed: !!fn, enabled: !!act?.enabled, id: act?.id };
|
||||
};
|
||||
return {
|
||||
delivery: state(DELIVERY_LABEL_HANDLE, d.deliveryCustomizations.nodes),
|
||||
payment: state(PAYMENT_RULES_HANDLE, d.paymentCustomizations.nodes),
|
||||
};
|
||||
}
|
||||
|
||||
const CUSTOMIZATION = {
|
||||
delivery: {
|
||||
handle: DELIVERY_LABEL_HANDLE,
|
||||
title: "Datewise: show chosen date and time",
|
||||
create: `#graphql
|
||||
mutation DeliveryLabelCreate($input: DeliveryCustomizationInput!) {
|
||||
deliveryCustomizationCreate(deliveryCustomization: $input) { deliveryCustomization { id } userErrors { message } }
|
||||
}`,
|
||||
update: `#graphql
|
||||
mutation DeliveryLabelUpdate($id: ID!, $input: DeliveryCustomizationInput!) {
|
||||
deliveryCustomizationUpdate(id: $id, deliveryCustomization: $input) { userErrors { message } }
|
||||
}`,
|
||||
},
|
||||
payment: {
|
||||
handle: PAYMENT_RULES_HANDLE,
|
||||
title: "Datewise: hide pay-in-person for shipping",
|
||||
create: `#graphql
|
||||
mutation PaymentRulesCreate($input: PaymentCustomizationInput!) {
|
||||
paymentCustomizationCreate(paymentCustomization: $input) { paymentCustomization { id } userErrors { message } }
|
||||
}`,
|
||||
update: `#graphql
|
||||
mutation PaymentRulesUpdate($id: ID!, $input: PaymentCustomizationInput!) {
|
||||
paymentCustomizationUpdate(id: $id, paymentCustomization: $input) { userErrors { message } }
|
||||
}`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Turns a customization on or off, creating its activation record the first time. Returns an error message or null. */
|
||||
export async function setCustomization(admin: AdminGraphql, kind: CustomizationKind, on: boolean): Promise<string | null> {
|
||||
const current = (await findCustomizations(admin))[kind];
|
||||
if (!current.deployed) return "This feature isn't deployed yet. Run `shopify app deploy`.";
|
||||
const c = CUSTOMIZATION[kind];
|
||||
type Res = Record<string, { userErrors: { message: string }[] }>;
|
||||
let res: Res;
|
||||
if (current.id) res = await gql<Res>(admin, c.update, { id: current.id, input: { enabled: on } });
|
||||
else if (on) res = await gql<Res>(admin, c.create, { input: { functionHandle: c.handle, title: c.title, enabled: true } });
|
||||
else return null;
|
||||
const errors = Object.values(res ?? {}).flatMap((x) => x?.userErrors ?? []);
|
||||
return errors.length ? errors.map((e) => e.message).join(" ") : null;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import { encrypt } from "../services/crypto.server";
|
||||
import { PLAN_COPY } from "../services/plans";
|
||||
import { type AppSettings, withDefaults } from "../services/settings";
|
||||
|
||||
import { GUARD_HANDLE, findGuard, gql } from "../lib/guard.server";
|
||||
import { type CustomizationKind, GUARD_HANDLE, findCustomizations, findGuard, gql, setCustomization } from "../lib/guard.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { cfg, admin, billing } = await adminContext(request);
|
||||
@ -18,11 +18,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
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(() => ({ fn: undefined, validation: undefined }));
|
||||
const off = { deployed: false, enabled: false };
|
||||
const custom = await findCustomizations(admin).catch(() => ({ delivery: off, payment: off }));
|
||||
const s = cfg.settings;
|
||||
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: { delivery: { deployed: custom.delivery.deployed, enabled: custom.delivery.enabled }, payment: { deployed: custom.payment.deployed, enabled: custom.payment.enabled } },
|
||||
};
|
||||
};
|
||||
|
||||
@ -68,6 +71,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
return { ok: true, message: turnOn ? "Checkout guard is on" : "Checkout guard is off" };
|
||||
}
|
||||
|
||||
if (intent === "custom:delivery" || intent === "custom:payment") {
|
||||
const kind = intent.slice(7) as CustomizationKind;
|
||||
const on = formBool(fd, "on");
|
||||
const error = await setCustomization(admin, kind, on).catch((e: Error) => e.message);
|
||||
if (error) return { ok: false, message: error };
|
||||
const name = kind === "delivery" ? "Date and time at checkout" : "Payment rules";
|
||||
return { ok: true, message: `${name} ${on ? "on" : "off"}` };
|
||||
}
|
||||
|
||||
if (intent === "policy") {
|
||||
s.holdMinutes = Math.min(60, Math.max(3, formInt(fd, "holdMinutes") ?? 10));
|
||||
s.mixedPrepPolicy = formString(fd, "mixedPrepPolicy") === "sum" ? "sum" : "longest";
|
||||
@ -152,7 +164,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const { plan, s, guard } = useLoaderData<typeof loader>();
|
||||
const { plan, s, guard, custom } = useLoaderData<typeof loader>();
|
||||
const result = useActionData<typeof action>();
|
||||
const busy = useNavigation().state !== "idle";
|
||||
return (
|
||||
@ -175,6 +187,24 @@ export default function Settings() {
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Checkout display">
|
||||
<s-stack gap="base">
|
||||
<CheckoutToggle
|
||||
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."
|
||||
/>
|
||||
<s-divider />
|
||||
<CheckoutToggle
|
||||
intent="custom:payment"
|
||||
state={custom.payment}
|
||||
title="Hide pay-in-person methods for shipped orders"
|
||||
body="Hides manual methods such as Cash on Delivery or Pay in store when the customer chose shipping. Pickup and local delivery keep every method."
|
||||
/>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Booking policies">
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="policy" />
|
||||
@ -314,3 +344,22 @@ export default function Settings() {
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutToggle({ intent, state, title, body }: { intent: string; state: { deployed: boolean; enabled: boolean }; title: string; body: string }) {
|
||||
return (
|
||||
<s-grid gridTemplateColumns="1fr auto" gap="base" alignItems="center">
|
||||
<s-stack gap="small-200">
|
||||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||||
<s-text type="strong">{title}</s-text>
|
||||
<s-badge tone={state.enabled ? "success" : undefined}>{state.enabled ? "On" : state.deployed ? "Off" : "Not deployed"}</s-badge>
|
||||
</s-stack>
|
||||
<s-paragraph color="subdued">{body}</s-paragraph>
|
||||
</s-stack>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value={intent} />
|
||||
<input type="hidden" name="on" value={state.enabled ? "false" : "true"} />
|
||||
<s-button type="submit" disabled={!state.deployed}>{state.enabled ? "Turn off" : "Turn on"}</s-button>
|
||||
</Form>
|
||||
</s-grid>
|
||||
);
|
||||
}
|
||||
|
||||
16
extensions/delivery-label/package.json
Normal file
16
extensions/delivery-label/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "datewise-delivery-label",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"shopify": "npm exec -- shopify",
|
||||
"typegen": "npm exec -- shopify app function typegen",
|
||||
"build": "npm exec -- shopify app function build",
|
||||
"preview": "npm exec -- shopify app function run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify_function": "^2.0.1"
|
||||
}
|
||||
}
|
||||
16
extensions/delivery-label/shopify.extension.toml
Normal file
16
extensions/delivery-label/shopify.extension.toml
Normal file
@ -0,0 +1,16 @@
|
||||
api_version = "2026-01"
|
||||
|
||||
[[extensions]]
|
||||
name = "Datewise delivery label"
|
||||
handle = "datewise-delivery-label"
|
||||
type = "function"
|
||||
description = "Shows the customer's chosen pickup or delivery date and time on the matching checkout delivery option."
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "cart.delivery-options.transform.run"
|
||||
input_query = "src/cart_delivery_options_transform_run.graphql"
|
||||
export = "cart-delivery-options-transform-run"
|
||||
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
@ -0,0 +1,19 @@
|
||||
query CartDeliveryOptionsTransformRunInput {
|
||||
cart {
|
||||
selection: attribute(key: "_datewise") {
|
||||
value
|
||||
}
|
||||
deliveryGroups {
|
||||
deliveryOptions {
|
||||
handle
|
||||
title
|
||||
deliveryMethodType
|
||||
}
|
||||
}
|
||||
}
|
||||
localization {
|
||||
language {
|
||||
isoCode
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Datewise delivery label (Delivery Customization Function, all Shopify plans).
|
||||
*
|
||||
* Checkout only shows the merchant's generic option names ("Local Delivery", "Pickup at Kitchener").
|
||||
* This appends the date and time the customer chose in the cart, so the order summary and the
|
||||
* confirmation say exactly when, e.g. "Local Delivery — Wed, Nov 7, 10 AM–1 PM".
|
||||
*
|
||||
* Only options of the chosen method's type are renamed, so a store that offers both "Standard" and
|
||||
* "Express" shipping keeps them distinct, and a pickup choice never relabels a shipping rate.
|
||||
* Functions can't use Intl reliably, so dates are formatted here (English and French, like the widget).
|
||||
*/
|
||||
|
||||
/** Datewise method → checkout delivery method types it corresponds to. */
|
||||
const METHOD_TYPES = /** @type {Record<string, string[]>} */ ({
|
||||
pickup: ["PICK_UP"],
|
||||
delivery: ["LOCAL"],
|
||||
shipping: ["SHIPPING"],
|
||||
});
|
||||
|
||||
const WORDS = /** @type {Record<string, { days: string[]; months: string[]; ships: string }>} */ ({
|
||||
en: {
|
||||
days: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
ships: "ships",
|
||||
},
|
||||
fr: {
|
||||
days: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
|
||||
months: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
|
||||
ships: "expédié le",
|
||||
},
|
||||
});
|
||||
|
||||
/** @param {string} date YYYY-MM-DD @param {string} lang */
|
||||
export function formatDate(date, lang) {
|
||||
const w = WORDS[lang] ?? WORDS.en;
|
||||
const [y, m, d] = date.split("-").map(Number);
|
||||
// Sakamoto's day-of-week algorithm: no Date objects needed.
|
||||
const t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
|
||||
const yy = m < 3 ? y - 1 : y;
|
||||
const dow = (yy + Math.floor(yy / 4) - Math.floor(yy / 100) + Math.floor(yy / 400) + t[m - 1] + d) % 7;
|
||||
return lang === "fr" ? `${w.days[dow]} ${d} ${w.months[m - 1]}` : `${w.days[dow]}, ${w.months[m - 1]} ${d}`;
|
||||
}
|
||||
|
||||
/** @param {string} hhmm @param {string} lang */
|
||||
export function formatTime(hhmm, lang) {
|
||||
const [h, min] = hhmm.split(":").map(Number);
|
||||
if (lang === "fr") return `${h} h${min ? String(min).padStart(2, "0") : ""}`;
|
||||
const suffix = h < 12 ? "AM" : "PM";
|
||||
const h12 = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${h12}${min ? ":" + String(min).padStart(2, "0") : ""} ${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} raw the `_datewise` cart attribute
|
||||
* @returns {{ method: string; date: string; start: string; end: string } | null}
|
||||
*/
|
||||
export function parseSelection(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const s = JSON.parse(raw);
|
||||
if (typeof s?.m !== "string" || !METHOD_TYPES[s.m]) return null;
|
||||
if (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] : "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ method: string; date: string; start: string; end: string }} sel
|
||||
* @param {string} title the option's current title
|
||||
* @param {string} lang
|
||||
*/
|
||||
export function labelFor(sel, title, lang) {
|
||||
const w = WORDS[lang] ?? WORDS.en;
|
||||
const date = formatDate(sel.date, lang);
|
||||
if (sel.method === "shipping") return `${title} (${w.ships} ${date})`;
|
||||
const time = sel.start && sel.end ? `, ${formatTime(sel.start, lang)}–${formatTime(sel.end, lang)}` : "";
|
||||
return `${title} — ${date}${time}`;
|
||||
}
|
||||
|
||||
/** @param {any} input */
|
||||
export function cartDeliveryOptionsTransformRun(input) {
|
||||
const sel = parseSelection(input.cart?.selection?.value);
|
||||
if (!sel) return { operations: [] };
|
||||
const lang = String(input.localization?.language?.isoCode ?? "EN").slice(0, 2).toLowerCase();
|
||||
const types = METHOD_TYPES[sel.method];
|
||||
const operations = (input.cart?.deliveryGroups ?? []).flatMap((/** @type {any} */ g) =>
|
||||
(g.deliveryOptions ?? [])
|
||||
.filter((/** @type {any} */ o) => types.includes(o.deliveryMethodType))
|
||||
.map((/** @type {any} */ o) => ({
|
||||
deliveryOptionRename: { deliveryOptionHandle: o.handle, title: labelFor(sel, o.title ?? "", lang) },
|
||||
})),
|
||||
);
|
||||
return { operations };
|
||||
}
|
||||
1
extensions/delivery-label/src/index.js
Normal file
1
extensions/delivery-label/src/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from "./cart_delivery_options_transform_run";
|
||||
53
extensions/delivery-label/tests/delivery-label.test.js
Normal file
53
extensions/delivery-label/tests/delivery-label.test.js
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cartDeliveryOptionsTransformRun as run, formatDate, formatTime } from "../src/cart_delivery_options_transform_run.js";
|
||||
|
||||
const sel = (o = {}) => JSON.stringify({ t: "tok", m: "delivery", l: "kit", d: "2026-11-07", s: "10:00-13:00", f: 0, ...o });
|
||||
const options = [
|
||||
{ handle: "local", title: "Local Delivery", deliveryMethodType: "LOCAL" },
|
||||
{ handle: "std", title: "Standard", deliveryMethodType: "SHIPPING" },
|
||||
{ handle: "exp", title: "Express", deliveryMethodType: "SHIPPING" },
|
||||
{ handle: "pick", title: "Kitchener — Guelph St", deliveryMethodType: "PICK_UP" },
|
||||
];
|
||||
const input = (raw, lang = "EN") => ({
|
||||
cart: { selection: raw == null ? null : { value: raw }, deliveryGroups: [{ deliveryOptions: options }] },
|
||||
localization: { language: { isoCode: lang } },
|
||||
});
|
||||
const renames = (r) => r.operations.map((o) => [o.deliveryOptionRename.deliveryOptionHandle, o.deliveryOptionRename.title]);
|
||||
|
||||
describe("delivery label", () => {
|
||||
it("formats dates without Intl, including weekday", () => {
|
||||
expect(formatDate("2026-11-07", "en")).toBe("Sat, Nov 7");
|
||||
expect(formatDate("2024-02-29", "en")).toBe("Thu, Feb 29");
|
||||
expect(formatDate("2026-01-01", "fr")).toBe("jeu. 1 janv.");
|
||||
});
|
||||
it("formats times in 12 h English and 24 h French", () => {
|
||||
expect(formatTime("00:00", "en")).toBe("12 AM");
|
||||
expect(formatTime("12:30", "en")).toBe("12:30 PM");
|
||||
expect(formatTime("13:00", "en")).toBe("1 PM");
|
||||
expect(formatTime("09:05", "fr")).toBe("9 h05");
|
||||
});
|
||||
it("does nothing without a selection or with a malformed one", () => {
|
||||
expect(run(input(null)).operations).toEqual([]);
|
||||
expect(run(input("{not json")).operations).toEqual([]);
|
||||
expect(run(input(sel({ m: "teleport" }))).operations).toEqual([]);
|
||||
expect(run(input(sel({ d: "07/11/2026" }))).operations).toEqual([]);
|
||||
});
|
||||
it("renames only local-delivery options for a delivery choice", () => {
|
||||
expect(renames(run(input(sel())))).toEqual([["local", "Local Delivery — Sat, Nov 7, 10 AM–1 PM"]]);
|
||||
});
|
||||
it("renames only pickup options for a pickup choice", () => {
|
||||
expect(renames(run(input(sel({ m: "pickup", s: "14:00-16:00" }))))).toEqual([["pick", "Kitchener — Guelph St — Sat, Nov 7, 2 PM–4 PM"]]);
|
||||
});
|
||||
it("keeps carrier names distinct and adds the ship date for shipping", () => {
|
||||
expect(renames(run(input(sel({ m: "shipping" }))))).toEqual([
|
||||
["std", "Standard (ships Sat, Nov 7)"],
|
||||
["exp", "Express (ships Sat, Nov 7)"],
|
||||
]);
|
||||
});
|
||||
it("uses the buyer's language", () => {
|
||||
expect(renames(run(input(sel(), "FR")))[0][1]).toBe("Local Delivery — sam. 7 nov., 10 h–13 h");
|
||||
});
|
||||
it("omits the time when the slot isn't a time range", () => {
|
||||
expect(renames(run(input(sel({ s: "*" }))))[0][1]).toBe("Local Delivery — Sat, Nov 7");
|
||||
});
|
||||
});
|
||||
16
extensions/payment-rules/package.json
Normal file
16
extensions/payment-rules/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "datewise-payment-rules",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"shopify": "npm exec -- shopify",
|
||||
"typegen": "npm exec -- shopify app function typegen",
|
||||
"build": "npm exec -- shopify app function build",
|
||||
"preview": "npm exec -- shopify app function run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify_function": "^2.0.1"
|
||||
}
|
||||
}
|
||||
16
extensions/payment-rules/shopify.extension.toml
Normal file
16
extensions/payment-rules/shopify.extension.toml
Normal file
@ -0,0 +1,16 @@
|
||||
api_version = "2026-01"
|
||||
|
||||
[[extensions]]
|
||||
name = "Datewise payment rules"
|
||||
handle = "datewise-payment-rules"
|
||||
type = "function"
|
||||
description = "Hides cash-on-delivery and pay-in-store payment methods when the customer chose shipping."
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "cart.payment-methods.transform.run"
|
||||
input_query = "src/cart_payment_methods_transform_run.graphql"
|
||||
export = "cart-payment-methods-transform-run"
|
||||
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
@ -0,0 +1,11 @@
|
||||
query CartPaymentMethodsTransformRunInput {
|
||||
cart {
|
||||
selection: attribute(key: "_datewise") {
|
||||
value
|
||||
}
|
||||
}
|
||||
paymentMethods {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Datewise payment rules (Payment Customization Function, all Shopify plans).
|
||||
*
|
||||
* A shipped order leaves before anyone can pay in person, so manual "cash on delivery" /
|
||||
* "pay in store" methods are hidden when the customer chose shipping. Pickup and local
|
||||
* delivery keep every method, and with no selection nothing changes.
|
||||
*/
|
||||
|
||||
/** Common names merchants give manual pay-later / in-person methods. */
|
||||
export const PAY_IN_PERSON = /cash on delivery|\bc\.?o\.?d\.?\b|pay on (delivery|pickup|collection)|pay in (store|person)|paiement à la livraison|paiement en magasin/i;
|
||||
|
||||
/** @param {string | null | undefined} raw the `_datewise` cart attribute */
|
||||
function method(raw) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const m = JSON.parse(raw)?.m;
|
||||
return typeof m === "string" ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {any} input */
|
||||
export function cartPaymentMethodsTransformRun(input) {
|
||||
if (method(input.cart?.selection?.value) !== "shipping") return { operations: [] };
|
||||
const operations = (input.paymentMethods ?? [])
|
||||
.filter((/** @type {any} */ p) => typeof p.name === "string" && PAY_IN_PERSON.test(p.name))
|
||||
.map((/** @type {any} */ p) => ({ paymentMethodHide: { paymentMethodId: p.id } }));
|
||||
return { operations };
|
||||
}
|
||||
1
extensions/payment-rules/src/index.js
Normal file
1
extensions/payment-rules/src/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from "./cart_payment_methods_transform_run";
|
||||
26
extensions/payment-rules/tests/payment-rules.test.js
Normal file
26
extensions/payment-rules/tests/payment-rules.test.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cartPaymentMethodsTransformRun as run } from "../src/cart_payment_methods_transform_run.js";
|
||||
|
||||
const methods = [
|
||||
{ id: "card", name: "Credit card" },
|
||||
{ id: "cod", name: "Cash on Delivery (COD)" },
|
||||
{ id: "store", name: "Pay in store" },
|
||||
{ id: "paypal", name: "PayPal" },
|
||||
];
|
||||
const input = (m) => ({ cart: { selection: m ? { value: JSON.stringify({ m, d: "2026-11-07", s: "10:00-13:00" }) } : null }, paymentMethods: methods });
|
||||
const hidden = (r) => r.operations.map((o) => o.paymentMethodHide.paymentMethodId);
|
||||
|
||||
describe("payment rules", () => {
|
||||
it("hides pay-in-person methods for shipping", () => expect(hidden(run(input("shipping")))).toEqual(["cod", "store"]));
|
||||
it("keeps every method for pickup and local delivery", () => {
|
||||
expect(hidden(run(input("pickup")))).toEqual([]);
|
||||
expect(hidden(run(input("delivery")))).toEqual([]);
|
||||
});
|
||||
it("changes nothing without a selection or with bad JSON", () => {
|
||||
expect(hidden(run(input(null)))).toEqual([]);
|
||||
expect(hidden(run({ cart: { selection: { value: "{x" } }, paymentMethods: methods }))).toEqual([]);
|
||||
});
|
||||
it("doesn't hide names that merely contain the letters cod", () => {
|
||||
expect(hidden(run({ ...input("shipping"), paymentMethods: [{ id: "x", name: "Codiac Pay" }] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -13,7 +13,7 @@ include_config_on_deploy = true
|
||||
|
||||
[access_scopes]
|
||||
# Each scope is used by a shipped feature — see README "Permissions".
|
||||
scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations"
|
||||
scopes = "read_products,write_products,write_publications,read_orders,write_orders,read_customers,read_locations,read_inventory,write_validations,write_delivery_customizations,write_payment_customizations"
|
||||
|
||||
[auth]
|
||||
redirect_urls = ["https://example.com/auth/callback"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user