@final-commerce/command-frame 0.8.0-staging.2 → 0.9.0-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/CommonTypes.d.ts +25 -1
  2. package/dist/actions/add-product-fee/mock.d.ts +1 -1
  3. package/dist/actions/add-product-fee/mock.js +15 -9
  4. package/dist/actions/add-product-to-cart/mock.d.ts +23 -0
  5. package/dist/actions/add-product-to-cart/mock.js +300 -1
  6. package/dist/actions/add-product-to-cart/types.d.ts +27 -1
  7. package/dist/actions/get-composite-availability/action.d.ts +6 -0
  8. package/dist/actions/get-composite-availability/action.js +8 -0
  9. package/dist/actions/get-composite-availability/mock.d.ts +2 -0
  10. package/dist/actions/get-composite-availability/mock.js +45 -0
  11. package/dist/actions/get-composite-availability/types.d.ts +29 -0
  12. package/dist/actions/get-composite-availability/types.js +1 -0
  13. package/dist/actions/get-composite-price/action.d.ts +6 -0
  14. package/dist/actions/get-composite-price/action.js +8 -0
  15. package/dist/actions/get-composite-price/mock.d.ts +2 -0
  16. package/dist/actions/get-composite-price/mock.js +37 -0
  17. package/dist/actions/get-composite-price/types.d.ts +31 -0
  18. package/dist/actions/get-composite-price/types.js +1 -0
  19. package/dist/actions/get-current-cart/mock.d.ts +1 -1
  20. package/dist/actions/get-current-cart/mock.js +13 -3
  21. package/dist/actions/get-current-cart/types.d.ts +7 -1
  22. package/dist/actions/get-product-modifier-selections/mock.js +12 -0
  23. package/dist/actions/get-products/mock.js +7 -3
  24. package/dist/actions/get-products/types.d.ts +1 -1
  25. package/dist/actions/set-product-modifier-selections/mock.js +12 -0
  26. package/dist/demo/database.d.ts +39 -1
  27. package/dist/demo/database.js +218 -3
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +4 -0
  30. package/dist/projects/render/mocks.js +4 -0
  31. package/dist/projects/render/types.d.ts +3 -1
  32. package/dist/pubsub/topics/products/catalog-visibility-changed/types.d.ts +17 -0
  33. package/dist/pubsub/topics/products/catalog-visibility-changed/types.js +1 -0
  34. package/dist/pubsub/topics/products/composite-changed/types.d.ts +17 -0
  35. package/dist/pubsub/topics/products/composite-changed/types.js +1 -0
  36. package/dist/pubsub/topics/products/index.d.ts +2 -2
  37. package/dist/pubsub/topics/products/index.js +33 -18
  38. package/dist/pubsub/topics/products/inventory-changed/types.d.ts +18 -0
  39. package/dist/pubsub/topics/products/inventory-changed/types.js +1 -0
  40. package/dist/pubsub/topics/products/types.d.ts +16 -10
  41. package/dist/pubsub/topics/products/types.js +7 -4
  42. package/package.json +2 -2
@@ -0,0 +1,31 @@
1
+ import type { CFCompositePick } from '../../CommonTypes';
2
+ export interface GetCompositePriceParams {
3
+ /** The composite's variant — the one `addProductToCart` takes. */
4
+ variantId: string;
5
+ /** The picks so far, exactly as `addProductToCart({ composite })` would send them. */
6
+ composite: CFCompositePick[];
7
+ /** B41: the date and time a bookable pick is for, as `addProductToCart` takes it; without one a bookable pick is refused. */
8
+ compositeSlot?: {
9
+ startAt: string;
10
+ };
11
+ }
12
+ export interface GetCompositePriceResponse {
13
+ success: boolean;
14
+ /** Why these picks are refused — the same refusal and text `addProductToCart` would give. */
15
+ reason?: string;
16
+ /**
17
+ * What ONE composite with these picks adds to the cart, minor units: the line price the host would build plus the
18
+ * picked items' modifiers. Before discounts, fees and tax. Show it; do not recompute it. Absent when refused.
19
+ */
20
+ total?: number;
21
+ /**
22
+ * The parts that still need picks before the composite can be added, and how many ("Pick 2 more"); empty when every
23
+ * required part is filled. Decided by the host — keep Add to cart disabled while this is not empty.
24
+ */
25
+ missing: {
26
+ partId: string;
27
+ needed: number;
28
+ }[];
29
+ timestamp: string;
30
+ }
31
+ export type GetCompositePrice = (params: GetCompositePriceParams) => Promise<GetCompositePriceResponse>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,2 @@
1
- import { GetCurrentCart } from "./types";
1
+ import { GetCurrentCart } from './types';
2
2
  export declare const mockGetCurrentCart: GetCurrentCart;
@@ -1,9 +1,19 @@
1
- import { MOCK_CART, safeSerialize } from "../../demo/database";
1
+ import { MOCK_CART, buildModifierRows, safeSerialize } from '../../demo/database';
2
+ import { extendPrice } from '@final-commerce/common';
2
3
  export const mockGetCurrentCart = async () => {
3
- console.log("[Mock] getCurrentCart called");
4
+ console.log('[Mock] getCurrentCart called');
5
+ // The mock keeps no discounts or fees per line: a line is its price × quantity plus its modifier rows.
6
+ const lineTotals = Object.fromEntries([
7
+ ...MOCK_CART.products.map((line) => [
8
+ line.internalId ?? line.id,
9
+ extendPrice(line.price, line.quantity) + buildModifierRows(line.modifiers, line.quantity).modifiersTotal,
10
+ ]),
11
+ ...(MOCK_CART.customSales ?? []).map((sale) => [sale.id, extendPrice(sale.price, sale.quantity)]),
12
+ ]);
4
13
  return {
5
14
  success: true,
6
15
  cart: safeSerialize(MOCK_CART),
7
- timestamp: new Date().toISOString()
16
+ lineTotals,
17
+ timestamp: new Date().toISOString(),
8
18
  };
9
19
  };
@@ -1,7 +1,13 @@
1
- import { CFActiveCart } from "../../CommonTypes";
1
+ import { CFActiveCart } from '../../CommonTypes';
2
2
  export interface GetCurrentCartResponse {
3
3
  success: boolean;
4
4
  cart: CFActiveCart;
5
+ /**
6
+ * What each line adds to the cart, minor units, keyed by the line's `internalId` (products, bookings) or `id`
7
+ * (custom sales): price × quantity − its own discounts + its own fees + its modifiers. Before the cart discount
8
+ * and tax; Σ = the cart's subtotal before non-revenue items. Show it; do not recompute it.
9
+ */
10
+ lineTotals?: Record<string, number>;
5
11
  timestamp: string;
6
12
  }
7
13
  export type GetCurrentCart = () => Promise<GetCurrentCartResponse>;
@@ -16,6 +16,18 @@ export const mockGetProductModifierSelections = async (params) => {
16
16
  timestamp: new Date().toISOString(),
17
17
  };
18
18
  }
19
+ // Like the host (FT-83): a composite line's modifiers belong to its components.
20
+ if (line.components?.length) {
21
+ return {
22
+ success: false,
23
+ reason: 'The modifiers of a composite line cannot be edited yet: remove the line and add it again with its picks',
24
+ internalId: line.internalId,
25
+ selections: [],
26
+ rows: [],
27
+ modifiersTotal: 0,
28
+ timestamp: new Date().toISOString(),
29
+ };
30
+ }
19
31
  // The priced rows already sit on the line; extending them by the line quantity is
20
32
  // the host's job, not the flow's.
21
33
  const { rows, modifiersTotal } = buildModifierRows(line.modifiers, line.quantity);
@@ -1,9 +1,13 @@
1
- import { MOCK_PRODUCTS, safeSerialize } from '../../demo/database';
1
+ import { MOCK_PRODUCTS, mockCompositeAtOutlet, mockHiddenProductIds, safeSerialize } from '../../demo/database';
2
2
  import { resolveUnit } from '@final-commerce/common';
3
3
  export const mockGetProducts = async (params) => {
4
4
  console.log('[Mock] getProducts called', params);
5
- // Simple filter simulation
6
- let products = MOCK_PRODUCTS;
5
+ // Simple filter simulation. Like the host: a product hidden at this outlet is left out, and a composite offers only
6
+ // what is sold here.
7
+ const hidden = mockHiddenProductIds();
8
+ let products = MOCK_PRODUCTS.filter((p) => !hidden.has(p._id)).map((p) =>
9
+ // B43: the host never hands a composite modifiers of its own.
10
+ p.composite ? { ...p, modifiers: [], composite: mockCompositeAtOutlet(p.composite, hidden) } : p);
7
11
  const query = params?.query || {};
8
12
  // Same rule as the real host: a bookable service is not catalogue stock and is left out unless
9
13
  // the query names `productType`. Mirrored here on purpose — a mock that hands back services the
@@ -27,7 +27,7 @@ export interface GetProductsParams {
27
27
  };
28
28
  status?: string;
29
29
  /**
30
- * `simple` | `variable` | `booking`. Naming it at all switches OFF the default exclusion
30
+ * `simple` | `variable` | `booking` | `composite`. Naming it at all switches OFF the default exclusion
31
31
  * of bookable services — including `{ $ne: 'booking' }`, which then means what it says.
32
32
  */
33
33
  productType?: string | {
@@ -30,6 +30,18 @@ export const mockSetProductModifierSelections = async (params) => {
30
30
  timestamp: new Date().toISOString(),
31
31
  };
32
32
  }
33
+ // Like the host (FT-83): a composite line's modifiers belong to its components.
34
+ if (line.components?.length) {
35
+ return {
36
+ success: false,
37
+ reason: 'The modifiers of a composite line cannot be edited yet: remove the line and add it again with its picks',
38
+ internalId: line.internalId,
39
+ selections: [],
40
+ rows: [],
41
+ modifiersTotal: 0,
42
+ timestamp: new Date().toISOString(),
43
+ };
44
+ }
33
45
  // Full replacement — passing [] clears the rows. The line drops the product's resolved
34
46
  // stack when it is built, so the menu is re-read from the catalogue to price against.
35
47
  // Cart totals are not touched, for the same reason addProductToCart does not move them
@@ -2,7 +2,7 @@
2
2
  * Mock Database for Standalone/Demo Mode
3
3
  * Stores mock data that mimics the Render environment
4
4
  */
5
- import { CFActiveCompany, CFActiveUser, CFActiveStation, CFActiveOutlet, CFActiveOrder, CFCustomer, CFProduct, CFActiveCart, CFCategory, CFActiveProduct, CFSession, CFActiveRefundDetails, CFSmartGridLayout, CFBooking, CFBookingAvailability, CFBookingResource, CFCartLineModifier, CFProdModifierBreakdown, CFModifierSelection, CFResolvedModifier } from '../CommonTypes';
5
+ import { CFActiveCompany, CFActiveUser, CFActiveStation, CFActiveOutlet, CFActiveOrder, CFCustomer, CFProduct, CFComposite, CFCartLineComponent, CFActiveCart, CFCategory, CFActiveProduct, CFSession, CFActiveRefundDetails, CFSmartGridLayout, CFBooking, CFBookingAvailability, CFBookingResource, CFBookingSlot, CFCartLineModifier, CFProdModifierBreakdown, CFModifierSelection, CFResolvedModifier } from '../CommonTypes';
6
6
  export * from './mocks';
7
7
  /** Replace mock catalog / context data in place (same array references mock handlers use). */
8
8
  export interface MockDatabaseConfig {
@@ -19,6 +19,12 @@ export interface MockDatabaseConfig {
19
19
  bookingResources?: CFBookingResource[];
20
20
  /** Windows already taken when the dataset loads, so a calendar does not open empty. */
21
21
  bookings?: CFBooking[];
22
+ /** Products hidden at an outlet (the host's `catalog-visibility`, product-level rows). Empty = sold everywhere. */
23
+ catalogVisibility?: MockCatalogVisibility[];
24
+ }
25
+ export interface MockCatalogVisibility {
26
+ productId: string;
27
+ outletId: string;
22
28
  }
23
29
  export declare const MOCK_COMPANY: CFActiveCompany;
24
30
  export declare const MOCK_OUTLET_MAIN: CFActiveOutlet;
@@ -64,8 +70,40 @@ export declare const MOCK_OUTLETS: import("@final-commerce/common/pos-types").Ac
64
70
  export declare const MOCK_CUSTOMERS: import("@final-commerce/common/pos-types").ActiveCustomer[];
65
71
  export declare const MOCK_CATEGORIES: import("@final-commerce/common/pos-types").Category[];
66
72
  export declare const MOCK_PRODUCT_HAIRCUT: CFProduct;
73
+ export declare const MOCK_PRODUCT_PASTE_TRIO: CFProduct;
67
74
  export declare const MOCK_PRODUCTS: import("@final-commerce/common/pos-types").FullProduct[];
68
75
  export declare const MOCK_ORDERS: import("@final-commerce/common/pos-types").ActiveOrder[];
76
+ /** Nothing hidden by default; `setMockDatabase({ catalogVisibility })` hides products at an outlet. */
77
+ export declare const MOCK_CATALOG_VISIBILITY: MockCatalogVisibility[];
78
+ /**
79
+ * The host's cart stock check for composites (kaching `compositeStockRefusal`), over the demo shelves — a copy: what the
80
+ * cart takes of each component's variant (plain lines and components × their line quantity) against its shelf here.
81
+ * The demo has no pools or units, so it counts per variant; untracked and backordered variants are never short.
82
+ */
83
+ export declare function mockCompositeStockRefusal(products: {
84
+ variantId: string;
85
+ quantity: number;
86
+ components?: CFCartLineComponent[];
87
+ }[]): string | null;
88
+ /** Product ids hidden at the demo's active outlet. */
89
+ export declare const mockHiddenProductIds: () => Set<string>;
90
+ /**
91
+ * The host's outlet rules for a composite (kaching `resolveComposite`), over the demo's static view — a copy, change both
92
+ * in one step: an item whose product is hidden here is left out, a category item keeps only products sold here (none →
93
+ * `'empty'`), an Optional part with nothing to pick is not offered, a required one with nothing makes it unavailable,
94
+ * and the D36 default and "from" price are read off what is left.
95
+ */
96
+ /** B41: the seats a bookable demo product has free in the slot starting at `startAt` — as the host reads them. */
97
+ export declare function mockBookableWindow(productId: string, startAt: string): {
98
+ free: number;
99
+ slot: CFBookingSlot | null;
100
+ };
101
+ export declare const mockIsBookable: (productId: string) => boolean;
102
+ export declare function mockCompositeAtOutlet(composite: Omit<CFComposite, 'needsDate'>, hidden?: Set<string>,
103
+ /** B41: the window bookable items are judged in; absent = `by_date`. */
104
+ slot?: {
105
+ startAt: string;
106
+ }): CFComposite;
69
107
  export declare const MOCK_PARKED_ORDERS: CFActiveOrder[];
70
108
  export declare let MOCK_USER: CFActiveUser;
71
109
  export declare let MOCK_STATION: CFActiveStation;
@@ -3,7 +3,7 @@
3
3
  * Stores mock data that mimics the Render environment
4
4
  */
5
5
  import { CFProductType, CFUserTypes, CurrencyCode, } from '../CommonTypes';
6
- import { extendPrice, resolveUnit, toBase } from '@final-commerce/common';
6
+ import { cheapestCompositeItem, cheapestCompositePrice, extendPrice, resolveUnit, toBase, } from '@final-commerce/common';
7
7
  import { BookingRatePeriod, BookingResourceKind, BookingType, ReservationStatus } from '@final-commerce/common';
8
8
  export * from './mocks';
9
9
  // Asset Imports - Using Remote URLs to avoid build complexity with asset copying
@@ -228,6 +228,7 @@ const createSimpleProduct = (id, name, price, image, categories, description) =>
228
228
  maxPrice: price,
229
229
  status: 'active',
230
230
  productType: CFProductType.SIMPLE,
231
+ composite: null,
231
232
  taxTable: 'tax_standard',
232
233
  description,
233
234
  images: [image],
@@ -263,6 +264,7 @@ const createVariableProduct = (id, name, basePrice, largePrice, image, categorie
263
264
  maxPrice: largePrice,
264
265
  status: 'active',
265
266
  productType: CFProductType.VARIABLE,
267
+ composite: null,
266
268
  taxTable: 'tax_standard',
267
269
  description,
268
270
  images: [image],
@@ -658,7 +660,106 @@ export const MOCK_PRODUCT_HAIRCUT = {
658
660
  productType: CFProductType.BOOKING,
659
661
  bookingRulesId: 'rule_salon_30',
660
662
  };
663
+ // A composite (FT-83) as the host serves it: the merchant's price 27.00, items add only their upcharges (B31); each item
664
+ // keeps its own tax (`taxTable` empty). One item points at a deleted variant (unavailable), one is a whole category.
665
+ export const MOCK_PRODUCT_PASTE_TRIO = {
666
+ ...createSimpleProduct('prod_paste_trio', 'Paste Trio', 2700, basilAlmondImg, [MOCK_CATEGORY_PASTES], 'Pick a paste and a spicy one.'),
667
+ productType: CFProductType.COMPOSITE,
668
+ taxTable: '',
669
+ // B43: a composite has no modifiers of its own; its items carry theirs.
670
+ modifiers: [],
671
+ composite: {
672
+ available: true,
673
+ basePrice: 2700,
674
+ fromPrice: 2800,
675
+ parts: [
676
+ {
677
+ _id: 'part_paste',
678
+ name: 'Paste',
679
+ required: true,
680
+ min: 1,
681
+ max: 1,
682
+ defaultPick: { itemId: 'item_basil', variantId: 'prod_basil_almond_var_main' },
683
+ items: [
684
+ {
685
+ _id: 'item_basil',
686
+ variantId: 'prod_basil_almond_var_main',
687
+ categoryId: null,
688
+ name: 'Basil Almond Paste',
689
+ extraCharge: 0,
690
+ quantity: 1,
691
+ cost: 0,
692
+ unavailable: null,
693
+ choices: [
694
+ {
695
+ productId: 'prod_basil_almond',
696
+ variantId: 'prod_basil_almond_var_main',
697
+ name: 'Basil Almond Paste',
698
+ attributes: [],
699
+ // The picked product's own modifiers (B28): taxed at its table, sent in the pick.
700
+ modifiers: [
701
+ {
702
+ _id: 'mod_texture',
703
+ name: 'Texture',
704
+ selectionType: 'single',
705
+ required: false,
706
+ sortOrder: 0,
707
+ choices: [
708
+ { _id: 'choice_smooth', name: 'Smooth', price: 0, sortOrder: 0 },
709
+ { _id: 'choice_chunky', name: 'Chunky', price: 50, sortOrder: 1 },
710
+ ],
711
+ },
712
+ ],
713
+ },
714
+ ],
715
+ },
716
+ {
717
+ _id: 'item_gone',
718
+ variantId: 'prod_gone_var_main',
719
+ categoryId: null,
720
+ name: '',
721
+ extraCharge: 0,
722
+ quantity: 1,
723
+ cost: 0,
724
+ unavailable: 'deleted',
725
+ choices: [],
726
+ },
727
+ ],
728
+ },
729
+ {
730
+ _id: 'part_spicy',
731
+ name: null,
732
+ required: true,
733
+ min: 1,
734
+ max: 2,
735
+ // "Pick up to 2": no preselected pick (B29 = D36) — the till shows how many are still needed.
736
+ defaultPick: null,
737
+ items: [
738
+ {
739
+ _id: 'item_spicy',
740
+ variantId: null,
741
+ categoryId: MOCK_CATEGORY_SPICY.id,
742
+ name: 'Spicy',
743
+ extraCharge: 100,
744
+ quantity: 1,
745
+ cost: 100,
746
+ unavailable: null,
747
+ choices: [
748
+ {
749
+ productId: 'prod_habanero',
750
+ variantId: 'prod_habanero_var_main',
751
+ name: 'Habanero Paste',
752
+ attributes: [],
753
+ },
754
+ ],
755
+ },
756
+ ],
757
+ },
758
+ ],
759
+ },
760
+ };
661
761
  export const MOCK_PRODUCTS = [
762
+ MOCK_PRODUCT_PASTE_TRIO,
662
763
  MOCK_PRODUCT_BASIL_ALMOND,
663
764
  MOCK_PRODUCT_BEER,
664
765
  MOCK_PRODUCT_BEET,
@@ -676,6 +777,114 @@ export const MOCK_PRODUCTS = [
676
777
  MOCK_PRODUCT_HAIRCUT,
677
778
  ];
678
779
  export const MOCK_ORDERS = [MOCK_ORDER_1, MOCK_ORDER_2, MOCK_ORDER_3, MOCK_ORDER_4];
780
+ /** Nothing hidden by default; `setMockDatabase({ catalogVisibility })` hides products at an outlet. */
781
+ export const MOCK_CATALOG_VISIBILITY = [];
782
+ /**
783
+ * The host's cart stock check for composites (kaching `compositeStockRefusal`), over the demo shelves — a copy: what the
784
+ * cart takes of each component's variant (plain lines and components × their line quantity) against its shelf here.
785
+ * The demo has no pools or units, so it counts per variant; untracked and backordered variants are never short.
786
+ */
787
+ export function mockCompositeStockRefusal(products) {
788
+ const components = products.flatMap((product) => product.components ?? []);
789
+ const demand = new Map();
790
+ for (const product of products) {
791
+ if (product.components)
792
+ for (const component of product.components)
793
+ demand.set(component.variantId, (demand.get(component.variantId) ?? 0) + component.quantity * product.quantity);
794
+ else
795
+ demand.set(product.variantId, (demand.get(product.variantId) ?? 0) + product.quantity);
796
+ }
797
+ for (const component of components) {
798
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === component.variantId);
799
+ if (!variant?.manageStock || variant.allowBackorder)
800
+ continue;
801
+ const left = variant.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock ?? 0;
802
+ if ((demand.get(component.variantId) ?? 0) > left)
803
+ return `${component.name}: only ${left} left`;
804
+ }
805
+ return null;
806
+ }
807
+ /** Product ids hidden at the demo's active outlet. */
808
+ export const mockHiddenProductIds = () => new Set(MOCK_CATALOG_VISIBILITY.filter((row) => row.outletId === MOCK_OUTLET.id).map((row) => row.productId));
809
+ /**
810
+ * The host's outlet rules for a composite (kaching `resolveComposite`), over the demo's static view — a copy, change both
811
+ * in one step: an item whose product is hidden here is left out, a category item keeps only products sold here (none →
812
+ * `'empty'`), an Optional part with nothing to pick is not offered, a required one with nothing makes it unavailable,
813
+ * and the D36 default and "from" price are read off what is left.
814
+ */
815
+ /** B41: the seats a bookable demo product has free in the slot starting at `startAt` — as the host reads them. */
816
+ export function mockBookableWindow(productId, startAt) {
817
+ const at = new Date(startAt);
818
+ const slot = mockBookingAvailability(productId, at, new Date(+at + 86400000)).slots.find((candidate) => +new Date(candidate.startAt) === +at && candidate.canStart) ?? null;
819
+ return { free: slot?.free ?? 0, slot };
820
+ }
821
+ export const mockIsBookable = (productId) => MOCK_PRODUCTS.find((product) => product._id === productId)?.productType === CFProductType.BOOKING;
822
+ export function mockCompositeAtOutlet(composite, hidden = mockHiddenProductIds(),
823
+ /** B41: the window bookable items are judged in; absent = `by_date`. */
824
+ slot) {
825
+ // B41: a bookable item is judged by seats in the window, never by stock; with no window it waits for a date.
826
+ const bookable = (item) => !!item.variantId && item.choices.some((choice) => mockIsBookable(choice.productId));
827
+ const seatsFor = (item) => !slot
828
+ ? 'by_date'
829
+ : mockBookableWindow(item.choices[0].productId, slot.startAt).free >= item.quantity
830
+ ? null
831
+ : 'fully_booked';
832
+ const offered = (item) => !item.unavailable || item.unavailable === 'by_date';
833
+ // B35: a tracked variant without `quantity` on the demo shelf here (and no backorder) is sold out.
834
+ const soldOut = (variantId, quantity) => {
835
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === variantId);
836
+ if (!variant?.manageStock || variant.allowBackorder)
837
+ return false;
838
+ return (variant.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock ?? 0) < quantity;
839
+ };
840
+ // B36e: "N left" on a unit-sold tracked choice — the demo shelf already reads in the variant's own unit.
841
+ const withStockLeft = (choice) => {
842
+ const variant = MOCK_PRODUCTS.flatMap((product) => product.variants).find((candidate) => candidate._id === choice.variantId);
843
+ const onHand = variant?.inventory?.find((row) => row.outletId === MOCK_OUTLET.id)?.stock;
844
+ return variant?.unitId && variant.manageStock && typeof onHand === 'number'
845
+ ? { ...choice, stockLeft: onHand }
846
+ : choice;
847
+ };
848
+ const upcharge = (item) => ({
849
+ extraCharge: item.extraCharge,
850
+ quantity: item.quantity,
851
+ available: offered(item),
852
+ });
853
+ const parts = composite.parts
854
+ .map((part) => {
855
+ const items = part.items.flatMap((item) => {
856
+ if (item.variantId) {
857
+ if (item.choices.some((choice) => hidden.has(choice.productId)))
858
+ return [];
859
+ if (!item.unavailable && bookable(item))
860
+ return [{ ...item, unavailable: seatsFor(item) }];
861
+ return item.unavailable || !soldOut(item.variantId, item.quantity)
862
+ ? [{ ...item, choices: item.choices.map(withStockLeft) }]
863
+ : [{ ...item, unavailable: 'out_of_stock', choices: [] }];
864
+ }
865
+ const sold = item.choices.filter((choice) => !hidden.has(choice.productId));
866
+ const choices = sold.filter((choice) => !soldOut(choice.variantId, item.quantity));
867
+ const unavailable = item.unavailable ?? (!sold.length ? 'empty' : !choices.length ? 'out_of_stock' : null);
868
+ return [{ ...item, choices: unavailable ? [] : choices.map(withStockLeft), unavailable }];
869
+ });
870
+ const index = part.min === 1 && part.max === 1 ? cheapestCompositeItem(items.map(upcharge)) : undefined;
871
+ const cheapest = index === undefined ? undefined : items[index];
872
+ return {
873
+ ...part,
874
+ items,
875
+ defaultPick: cheapest?.variantId ? { itemId: cheapest._id, variantId: cheapest.variantId } : null,
876
+ };
877
+ })
878
+ .filter((part) => part.min > 0 || part.items.some(offered));
879
+ const cheapest = cheapestCompositePrice(parts.map((part) => ({ min: part.min, items: part.items.map(upcharge) })));
880
+ return {
881
+ ...composite,
882
+ available: composite.available && parts.every((part) => part.min === 0 || part.items.some(offered)),
883
+ needsDate: parts.some((part) => part.items.some(bookable)),
884
+ fromPrice: composite.basePrice === null || cheapest === undefined ? null : composite.basePrice + cheapest,
885
+ parts,
886
+ };
887
+ }
679
888
  export const MOCK_PARKED_ORDERS = [MOCK_PARKED_ORDER_1, MOCK_PARKED_ORDER_2];
680
889
  // Compatibility Exports (reassigned by setMockDatabase)
681
890
  export let MOCK_USER = MOCK_USERS[0];
@@ -760,6 +969,9 @@ export function setMockDatabase(config) {
760
969
  if (config.bookings !== undefined) {
761
970
  MOCK_BOOKINGS.splice(0, MOCK_BOOKINGS.length, ...config.bookings);
762
971
  }
972
+ if (config.catalogVisibility !== undefined) {
973
+ MOCK_CATALOG_VISIBILITY.splice(0, MOCK_CATALOG_VISIBILITY.length, ...config.catalogVisibility);
974
+ }
763
975
  if (MOCK_OUTLETS.length > 0) {
764
976
  Object.assign(MOCK_OUTLET_MAIN, MOCK_OUTLETS[0]);
765
977
  if (MOCK_OUTLET_MAIN.id === undefined && MOCK_OUTLET_MAIN._id !== undefined) {
@@ -982,8 +1194,10 @@ const CLOSE_HOUR = 18;
982
1194
  // Rooms, not people: a resource is whatever is scarce, and a room is the case that
983
1195
  // reads the same in every vertical a dataset might describe.
984
1196
  export const MOCK_BOOKING_RESOURCES = [
985
- { id: 'res_room_1', name: 'Room 1', kind: BookingResourceKind.ROOM },
986
- { id: 'res_room_2', name: 'Room 2', kind: BookingResourceKind.ROOM },
1197
+ // Two different photos, from the assets this demo already ships: a screen that picks a
1198
+ // resource by sight has to be fed two that look apart, not one repeated.
1199
+ { id: 'res_room_1', name: 'Room 1', kind: BookingResourceKind.ROOM, image: beetImg },
1200
+ { id: 'res_room_2', name: 'Room 2', kind: BookingResourceKind.ROOM, image: roastedTomatoImg },
987
1201
  ];
988
1202
  /**
989
1203
  * The clock this mock shop keeps — the same one `getContext` reports. They used to disagree: the
@@ -1130,6 +1344,7 @@ export const buildModifierRows = (modifiers, lineQuantity) => {
1130
1344
  amount: extendPrice(modifier.unitPrice * modifier.quantity, quantity),
1131
1345
  tax: 0,
1132
1346
  ...(modifier.taxTableId ? { taxTableId: modifier.taxTableId } : {}),
1347
+ ...(modifier.componentIndex !== undefined ? { componentIndex: modifier.componentIndex } : {}),
1133
1348
  }));
1134
1349
  return { rows, modifiersTotal: rows.reduce((sum, row) => sum + row.amount, 0) };
1135
1350
  };
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare const command: {
29
29
  readonly addProductNote: import(".").AddProductNote;
30
30
  readonly addProductFee: import(".").AddProductFee;
31
31
  readonly getProductModifierSelections: import(".").GetProductModifierSelections;
32
+ readonly getCompositePrice: import(".").GetCompositePrice;
33
+ readonly getCompositeAvailability: import(".").GetCompositeAvailability;
32
34
  readonly setProductModifierSelections: import(".").SetProductModifierSelections;
33
35
  readonly setActiveProductFee: import(".").SetActiveProductFee;
34
36
  readonly setActiveProductDiscount: import(".").SetActiveProductDiscount;
@@ -174,6 +176,8 @@ export type { GetFinalContext, GetFinalContextResponse } from './actions/get-fin
174
176
  export type { AddProductNote, AddProductNoteParams, AddProductNoteResponse } from './actions/add-product-note/types';
175
177
  export type { AddProductFee, AddProductFeeParams, AddProductFeeResponse } from './actions/add-product-fee/types';
176
178
  export type { GetProductModifierSelections, GetProductModifierSelectionsParams, GetProductModifierSelectionsResponse, ModifierSelection, ModifierChoiceSelection, } from './actions/get-product-modifier-selections/types';
179
+ export type { GetCompositePrice, GetCompositePriceParams, GetCompositePriceResponse, } from './actions/get-composite-price/types';
180
+ export type { GetCompositeAvailability, GetCompositeAvailabilityParams, GetCompositeAvailabilityResponse, CFCompositeSlot, } from './actions/get-composite-availability/types';
177
181
  export type { SetProductModifierSelections, SetProductModifierSelectionsParams, SetProductModifierSelectionsResponse, } from './actions/set-product-modifier-selections/types';
178
182
  export type { SetActiveProductFee, SetActiveProductFeeParams, SetActiveProductFeeResponse, } from './actions/set-active-product-fee/types';
179
183
  export type { SetActiveProductDiscount, SetActiveProductDiscountParams, SetActiveProductDiscountResponse, } from './actions/set-active-product-discount/types';
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@ import { updateCartItemQuantity } from './actions/update-cart-item-quantity/acti
28
28
  import { addProductNote } from './actions/add-product-note/action';
29
29
  import { addProductFee } from './actions/add-product-fee/action';
30
30
  import { getProductModifierSelections } from './actions/get-product-modifier-selections/action';
31
+ import { getCompositePrice } from './actions/get-composite-price/action';
32
+ import { getCompositeAvailability } from './actions/get-composite-availability/action';
31
33
  import { setProductModifierSelections } from './actions/set-product-modifier-selections/action';
32
34
  import { setActiveProductFee } from './actions/set-active-product-fee/action';
33
35
  import { setActiveProductDiscount } from './actions/set-active-product-discount/action';
@@ -174,6 +176,8 @@ export const command = {
174
176
  addProductNote,
175
177
  addProductFee,
176
178
  getProductModifierSelections,
179
+ getCompositePrice,
180
+ getCompositeAvailability,
177
181
  setProductModifierSelections,
178
182
  setActiveProductFee,
179
183
  setActiveProductDiscount,
@@ -13,6 +13,8 @@ import { mockRemoveOrderNote } from '../../actions/remove-order-note/mock';
13
13
  import { mockAddProductDiscount } from '../../actions/add-product-discount/mock';
14
14
  import { mockAddProductFee } from '../../actions/add-product-fee/mock';
15
15
  import { mockGetProductModifierSelections } from '../../actions/get-product-modifier-selections/mock';
16
+ import { mockGetCompositePrice } from '../../actions/get-composite-price/mock';
17
+ import { mockGetCompositeAvailability } from '../../actions/get-composite-availability/mock';
16
18
  import { mockSetProductModifierSelections } from '../../actions/set-product-modifier-selections/mock';
17
19
  import { mockRemoveProductDiscount } from '../../actions/remove-product-discount/mock';
18
20
  import { mockRemoveProductFee } from '../../actions/remove-product-fee/mock';
@@ -122,6 +124,8 @@ export const RENDER_MOCKS = {
122
124
  addProductDiscount: mockAddProductDiscount,
123
125
  addProductFee: mockAddProductFee,
124
126
  getProductModifierSelections: mockGetProductModifierSelections,
127
+ getCompositePrice: mockGetCompositePrice,
128
+ getCompositeAvailability: mockGetCompositeAvailability,
125
129
  setProductModifierSelections: mockSetProductModifierSelections,
126
130
  setActiveProductFee: mockSetActiveProductFee,
127
131
  setActiveProductDiscount: mockSetActiveProductDiscount,
@@ -1,4 +1,4 @@
1
- import type { ExampleFunction, GetProducts, AddCustomSale, EditCustomSale, GetCustomers, AssignCustomer, AddCustomer, EditCustomer, GetCategories, GetOrders, GetRefunds, GetTaxTables, AddProductDiscount, AddProductToCart, RemoveProductFromCart, UpdateCartItemQuantity, AddCartDiscount, GetContext, GetFinalContext, AddProductNote, AddProductFee, GetProductModifierSelections, SetProductModifierSelections, SetActiveProductFee, SetActiveProductDiscount, GetActiveProduct, SetActiveProduct, AdjustInventory, AddOrderNote, AddCartFee, ClearCart, ParkOrder, ResumeParkedOrder, DeleteParkedOrder, VoidOrder, InitiateRefund, CashPayment, GetCashRoundingAmount, TapToPayPayment, TerminalPayment, ExtensionPayment, RedeemPayment, AddNonRevenueItem, AddCustomerNote, RemoveCustomerNote, RemoveCustomerFromCart, GoToStationHome, OpenCashDrawer, ShowNotification, ShowConfirmation, AuthenticateUser, PartialPayment, SwitchUser, SetRefundStockAction, SelectAllRefundItems, ResetRefundDetails, CalculateRefundTotal, GetRemainingRefundableQuantities, ProcessPartialRefund, RedeemRefund, GetRefundPlan, CheckPermission, GetCurrentCart, Print, SetActiveOrder, GetCustomTables, GetCustomTableData, UpsertCustomTableData, DeleteCustomTableData, GetCustomExtensions, GetCurrentCompanyCustomExtensions, GetCustomExtensionCustomTables, GetCustomTableFields, GetSecretsKeys, GetSecretVal, SetSecretVal, GetUsers, GetRoles, RemoveCartDiscount, GetActiveOrder, GetActiveCustomer, SetActiveCustomer, GetActiveOutlet, GetActiveStation, GetActiveSession, GetActiveUser, SetActiveUser, SetActiveRefund, RemoveProductDiscount, RemoveProductFee, RemoveProductNote, RemoveCartFee, RemoveOrderNote, RemoveCustomSale, RemoveNonRevenueItem, CanTransition, GetAvailableTransitions, ApplyTransition, IntegrationPayment, GetSmartGridLayout, SaveSmartGridLayout, SendEmail, SendSms, GetBookingResources, GetBookingAvailability, GetBookings, HoldBooking, AddBookingToCart, RemoveBookingFromCart, CancelBooking, CreatePaymentLink, ChargeMoto, GetTimeClockStatus } from '../../index';
1
+ import type { ExampleFunction, GetProducts, AddCustomSale, EditCustomSale, GetCustomers, AssignCustomer, AddCustomer, EditCustomer, GetCategories, GetOrders, GetRefunds, GetTaxTables, AddProductDiscount, AddProductToCart, RemoveProductFromCart, UpdateCartItemQuantity, AddCartDiscount, GetContext, GetFinalContext, AddProductNote, AddProductFee, GetProductModifierSelections, GetCompositePrice, GetCompositeAvailability, SetProductModifierSelections, SetActiveProductFee, SetActiveProductDiscount, GetActiveProduct, SetActiveProduct, AdjustInventory, AddOrderNote, AddCartFee, ClearCart, ParkOrder, ResumeParkedOrder, DeleteParkedOrder, VoidOrder, InitiateRefund, CashPayment, GetCashRoundingAmount, TapToPayPayment, TerminalPayment, ExtensionPayment, RedeemPayment, AddNonRevenueItem, AddCustomerNote, RemoveCustomerNote, RemoveCustomerFromCart, GoToStationHome, OpenCashDrawer, ShowNotification, ShowConfirmation, AuthenticateUser, PartialPayment, SwitchUser, SetRefundStockAction, SelectAllRefundItems, ResetRefundDetails, CalculateRefundTotal, GetRemainingRefundableQuantities, ProcessPartialRefund, RedeemRefund, GetRefundPlan, CheckPermission, GetCurrentCart, Print, SetActiveOrder, GetCustomTables, GetCustomTableData, UpsertCustomTableData, DeleteCustomTableData, GetCustomExtensions, GetCurrentCompanyCustomExtensions, GetCustomExtensionCustomTables, GetCustomTableFields, GetSecretsKeys, GetSecretVal, SetSecretVal, GetUsers, GetRoles, RemoveCartDiscount, GetActiveOrder, GetActiveCustomer, SetActiveCustomer, GetActiveOutlet, GetActiveStation, GetActiveSession, GetActiveUser, SetActiveUser, SetActiveRefund, RemoveProductDiscount, RemoveProductFee, RemoveProductNote, RemoveCartFee, RemoveOrderNote, RemoveCustomSale, RemoveNonRevenueItem, CanTransition, GetAvailableTransitions, ApplyTransition, IntegrationPayment, GetSmartGridLayout, SaveSmartGridLayout, SendEmail, SendSms, GetBookingResources, GetBookingAvailability, GetBookings, HoldBooking, AddBookingToCart, RemoveBookingFromCart, CancelBooking, CreatePaymentLink, ChargeMoto, GetTimeClockStatus } from '../../index';
2
2
  export interface RenderProviderActions {
3
3
  exampleFunction: ExampleFunction;
4
4
  getProducts: GetProducts;
@@ -29,6 +29,8 @@ export interface RenderProviderActions {
29
29
  addProductNote: AddProductNote;
30
30
  addProductFee: AddProductFee;
31
31
  getProductModifierSelections: GetProductModifierSelections;
32
+ getCompositePrice: GetCompositePrice;
33
+ getCompositeAvailability: GetCompositeAvailability;
32
34
  setProductModifierSelections: SetProductModifierSelections;
33
35
  setActiveProductFee: SetActiveProductFee;
34
36
  setActiveProductDiscount: SetActiveProductDiscount;
@@ -0,0 +1,17 @@
1
+ import type { TopicEvent } from '../../../types';
2
+ /**
3
+ * Payload for catalog-visibility-changed: a product was hidden or shown at an outlet. `getProducts` leaves hidden
4
+ * products out and a composite offers only items sold at the till's outlet — debounce, then re-ask `getProducts`.
5
+ */
6
+ export interface CatalogVisibilityChangedPayload {
7
+ visibility: {
8
+ _id: string;
9
+ productId: string;
10
+ outletId: string;
11
+ isDeleted?: boolean;
12
+ };
13
+ }
14
+ /**
15
+ * Typed event for catalog-visibility-changed
16
+ */
17
+ export type CatalogVisibilityChangedEvent = TopicEvent<CatalogVisibilityChangedPayload>;
@@ -0,0 +1,17 @@
1
+ import type { TopicEvent } from '../../../types';
2
+ /**
3
+ * Payload for composite-changed (FT-83): a part or item of a composite arrived. A save replaces all of a composite's
4
+ * parts and items at once (old rows come as `isDeleted: true`, new ones with new ids), so one save fires this many
5
+ * times — debounce, then re-ask `getProducts`; never patch `product.composite` from the row.
6
+ */
7
+ export interface CompositeChangedPayload {
8
+ compositeRow: {
9
+ _id: string;
10
+ compositeProductId: string;
11
+ isDeleted?: boolean;
12
+ };
13
+ }
14
+ /**
15
+ * Typed event for composite-changed
16
+ */
17
+ export type CompositeChangedEvent = TopicEvent<CompositeChangedPayload>;
@@ -2,6 +2,6 @@
2
2
  * Products Topic Definition
3
3
  * Defines the products topic and its available event types
4
4
  */
5
- import type { TopicDefinition } from "../../types";
5
+ import type { TopicDefinition } from '../../types';
6
6
  export declare const productsTopic: TopicDefinition;
7
- export * from "./types";
7
+ export * from './types';