Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pos-intercept-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/ui-extensions': minor
---

Add `shopify.intercept()` types for POS blocking workflows.
6 changes: 6 additions & 0 deletions .changeset/pos-resolution-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/ui-extensions': minor
'@shopify/ui-extensions-tester': minor
---

Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes `ActionTargetApi` (standard API + scanner), a write-capable live Cart API, and read-only navigation (`currentEntry` only — `navigate`/`back` throw host-side). The app identifies which validation to resolve by reading the handle from the seeded navigation URL (`navigation.currentEntry.url`), and re-runs its own interceptor validation function against the live `cart.current` to regenerate the violation details.
31 changes: 31 additions & 0 deletions packages/ui-extensions-tester/src/point-of-sale/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
CashTrackingSessionCompleteData,
CartUpdateEventData,
Money,
ReadonlyNavigationApi,
} from '@shopify/ui-extensions/point-of-sale';

import {createReadonlySignalLike} from '../mocks/signals';
Expand Down Expand Up @@ -256,6 +257,22 @@ function createMockCashDrawerApi(): CashDrawerApi {
return {cashDrawer: {open: async () => {}}};
}

function createMockReadonlyNavigationApi(): ReadonlyNavigationApi {
return {
navigation: {
currentEntry: {
key: 'mock-key',
// The URL is seeded as `/{handle}` by the host. Tests can override
// this by constructing their own mock and replacing `navigation`.
url: '/mock-handle',
getState: () => null,
},
addEventListener: () => {},
removeEventListener: () => {},
},
};
}

// ---------------------------------------------------------------------------
// Group factory functions — each composes the correct API for a target group
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -449,6 +466,17 @@ function createActionTargetCashDrawerMock<T extends RenderExtensionTarget>(
};
}

// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi
function createResolutionTargetMock<T extends RenderExtensionTarget>(
target: T,
): ActionTargetApi<T> & CartApi & ReadonlyNavigationApi {
return {
...createMockActionTargetApi(target),
...createMockCartApi(),
...createMockReadonlyNavigationApi(),
};
}

// Data target factories
function createDataTargetMock<T extends ExtensionTarget>(
target: T,
Expand Down Expand Up @@ -618,6 +646,9 @@ const posMockFactories: PosMockFactory = {
// Group Q: ActionTargetApi + CashDrawerApi
'pos.register-details.action.render': createActionTargetCashDrawerMock,

// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi
'pos.resolution.action.render': createResolutionTargetMock,

// Data targets
'pos.app.ready.data': createDataTargetMock,

Expand Down
8 changes: 8 additions & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export type {
ExtensionApiContent,
} from './api/extension-api/extension-api';
export type {ActionTargetApi} from './api/action-target-api/action-target-api';
export type {
ReadonlyNavigation,
ReadonlyNavigationApi,
Navigation,
NavigationNavigateOptions,
NavigationHistoryEntry,
NavigationCurrentEntryChangeEvent,
} from './api/navigation-api/navigation-api';
export type {DataTargetApi} from './api/data-target-api/data-target-api';
export type {
TransactionCompleteEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,36 @@ export interface Navigation {
}

/**
* The global `window` object provides control over the extension modal lifecycle. Access these properties and methods directly through the global `window` object to manage the modal interface programmatically.
* Read-only view of the `Navigation` object, for extension targets that must not
* navigate programmatically (e.g. `pos.resolution.action.render`).
*
* This is a `Pick` of `Navigation` rather than a redeclaration, so it stays in
* sync automatically as `Navigation` evolves: any member added to `Navigation`
* is excluded here by default, which is the safe direction for a read-only view.
*
* `navigate` and `back` are omitted. The host rejects navigation writes at
* runtime regardless of the type (see Shopify/extensibility#1586); this type
* exists so the omission is visible to app developers in autocomplete.
*
* Note: `currentEntry` intentionally keeps the same type it has on `Navigation`.
* The RPC bridge already makes signals read-only guest-side, so no separate
* read-only entry type is needed.
*
* @publicDocs
*/
export interface Window {
/**
* Closes the extension screen and dismisses the modal interface. Use to programmatically close the modal after completing a workflow, canceling an operation, or when user action is no longer required. This provides the same behavior as the user dismissing the modal through the UI.
*/
close(): void;
export type ReadonlyNavigation = Pick<
Navigation,
'currentEntry' | 'addEventListener' | 'removeEventListener'
>;

/**
* Provides read-only navigation for targets that cannot navigate
* programmatically. Follows the same shape convention as `CartApi` /
* `ReadonlyCartApi`.
* @publicDocs
*/
export interface ReadonlyNavigationApi {
navigation: ReadonlyNavigation;
}

/**
Expand Down
94 changes: 94 additions & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CashTrackingSessionStartEvent,
CashTrackingSessionCompleteEvent,
} from './events/cash-tracking-session-events';
import type {Cart} from './types/cart';

/**
* Canonical event-name constants for POS host events. Prefer these over string
Expand All @@ -16,6 +17,16 @@ export const POS_EVENT_NAMES = {
CASH_TRACKING_SESSION_COMPLETE: 'cashtrackingsessioncomplete',
} as const;

/**
* Canonical workflow-name constants for POS host interceptions. Prefer these
* over string literals when calling `shopify.intercept`.
*
* @publicDocs
*/
export const POS_INTERCEPT_NAMES = {
BEFORE_CHECKOUT: 'beforecheckout',
} as const;

/**
* Maps Shopify POS event names to their corresponding `Event` subclass types.
*
Expand All @@ -30,6 +41,89 @@ export interface ShopifyEventMap {
[POS_EVENT_NAMES.CASH_TRACKING_SESSION_COMPLETE]: CashTrackingSessionCompleteEvent;
}

/**
* Maps POS interceptable workflow names to their corresponding `Event` types.
*
* Used as the generic type parameter for `shopify.intercept`.
*
* @publicDocs
*/
export interface ShopifyInterceptMap {
[POS_INTERCEPT_NAMES.BEFORE_CHECKOUT]: BeforeCheckoutEvent;
}

/**
* Dispatched when staff attempts to leave the active cart for checkout.
*
* @publicDocs
*/
export interface BeforeCheckoutEvent extends Event {
readonly type: typeof POS_INTERCEPT_NAMES.BEFORE_CHECKOUT;
/** The POS cart at the point checkout was requested. */
readonly cart: Cart;
}

/** @publicDocs */
export type ShopifyInterceptor<TEvent extends Event> = (
event: TEvent,
) => InterceptResult;

/**
* The result an interceptor returns. An empty `operations` list allows the
* workflow; an `ERROR` validation blocks it.
*
* @publicDocs
*/
export interface InterceptResult {
operations: Operation[];
}

/**
* A single host operation produced by an interceptor.
*
* @publicDocs
*/
export interface Operation {
validationAdd?: ValidationAdd;
}

/** @publicDocs */
export type ValidationLevel = 'INFO' | 'WARNING' | 'ERROR';

/**
* Adds a validation to the workflow being intercepted.
*
* @publicDocs
*/
export interface ValidationAdd {
/** `ERROR` blocks the workflow. `WARNING` and `INFO` do not. */
level: ValidationLevel;

/** Stable identifier for this validation. */
handle: string;

/** Host-facing message for support, observability, or staff UX. */
message: string;

/** JSON-path locator for where the validation applies. Defaults to `$.cart`. */
target?: string;

/** Optional structured data for custom UX or order metadata. */
metafields?: Metafield[];
}

/**
* Metafield input attached to a validation.
*
* @publicDocs
*/
export interface Metafield {
namespace: string;
key: string;
value: string;
type: string;
}

export type {
TransactionCompleteEvent,
CashTrackingSessionStartEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
OrderApi,
StorageApi,
CashDrawerApi,
ReadonlyNavigationApi,
} from './api';
import type {ActionExtensionComponents} from './components/targets/ActionExtensionComponents';
import type {BlockExtensionComponents} from './components/targets/BlockExtensionComponents';
Expand Down Expand Up @@ -122,6 +123,69 @@ export interface RenderExtensionTargets {
ActionTargetApi<'pos.home.modal.render'> & CartApi,
BasicComponents
>;
/**
* Renders a resolution side panel beside the POS cart when a merchant app's
* `beforeCheckout` intercept returns a blocking validation. POS launches this
* target automatically — it is not triggered by a tile or menu item — and
* revalidates the cart when the side panel is closed.
*
* **Which validation are you resolving?** POS seeds the navigation URL with
* `/{handle}`, where `handle` is the one your app supplied on the blocking
* validation. Read it from `navigation.currentEntry`:
*
* ```ts
* const handle = navigation.currentEntry.url?.slice(1) ?? '';
* ```
*
* **Getting the validation details.** There is no API that hands you the
* violation — this is deliberate, so there is one source of truth for
* validation logic. Re-run the same validation function you use in your
* `beforeCheckout` interceptor against `shopify.cart.current`, then match on
* the handle to find the violation this screen is for:
*
* ```ts
* const violations = myValidations(shopify.cart.current);
* const violation = violations.find((v) => v.handle === handle);
* ```
*
* **`cart.current` is live.** The cart is the same live cart every other
* target sees — read and write. Mutate it to resolve the problem (remove a
* restricted item, apply a required discount, etc.), observe the change
* reflected in `cart.current`, then re-run your validation function to
* confirm it passes and render your own success UI. POS also revalidates
* the live cart when the resolution flow closes.
*
* **Navigation is read-only.** `currentEntry` and the `currententrychange`
* event listener work normally, but `navigate()`, `back()`, `push()`, and
* `pop()` throw — the host rejects all navigation writes.
*
* **Static per-event API table** (API exposure is static per intercept event
* name, documented here — there is no runtime capability introspection):
*
* | Event | Cart | Navigation | Standard | Scanner |
* | --- | --- | --- | --- | --- |
* | `beforeCheckout` | read + write | read-only | yes | yes |
* | `paymentType` *(future, not implemented)* | read-only | read-only | yes | yes |
*
* For the current `beforeCheckout` event the extension gets: the full
* `StandardApi` (including `ScannerApi` — scanning a driver's licence or ID
* to clear an age-restriction block is a first-class use of this target), a
* write-capable `CartApi`, and read-only navigation (`currentEntry` only —
* `navigate`/`back` throw host-side). A future `paymentType` event would
* receive read-only cart instead, documented here for forward compatibility.
*
* **API version requirement.** The handle travels in `navigation.currentEntry`,
* which the host only exposes on remote-dom api versions. This target therefore
* requires a remote-dom `minimum_api_version`; version enforcement happens in
* the shop/world server registration (`pos_ui.rb`), not in this package. Because
* the target is brand new, nothing is grandfathered.
*/
'pos.resolution.action.render': RenderExtension<
ActionTargetApi<'pos.resolution.action.render'> &
CartApi &
ReadonlyNavigationApi,
BasicComponents
>;
/**
* Renders a single interactive button component as a menu item in the post-return action menu. Use this target for post-return operations like generating return receipts, processing restocking workflows, or collecting return feedback.
*
Expand Down
27 changes: 26 additions & 1 deletion packages/ui-extensions/src/surfaces/point-of-sale/globals.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import type {Navigation} from './api/navigation-api/navigation-api';
import type {ShopifyEventMap} from './events';
import type {
ShopifyEventMap,
ShopifyInterceptMap,
ShopifyInterceptor,
} from './events';

/* eslint-disable-next-line no-warning-comments -- deliberate prototype marker; tracks a known gap in this draft */
// TODO(prototype): The `navigation` global is declared process-wide for all POS
// targets. For `pos.resolution.action.render` we want read-only navigation
// (currentEntry only, no navigate/back). This is currently expressed in the
// per-target API intersection via `ReadonlyNavigationApi`, but the `navigation`
// *global* still advertises the full `Navigation` type. Per-target global
// narrowing (following the BackgroundShopifyGlobal precedent + buildTargetDts
// isDataTarget branch) is deferred — navigation writes are rejected host-side
// in Shopify/extensibility, not at the type level. A future PR can add a
// `ResolutionShopifyGlobal` / narrowed navigation global if type-level
// enforcement is needed.

/**
* The `shopify` global provides APIs that are available to all POS extensions
Expand Down Expand Up @@ -36,6 +52,15 @@ export interface BackgroundShopifyGlobal extends ShopifyGlobal {
type: K,
listener: (event: ShopifyEventMap[K]) => void,
): void;

/**
* Register an interceptor for a POS host workflow that can be blocked.
* Returns a function that unregisters the interceptor.
*/
intercept<K extends keyof ShopifyInterceptMap>(
type: K,
interceptor: ShopifyInterceptor<ShopifyInterceptMap[K]>,
): () => void;
}

declare global {
Expand Down
Loading