diff --git a/.changeset/pos-background-scan-event.md b/.changeset/pos-background-scan-event.md new file mode 100644 index 0000000000..4ab0201d95 --- /dev/null +++ b/.changeset/pos-background-scan-event.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions': minor +--- + +Add the POS background `scan` host event types: `POS_EVENT_NAMES.SCAN`, a public `ScanEvent` payload with guaranteed decoded `data` and normalized `source` (`camera`, `external`, or `embedded`), and the matching `ShopifyEventMap` entry so `pos.app.ready.data` extensions can register `shopify.addEventListener('scan', ...)`. The event is delivered after POS has finished handling the scan itself. diff --git a/.changeset/pos-scan-event-tester-dispatch-type.md b/.changeset/pos-scan-event-tester-dispatch-type.md new file mode 100644 index 0000000000..5d32eff0b9 --- /dev/null +++ b/.changeset/pos-scan-event-tester-dispatch-type.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions-tester': minor +--- + +`extension.dispatch()` now stamps the dispatched event with its `type`, matching what the POS runtime delivers to `shopify.addEventListener` listeners, and accepts the payload without `type`. Assertions that compared a listener's argument to the raw payload should now expect the added `type` field. diff --git a/packages/ui-extensions-tester/src/index.ts b/packages/ui-extensions-tester/src/index.ts index 5b999d8e4a..a0fe842203 100644 --- a/packages/ui-extensions-tester/src/index.ts +++ b/packages/ui-extensions-tester/src/index.ts @@ -48,6 +48,8 @@ export const SymbolDispose: typeof Symbol.dispose = ((Symbol as any).dispose ?? * `getExtension`) and {@link DisposableExtensionHarness} (returned * by `setUpExtension`). */ +type EventDispatchPayload = T extends unknown ? Omit : never; + interface BaseExtensionHarness { /** * Imports and executes the extension module's default export, @@ -118,7 +120,7 @@ interface BaseExtensionHarness { */ dispatch>( type: K, - event: EventMapForTarget[K], + event: EventDispatchPayload[K]>, ): void; } @@ -234,15 +236,16 @@ class Extension implements ExtensionHarness { dispatch>( type: K, - event: EventMapForTarget[K], + event: EventDispatchPayload[K]>, ): void { const listeners = this.#eventListeners.get(type as string); if (!listeners) return; + const dispatchedEvent = {...event, type}; // Snapshot so listeners that register/unregister during dispatch // don't mutate the iteration. for (const listener of [...listeners]) { try { - listener(event); + listener(dispatchedEvent); } catch { // Fire-and-forget: per the shopify.addEventListener contract, // listener errors must not affect other listeners. diff --git a/packages/ui-extensions-tester/src/tests/shopify-events.test.ts b/packages/ui-extensions-tester/src/tests/shopify-events.test.ts index 1748bd934e..db7b1595d3 100644 --- a/packages/ui-extensions-tester/src/tests/shopify-events.test.ts +++ b/packages/ui-extensions-tester/src/tests/shopify-events.test.ts @@ -1,11 +1,23 @@ import type { CashTrackingSessionStartEvent, + ScanEvent, + ShopifyEventMap, TransactionCompleteEvent, } from '@shopify/ui-extensions/point-of-sale'; import {getExtension} from '../index'; +import type {EventMapForTarget} from '../targets'; import {createTestSandbox, type TestSandbox} from './helpers'; +import {assertType, type Equals} from './type-assertions'; + +// `extension.dispatch('scan', ...)` on the background target and +// `shopify.addEventListener('scan', ...)` share the same guaranteed payload: +// decoded `data` plus its normalized `source`. +assertType>(); +assertType< + Equals['scan'], ScanEvent> +>(); function makeTransactionCompleteEvent(): TransactionCompleteEvent { return { @@ -30,6 +42,13 @@ function makeCashTrackingSessionStartEvent(): CashTrackingSessionStartEvent { }; } +function makeScanEvent(): Omit { + return { + data: 'synthetic-test-value', + source: 'external', + }; +} + describe('shopify.addEventListener / extension.dispatch', () => { let sandbox: TestSandbox; @@ -57,6 +76,19 @@ describe('shopify.addEventListener / extension.dispatch', () => { expect(typeof shopify.removeEventListener).toBe('function'); }); + it('delivers dispatched scan events to registered scan listeners', () => { + const extension = setUpExt(); + const shopify = (globalThis as any).shopify; + const listener = jest.fn(); + shopify.addEventListener('scan', listener); + + const event = makeScanEvent(); + extension.dispatch('scan', event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({...event, type: 'scan'}); + }); + it('dispatches a registered listener with the provided event payload', () => { const extension = setUpExt(); const shopify = (globalThis as any).shopify; @@ -67,7 +99,10 @@ describe('shopify.addEventListener / extension.dispatch', () => { extension.dispatch('transactioncomplete', event); expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(event); + expect(listener).toHaveBeenCalledWith({ + ...event, + type: 'transactioncomplete', + }); }); it('fires all listeners registered for the same event', () => { @@ -81,8 +116,14 @@ describe('shopify.addEventListener / extension.dispatch', () => { const event = makeTransactionCompleteEvent(); extension.dispatch('transactioncomplete', event); - expect(listenerA).toHaveBeenCalledWith(event); - expect(listenerB).toHaveBeenCalledWith(event); + expect(listenerA).toHaveBeenCalledWith({ + ...event, + type: 'transactioncomplete', + }); + expect(listenerB).toHaveBeenCalledWith({ + ...event, + type: 'transactioncomplete', + }); }); it('does not fire other events when dispatching one', () => { 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 94aae31a7e..d946c4f83f 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -29,6 +29,7 @@ export type { TransactionCompleteEvent, CashTrackingSessionStartEvent, CashTrackingSessionCompleteEvent, + ScanEvent, ShopifyEventMap, } from './events'; 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 f3c7f0313e..0de2a3da88 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 {ScanEvent} from './events/scan-event'; import type {Cart} from './types/cart'; import type {MoneyV2} from './types/money'; @@ -16,6 +17,7 @@ export const POS_EVENT_NAMES = { TRANSACTION_COMPLETE: 'transactioncomplete', CASH_TRACKING_SESSION_START: 'cashtrackingsessionstart', CASH_TRACKING_SESSION_COMPLETE: 'cashtrackingsessioncomplete', + SCAN: 'scan', } as const; /** @@ -47,6 +49,12 @@ export interface ShopifyEventMap { [POS_EVENT_NAMES.CASH_TRACKING_SESSION_START]: CashTrackingSessionStartEvent; /** Dispatched when a cash tracking session closes after reconciliation. */ [POS_EVENT_NAMES.CASH_TRACKING_SESSION_COMPLETE]: CashTrackingSessionCompleteEvent; + /** + * Dispatched once for each successful scan captured by a camera, external, + * or embedded scanner, after POS has finished handling the scan itself. + * Every event carries decoded `data` and its `source`. + */ + [POS_EVENT_NAMES.SCAN]: ScanEvent; } /** @@ -246,4 +254,5 @@ export type { TransactionCompleteEvent, CashTrackingSessionStartEvent, CashTrackingSessionCompleteEvent, + ScanEvent, }; diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/events/scan-event.ts b/packages/ui-extensions/src/surfaces/point-of-sale/events/scan-event.ts new file mode 100644 index 0000000000..f33bc8003f --- /dev/null +++ b/packages/ui-extensions/src/surfaces/point-of-sale/events/scan-event.ts @@ -0,0 +1,34 @@ +import type {ScannerSource} from '../api/scanner-api/scanner-api'; + +/** + * Dispatched once for each successful scan captured by a camera, external, + * or embedded scanner. Every event carries a decoded `data` value and its + * `source`; no event is dispatched for initial or empty scanner state. + * + * The event is delivered only after POS has finished handling the scan + * itself. + * + * @example + * ```ts + * shopify.addEventListener('scan', (event) => { + * handleScan(event.data, event.source); + * }); + * ``` + * @publicDocs + */ +export interface ScanEvent { + /** The event name. */ + readonly type: 'scan'; + /** + * The decoded string captured by the scan. Contains the scanned barcode, QR code, or other scannable data. Always present: an event is only dispatched for a successful scan. + */ + readonly data: string; + /** + * The scanner source that captured the scan. One of the following scanner types: + * + * • `'camera'` - Built-in device camera used for scanning + * • `'external'` - External scanner hardware connected to the device + * • `'embedded'` - Embedded scanner hardware built into the device + */ + readonly source: ScannerSource; +}