diff --git a/pos-intercept/README.md.liquid b/pos-intercept/README.md.liquid new file mode 100644 index 00000000..386a88d9 --- /dev/null +++ b/pos-intercept/README.md.liquid @@ -0,0 +1,36 @@ +# {{ name }} + +Standalone cart acknowledgment example for a temporary POS intercept bug bash. The opt-in `pos_intercept` catalog entry selects `pos-intercept/`; the standard `pos_action` template and public `templates.json` catalog remain unchanged. + +## Setup + +- Use Shopify CLI 4.8.0 or later and the pinned `@shopify/ui-extensions@2026.10.0-rc.13` dependency, which includes `resolution.onSave`. The app must be approved for POS intercepts and the POS environment must be preview-enabled. Installing the CLI or SDK does not grant access. +- Keep the generated extension identity. `shopify.extension.toml` uses API `2026-10`, offline support, and the paired background/cart-resolution targets. Install dependencies and run `shopify app build` from your app root before testing in your authorized POS environment. + +Save `templates.pos-intercept.json` from this branch locally, either from a checkout or by downloading the file after publication. `SHOPIFY_CLI_APP_TEMPLATES_JSON_PATH` accepts a local file path, not a URL. It replaces the default catalog for that invocation; this file exposes only `pos_intercept`. + +After the branch has been published, use the installed `shopify` CLI to generate the extension into an existing app: + +```shell +SHOPIFY_CLI_APP_TEMPLATES_JSON_PATH="/absolute/path/to/extensions-templates/templates.pos-intercept.json" \ + shopify app generate extension --template pos_intercept --name pos-intercept-bugbash --clone-url "https://github.com/Shopify/extensions-templates#vctrchu/pos-intercept-bugbash" --path ./your-app-root +``` + +Do not pass `--flavor`; this custom catalog entry selects Preact. Keep the environment variable scoped to this command. `--clone-url` selects only the template repository and branch; it does not load the branch's `templates.json`. The remote command requires the branch to exist on GitHub. For an unpublished, locally committed branch, replace the clone URL with `file:///absolute/path/to/extensions-templates#vctrchu/pos-intercept-bugbash`. + +Generation still requires a linked app and App Management access. The custom catalog does not bypass authentication, app approval, or POS preview prerequisites, and does not approve or deploy the app. + +## Test in POS + +1. Start a fresh cart. An empty cart produces no finding. Add any product: the synchronous background callback returns one `ERROR` with handle `cart-review-required`, targeted at `$.cart`. Confirm checkout is blocked. +2. Select the cart validation banner. POS renders `pos.cart.validations.resolution.render`. +3. Review the cart, then use Shopify-owned **Save**, **Next**, or **Done**. POS waits for the registered `shopify.resolution.onSave` handler. +4. The handler awaits `shopify.cart.addCartProperties({example_cart_reviewed: 'true'})`, confirms the cart signal, and returns. POS then revalidates, and the background callback returns no finding for the acknowledged cart. +5. If saving fails, the target shows an error and the missing acknowledgment keeps the finding active. Use the POS control again to retry. +6. Add or change products after acknowledging the cart: this example does not require another review. Start a new cart to test the flow again. + +The resolution target registers one save handler on mount and unregisters it on unmount. `onSave` is not a completion API; POS waits for the handler and then revalidates. + +This is a generic staff acknowledgment **once per cart**, not product, per-unit, age or identity verification, or evidence of regulatory compliance. The `example_cart_reviewed` property persists through checkout, and later cart edits do not invalidate it. No identity or customer data is collected. Cash and payment validation are outside this example. + +A local generation, build, or simulated API smoke check does not verify the real POS banner, navigation, checkout blocking, or Shopify-owned revalidation controls. Those checks require the approved app in a preview-enabled POS environment. diff --git a/pos-intercept/locales/en.default.json.liquid b/pos-intercept/locales/en.default.json.liquid new file mode 100644 index 00000000..86f63999 --- /dev/null +++ b/pos-intercept/locales/en.default.json.liquid @@ -0,0 +1,8 @@ +{ + "name": "{{ name }}", + "heading": "Review cart", + "instructions": "Review the cart, then use Save, Next, or Done in POS to confirm and revalidate.", + "saved": "Cart review saved.", + "saveFailed": "Could not save the cart review. Try again.", + "saveUnavailable": "Could not prepare the cart review. Close and reopen this screen." +} diff --git a/pos-intercept/locales/fr.json.liquid b/pos-intercept/locales/fr.json.liquid new file mode 100644 index 00000000..e4e7c81f --- /dev/null +++ b/pos-intercept/locales/fr.json.liquid @@ -0,0 +1,8 @@ +{ + "name": "{{ name }}", + "heading": "Vérifier le panier", + "instructions": "Vérifiez le panier, puis utilisez Enregistrer, Suivant ou Terminé dans PDV pour confirmer et relancer la validation.", + "saved": "Vérification du panier enregistrée.", + "saveFailed": "Impossible d’enregistrer la vérification du panier. Réessayez.", + "saveUnavailable": "Impossible de préparer la vérification du panier. Fermez cet écran, puis rouvrez-le." +} diff --git a/pos-intercept/package.json.liquid b/pos-intercept/package.json.liquid new file mode 100644 index 00000000..c031d290 --- /dev/null +++ b/pos-intercept/package.json.liquid @@ -0,0 +1,11 @@ +{ + "name": "{{ handle }}", + "private": true, + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@shopify/ui-extensions": "2026.10.0-rc.13", + "preact": "^10.10.x", + "@preact/signals": "^2.3.x" + } +} diff --git a/pos-intercept/shopify.extension.toml.liquid b/pos-intercept/shopify.extension.toml.liquid new file mode 100644 index 00000000..44ff14cb --- /dev/null +++ b/pos-intercept/shopify.extension.toml.liquid @@ -0,0 +1,23 @@ +api_version = "2026-10" +[[extensions]] +type = "ui_extension" +# Change the merchant-facing name of the extension in locales/en.default.json +name = "t:name" +{% if uid %}uid = "{{ uid }}"{% endif %} +handle = "{{ handle }}" +description = "Cart intercept bug bash" + +[extensions.supported_features] +runs_offline = true + +[[extensions.targeting]] +module = "./src/Background.{{ srcFileExtension }}" +target = "pos.app.ready.data" + +[[extensions.targeting.capabilities.intercepts]] +event = "cartvalidations" +blocking = true + +[[extensions.targeting]] +module = "./src/CartResolution.{{ srcFileExtension }}" +target = "pos.cart.validations.resolution.render" diff --git a/pos-intercept/src/Background.liquid b/pos-intercept/src/Background.liquid new file mode 100644 index 00000000..d1b0d023 --- /dev/null +++ b/pos-intercept/src/Background.liquid @@ -0,0 +1,20 @@ +// @extension-target: pos.app.ready.data + +export default function extension() { + shopify.intercept('cartvalidations', ({cart}) => { + // Acknowledgment survives cart edits; invalidate it if edits must require another review. + const requiresReview = + cart.lineItems.length > 0 && + cart.properties.example_cart_reviewed !== 'true'; + + return { + operations: requiresReview ? [{ + validationAdd: { + level: 'ERROR', + handle: 'cart-review-required', + target: '$.cart', + }, + }] : [], + }; + }); +} diff --git a/pos-intercept/src/CartResolution.liquid b/pos-intercept/src/CartResolution.liquid new file mode 100644 index 00000000..f77a2bd9 --- /dev/null +++ b/pos-intercept/src/CartResolution.liquid @@ -0,0 +1,58 @@ +// @extension-target: pos.cart.validations.resolution.render +import "@shopify/ui-extensions/preact"; +import {render} from 'preact'; +import {useEffect, useState} from 'preact/hooks'; + +export default async () => { + render(, document.body); +}; + +function Extension() { + const {i18n} = shopify; + const [error, setError] = useState(''); + + useEffect(() => { + let active = true; + const registration = shopify.resolution.onSave(async () => { + if (!active) return; + setError(''); + + try { + await shopify.cart.addCartProperties({ + example_cart_reviewed: 'true', + }); + if (shopify.cart.current.value.properties.example_cart_reviewed !== 'true') { + throw new Error('Cart review was not saved.'); + } + shopify.toast.show(i18n.translate('saved')); + } catch (saveError) { + if (active) { + setError('saveFailed'); + shopify.toast.show(i18n.translate('saveFailed')); + } + throw saveError; + } + }); + + void registration.catch(() => { + if (active) setError('saveUnavailable'); + }); + + return () => { + active = false; + void registration.then((unregister) => unregister()).catch(console.error); + }; + }, []); + + return ( + + + {i18n.translate('heading')} + {i18n.translate('instructions')} + {error ? ( + + ) : undefined} + + + ); +} diff --git a/pos-intercept/tsconfig.json.liquid b/pos-intercept/tsconfig.json.liquid new file mode 100644 index 00000000..298288d4 --- /dev/null +++ b/pos-intercept/tsconfig.json.liquid @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "target": "ES2020", + "checkJs": true, + "allowJs": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["./src", "./shopify.d.ts"] +} diff --git a/templates.pos-intercept.json b/templates.pos-intercept.json new file mode 100644 index 00000000..6e2cc1fc --- /dev/null +++ b/templates.pos-intercept.json @@ -0,0 +1,20 @@ +[ + { + "identifier": "pos_intercept", + "name": "POS intercept (bug bash)", + "defaultName": "pos-intercept", + "group": "UI extensions", + "supportLinks": [], + "url": "https://github.com/Shopify/extensions-templates", + "type": "ui_extension", + "extensionPoints": [], + "supportedFlavors": [ + { + "name": "Preact", + "value": "preact", + "path": "pos-intercept" + } + ], + "minimumCliVersion": "4.8.0" + } +]