Skip to content
Draft
36 changes: 36 additions & 0 deletions pos-intercept/README.md.liquid
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions pos-intercept/locales/en.default.json.liquid
Original file line number Diff line number Diff line change
@@ -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."
}
8 changes: 8 additions & 0 deletions pos-intercept/locales/fr.json.liquid
Original file line number Diff line number Diff line change
@@ -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."
}
11 changes: 11 additions & 0 deletions pos-intercept/package.json.liquid
Original file line number Diff line number Diff line change
@@ -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"
}
}
23 changes: 23 additions & 0 deletions pos-intercept/shopify.extension.toml.liquid
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions pos-intercept/src/Background.liquid
Original file line number Diff line number Diff line change
@@ -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',
},
}] : [],
};
});
}
58 changes: 58 additions & 0 deletions pos-intercept/src/CartResolution.liquid
Original file line number Diff line number Diff line change
@@ -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(<Extension />, 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 (
<s-scroll-box padding="base">
<s-stack direction="block" gap="large">
<s-heading>{i18n.translate('heading')}</s-heading>
<s-text>{i18n.translate('instructions')}</s-text>
{error ? (
<s-banner tone="critical" heading={i18n.translate(error)} />
) : undefined}
</s-stack>
</s-scroll-box>
);
}
14 changes: 14 additions & 0 deletions pos-intercept/tsconfig.json.liquid
Original file line number Diff line number Diff line change
@@ -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"]
}
20 changes: 20 additions & 0 deletions templates.pos-intercept.json
Original file line number Diff line number Diff line change
@@ -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"
}
]