diff --git a/.changeset/pos-intercept-api.md b/.changeset/pos-intercept-api.md new file mode 100644 index 0000000000..4987223fc7 --- /dev/null +++ b/.changeset/pos-intercept-api.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions': minor +--- + +Add `shopify.intercept()` types for POS blocking workflows. diff --git a/.changeset/pos-resolution-target.md b/.changeset/pos-resolution-target.md new file mode 100644 index 0000000000..e4553a1407 --- /dev/null +++ b/.changeset/pos-resolution-target.md @@ -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. diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index 6c538ee5cc..3a88f62daf 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -26,6 +26,7 @@ import type { CashTrackingSessionCompleteData, CartUpdateEventData, Money, + ReadonlyNavigationApi, } from '@shopify/ui-extensions/point-of-sale'; import {createReadonlySignalLike} from '../mocks/signals'; @@ -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 // --------------------------------------------------------------------------- @@ -449,6 +466,17 @@ function createActionTargetCashDrawerMock( }; } +// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi +function createResolutionTargetMock( + target: T, +): ActionTargetApi & CartApi & ReadonlyNavigationApi { + return { + ...createMockActionTargetApi(target), + ...createMockCartApi(), + ...createMockReadonlyNavigationApi(), + }; +} + // Data target factories function createDataTargetMock( target: T, @@ -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, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts index 59394033d5..3ed88ce08c 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -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, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts index 69787040b6..312fdd24c9 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts @@ -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; } /** diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/events.ts b/packages/ui-extensions/src/surfaces/point-of-sale/events.ts index f8100cb31a..325d4b14ec 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/events.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/events.ts @@ -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 @@ -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. * @@ -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 = ( + 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, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts index ac77935b49..0ad99ada41 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts @@ -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'; @@ -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. * diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts index 67533a92d7..3f590a103d 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts @@ -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 @@ -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( + type: K, + interceptor: ShopifyInterceptor, + ): () => void; } declare global {