Shopify deletes an app's validation and delivery/payment customizations on uninstall. Datewise now remembers which ones were on (settings.checkoutFunctions, kept in sync with Shopify whenever Settings loads) and switches them back on when a shop reinstalls. LISTING.md and alt text updated to what the listing form accepted (5 features, 20-char search terms, 64-char alt text, no "plan" outside pricing). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
7.0 KiB
TypeScript
140 lines
7.0 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import type { AppSettings } from "../services/settings";
|
|
|
|
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> };
|
|
|
|
export async function gql<T>(admin: AdminGraphql, q: string, variables?: Record<string, unknown>) {
|
|
const r = await admin.graphql(q, { variables });
|
|
return ((await r.json()) as { data: T }).data;
|
|
}
|
|
|
|
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: 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.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 the checkout guard on or off, creating its validation the first time. Returns an error message or null. */
|
|
export async function setGuard(admin: AdminGraphql, on: boolean, requireScheduling: boolean): Promise<string | null> {
|
|
const { fn, validation } = await findGuard(admin);
|
|
if (!fn) return "The checkout guard isn't deployed yet. Run `shopify app deploy`.";
|
|
const metafields = [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling }) }];
|
|
type Res = Record<string, { userErrors: { message: string }[] }>;
|
|
let res: Res;
|
|
if (validation)
|
|
res = await gql<Res>(admin, `#graphql
|
|
mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, {
|
|
id: validation.id,
|
|
v: { enable: on, blockOnFailure: false, metafields },
|
|
});
|
|
else if (on)
|
|
res = await gql<Res>(admin, `#graphql
|
|
mutation($v: ValidationCreateInput!) { validationCreate(validation: $v) { userErrors { message } } }`, {
|
|
v: { functionHandle: GUARD_HANDLE, title: "Datewise checkout guard", enable: true, blockOnFailure: false, metafields },
|
|
});
|
|
else return null;
|
|
const errors = Object.values(res ?? {}).flatMap((x) => x?.userErrors ?? []);
|
|
return errors.length ? errors.map((e) => e.message).join(" ") : null;
|
|
}
|
|
|
|
/**
|
|
* Shopify deletes an app's validations and customizations on uninstall. On reinstall, switch back on
|
|
* the ones the merchant had on. Each is tried separately so one failure doesn't block the others.
|
|
*/
|
|
export async function restoreCheckoutFunctions(admin: AdminGraphql, settings: AppSettings) {
|
|
const { guard, delivery, payment } = settings.checkoutFunctions;
|
|
const jobs: [string, () => Promise<string | null>][] = [];
|
|
if (guard) jobs.push(["guard", () => setGuard(admin, true, settings.requireScheduling)]);
|
|
if (delivery) jobs.push(["delivery", () => setCustomization(admin, "delivery", true)]);
|
|
if (payment) jobs.push(["payment", () => setCustomization(admin, "payment", true)]);
|
|
for (const [name, run] of jobs) {
|
|
const error = await run().catch((e: Error) => e.message);
|
|
if (error) console.error(`[datewise] restore ${name} failed: ${error}`);
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|