@frontdesk-africa/store-js 0.1.0 → 0.3.0

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.
package/README.md CHANGED
@@ -49,6 +49,27 @@ const item = await store.product('blue-mug')
49
49
 
50
50
  You never choose the workspace: the key does. Any workspace ref you send is ignored.
51
51
 
52
+ ### Products sold in several dimensions
53
+
54
+ A product whose merchant sells it by Size *and* Colour carries an `options` array: one entry per
55
+ group, each with its own `display` (`dropdown`, `text`, `color`, `image`) and its list of values.
56
+ The buyer picks one value per group, and that set resolves to a single variant — the one whose
57
+ `optionValueRefs` holds exactly those value refs. Checkout is unchanged: you still send that
58
+ variant's `ref`.
59
+
60
+ ```ts
61
+ const item = await store.product('shirt-dress')
62
+
63
+ // options is absent on an ordinary product — render item.variants as a flat list then.
64
+ const picked = { [item.options![0].ref]: 'value-ref-small', [item.options![1].ref]: 'value-ref-teal' }
65
+ const chosen = item.variants.find(
66
+ (v) => v.optionValueRefs?.length === 2 && v.optionValueRefs.every((r) => Object.values(picked).includes(r)),
67
+ )
68
+ ```
69
+
70
+ Dim a value when every variant still holding it is `soldOut` (or has no price in the buyer's
71
+ currency), and swap your gallery to a value's `media` when it is selected.
72
+
52
73
  ## Checkout (server)
53
74
 
54
75
  You never handle card details and you never price a cart. The buyer pays on a FrontDesk-hosted page
@@ -67,6 +88,10 @@ const checkout = await store.createCheckout(
67
88
  redirect(checkout.hostedUrl)
68
89
  ```
69
90
 
91
+ Every `variantRef` must be a live one from the catalogue. An unknown or unpublished ref is refused
92
+ with `400 VALIDATION_ERROR` listing the bad refs in `error.details.invalid`, so a typo fails on your
93
+ server instead of in front of your buyer.
94
+
70
95
  Then, when the buyer comes back:
71
96
 
72
97
  ```ts
@@ -131,15 +156,39 @@ the check will fail in a way that looks like a key mismatch.
131
156
  Payloads are thin — refs only. Re-fetch through the API for detail, so a replayed delivery can never
132
157
  present stale figures as current.
133
158
 
159
+ ## Orders + keeping a local copy in sync
160
+
161
+ `orders()` (server only, secret key) lists your own orders as a **delta read**: pass the largest
162
+ `updatedAt` you have seen back as `updatedSince` and upsert by `ref` — the `>=` comparison repeats
163
+ the boundary row on purpose so a same-second tie can never drop one. `products({ updatedSince })`
164
+ works the same way for the catalogue.
165
+
166
+ ```ts
167
+ let cursor = loadCursor() // null on first run = full backfill
168
+ for (;;) {
169
+ const page = await store.orders(cursor ? { updatedSince: cursor, limit: 200 } : { limit: 200 })
170
+ for (const order of page) upsertByRef(order)
171
+ if (page.length < 200) break
172
+ cursor = page[page.length - 1].updatedAt
173
+ }
174
+ ```
175
+
176
+ Deletions never show up as absence in a delta page: they arrive as the `product.deleted` and
177
+ `order.deleted` webhooks — remove those refs from your copy when they land. FrontDesk stays the
178
+ source of truth; on any conflict, its row wins.
179
+
180
+ Confirming a single purchase is still `getCheckout(ref)`, not a scan of `orders()`.
181
+
134
182
  ## Test mode
135
183
 
136
- A `fd_sk_test_…` key opens a test checkout. The hosted page shows a test banner and a
137
- **Simulate a successful payment** button, so you can build the whole loop with no bank account and no
138
- spend.
184
+ A `fd_sk_test_…` key opens a test checkout that completes with a **real charge on the provider's
185
+ sandbox** pay it with a Paystack test card (the popup offers a Success option). There is exactly
186
+ one way to finish a checkout in either mode.
139
187
 
140
- A simulated payment completes the session, sets a synthetic `test_ord_…` ref, and fires
141
- `checkout.completed` with `"test": true`. It creates **no order**, credits nobody and posts no ledger
142
- entry so do not try to fetch that order ref. Test mode proves your integration, not fulfilment.
188
+ The order it creates is REAL and marked `test` everywhere: no money moves, nothing is held from
189
+ stock, and `checkout.completed` fires with the real `orderRef`. Test orders show under a Test badge
190
+ in the portal, only a test key can read them (`orders()` never mixes envs), and they are
191
+ hard-deleted after 90 days — the `order.deleted` webhook tells your mirror when that happens.
143
192
 
144
193
  ---
145
194
 
package/dist/index.d.mts CHANGED
@@ -576,7 +576,13 @@ type PublicProductInfo = Omit<ProductInfoView, 'hsCode'>;
576
576
  /** How a package product's options are chosen: one tier (radio) vs a menu (pick many + quantity). */
577
577
  type SelectionMode = 'choose_one' | 'choose_many';
578
578
  /** How the storefront draws a product's option picker. A variant missing an image/hex falls back to text. */
579
- type VariantDisplay = 'text' | 'image' | 'color';
579
+ type VariantDisplay = 'text' | 'image' | 'color' | 'dropdown';
580
+ /**
581
+ * Multi-group variations: how ONE variation group's picker is drawn. Per-group (Size = dropdown,
582
+ * Color = swatches), unlike the legacy product-level VariantDisplay, which keeps governing products
583
+ * that have no groups. A value missing its hex/image falls back to a text chip.
584
+ */
585
+ type OptionGroupDisplay = 'dropdown' | 'text' | 'color' | 'image';
580
586
  /** Product/option payment modes: the shipped package modes + Pay Later (Save-For-It deferred). */
581
587
  type ProductPricingMode = PackagePricingMode | 'pay_later';
582
588
  interface ProductAddonOption {
@@ -645,6 +651,23 @@ interface StoreAvailabilitySummary {
645
651
  leadTimeMinutes: number;
646
652
  }
647
653
  type PreorderDepositType = 'percent' | 'fixed';
654
+ interface PublicProductOptionValue {
655
+ ref: string;
656
+ label: string;
657
+ /** '#RRGGBB' for the colour swatch under display 'color'; absent/null falls back to a text chip. */
658
+ swatchHex?: string | null;
659
+ /** Selecting this value swaps the product gallery to these images. Absent = keep the gallery. */
660
+ media?: string[];
661
+ }
662
+ /** One variation axis of a multi-group product (e.g. Size, Color). The buyer picks one value per
663
+ * group; the picked set resolves to exactly one variant (see PublicProductVariant.optionValueRefs). */
664
+ interface PublicProductOptionGroup {
665
+ ref: string;
666
+ name: string;
667
+ /** How to draw THIS group's picker: a select, text chips, colour swatches, or image tiles. */
668
+ display: OptionGroupDisplay;
669
+ values: PublicProductOptionValue[];
670
+ }
648
671
  interface PublicProductVariant {
649
672
  ref: string;
650
673
  name: string;
@@ -668,6 +691,9 @@ interface PublicProductVariant {
668
691
  availableQty?: number | null;
669
692
  /** '#RRGGBB' for the colour swatch; null under 'color' mode falls back to a text chip. */
670
693
  swatchHex?: string | null;
694
+ /** Multi-group products: the option values this combination is made of (one value ref per group,
695
+ * in group sort order). Absent on ungrouped products — render the flat `variants` picker then. */
696
+ optionValueRefs?: string[];
671
697
  description?: string | null;
672
698
  badge?: string | null;
673
699
  features?: PackageFeature[];
@@ -702,6 +728,10 @@ interface PublicProduct {
702
728
  selectionMode?: SelectionMode;
703
729
  /** How the option picker is drawn: text chips (default), image swatches, or colour swatches. */
704
730
  variantDisplay?: VariantDisplay;
731
+ /** Multi-group variations: one picker per group, each drawn per its `display`. When present, the
732
+ * buyer picks one value per group and the picks resolve to a variant via `optionValueRefs`; when
733
+ * absent, render the flat `variants` list exactly as before (existing storefronts keep working). */
734
+ options?: PublicProductOptionGroup[];
705
735
  schedulingEnabled?: boolean;
706
736
  /** Add-on groups the buyer can pick from (option-scoped groups carry variantRef). */
707
737
  addonGroups?: ProductAddonGroup[];
@@ -822,6 +852,9 @@ interface PublicProductSummary {
822
852
  ratingAvg?: number;
823
853
  /** Published review count behind `ratingAvg`. Absent under the floor, for the same reason. */
824
854
  ratingCount?: number;
855
+ /** Last change to the product or its variants (ISO). On GET /v1/store/products this is the
856
+ * `updatedSince` delta cursor: pass the largest value you have seen back to get what changed. */
857
+ updatedAt?: string;
825
858
  }
826
859
  type CollectionCoverKind = 'image' | 'video';
827
860
  /** Buyer-facing collection in the storefront payload / public endpoints. */
@@ -860,6 +893,14 @@ interface DeliveryZone {
860
893
  * payloads and on single-currency stores. */
861
894
  prices?: ZoneFee[];
862
895
  }
896
+ /**
897
+ * Store order lifecycle — the union of the standard product-cart axis (pending → paid → fulfilled) and
898
+ * the package-BOOKING axis (requested → awaiting_payment → confirmed → in_progress → completed). One
899
+ * table (store_orders) carries both since Unified Products; the DB enum matches this set.
900
+ */
901
+ type StoreOrderStatus = 'pending' | 'awaiting_payment' | 'requested' | 'accepted' | 'declined' | 'confirmed' | 'in_progress' | 'paid' | 'completed' | 'fulfilled' | 'cancelled' | 'expired' | 'refunded' | 'archived'
902
+ /** Held while a Save For It plan funds toward this product (no stock reserved); flips to `paid` on completion. */
903
+ | 'saving';
863
904
  interface StoreCheckoutItem {
864
905
  variantRef: string;
865
906
  quantity: number;
@@ -879,6 +920,14 @@ interface StoreShippingAddress {
879
920
  lat?: number | null;
880
921
  lng?: number | null;
881
922
  }
923
+ /** `awaiting_stock` = paid, but the item was sold out when the Save For It plan completed. The merchant
924
+ * owes a unit they do not have; the order must stay OUT of the ship-now queue until they restock, at
925
+ * which point it clears back to `unfulfilled` on its own. */
926
+ /** `preordered` = the buyer deliberately bought something the merchant does not have yet (PO-1). Kept
927
+ * distinct from `awaiting_stock` because the merchant's queue has to tell "a saver's plan completed and
928
+ * I owe them" apart from "I pre-sold this on purpose", and because a part-paid pre-order must NOT rejoin
929
+ * the ship-now queue on restock. */
930
+ type StoreOrderFulfillmentStatus = 'unfulfilled' | 'fulfilled' | 'shipped' | 'delivered' | 'awaiting_stock' | 'preordered';
882
931
 
883
932
  /**
884
933
  * Packages — sellable offerings + priced tiers (options). Spec §13.1, §10.18 (PV-012/054).
@@ -966,7 +1015,12 @@ interface StoreTicketItem {
966
1015
  */
967
1016
  interface StoreEventCheckoutInput {
968
1017
  tickets: StoreTicketItem[];
969
- /** Products already attached to this event. Not the general catalogue. */
1018
+ /**
1019
+ * 🔴 NOT SUPPORTED YET — a non-empty array is refused with 400. It was declared with D18 and never
1020
+ * wired: the hosted ticket page has no product concept, so anything sent here reached the buyer as
1021
+ * neither a line nor a charge. Kept on the type because the contract is frozen (D13). Sell
1022
+ * merchandise with a separate POST /v1/store/checkouts until the hosted page can carry it.
1023
+ */
970
1024
  products?: StoreCheckoutItem[] | null;
971
1025
  contact: StoreCheckoutContact;
972
1026
  /** Buyer DOB, when the event uses order-level age checks. YYYY-MM-DD. */
@@ -1042,6 +1096,92 @@ interface StoreCheckoutSessionView {
1042
1096
  createdAt: string;
1043
1097
  }
1044
1098
 
1099
+ /**
1100
+ * GET /v1/store/orders — the merchant's own store orders, over their SECRET key.
1101
+ *
1102
+ * Built for delta sync (the Supabase mirror pulls through `updatedSince`), which is why the shape
1103
+ * is a snapshot a mirror can upsert whole: refs, money in minor units, line snapshots that survive
1104
+ * product edits. Live and test orders never mix — the key's env picks the set, exactly like
1105
+ * checkouts. What is NOT here is deliberate: no provider references, no ledger refs, no wallet or
1106
+ * payout figures — the standing constraint (no money surface behind an installed key) applies to
1107
+ * this read exactly as it does to mirrors.
1108
+ */
1109
+ interface StoreOrderRecordLine {
1110
+ /** Snapshot refs: null when the product/variant was deleted after purchase. */
1111
+ productRef: string | null;
1112
+ productName: string;
1113
+ variantRef: string | null;
1114
+ variantName: string;
1115
+ imageUrl: string | null;
1116
+ quantity: number;
1117
+ unitPriceMinor: number;
1118
+ subtotalMinor: number;
1119
+ }
1120
+ interface StoreOrderRecord {
1121
+ ref: string;
1122
+ /** The short human reference shown to the buyer (receipts, support). */
1123
+ shortReference: string;
1124
+ /** The full lifecycle union covers BOTH axes: product carts (pending → paid → fulfilled) and
1125
+ * package bookings (requested → awaiting_payment → confirmed → in_progress → completed).
1126
+ * `saving` (an in-progress savings plan) never appears here — it is not an order yet. */
1127
+ status: Exclude<StoreOrderStatus, 'saving'>;
1128
+ fulfillmentStatus: StoreOrderFulfillmentStatus;
1129
+ /** Paid with a test key: no real money moved, hard-deleted after 90 days (`order.deleted`). */
1130
+ test: boolean;
1131
+ currency: string;
1132
+ subtotalMinor: number;
1133
+ deliveryFeeMinor: number;
1134
+ totalMinor: number;
1135
+ buyer: {
1136
+ name: string;
1137
+ email: string | null;
1138
+ phone: string | null;
1139
+ };
1140
+ needsDelivery: boolean;
1141
+ shipping: {
1142
+ address1: string | null;
1143
+ address2: string | null;
1144
+ city: string | null;
1145
+ state: string | null;
1146
+ country: string | null;
1147
+ zoneRef: string | null;
1148
+ zoneName: string | null;
1149
+ } | null;
1150
+ lines: StoreOrderRecordLine[];
1151
+ paidAt: string | null;
1152
+ createdAt: string;
1153
+ /** The delta cursor: pass the largest value you have seen back as `updatedSince`. */
1154
+ updatedAt: string;
1155
+ }
1156
+
1157
+ /**
1158
+ * `POST /v1/store/customers/link` — attach the caller's own signed-in user to this workspace's
1159
+ * contacts (Supabase Phase 1c; design doc §7.6).
1160
+ *
1161
+ * TRUST MODEL: the caller holds a secret key, i.e. IS the merchant (or their installed backend).
1162
+ * They can already create and edit their own contacts in the portal, so accepting their assertion
1163
+ * that `email` was verified by THEIR auth system delegates nothing they do not have. The identity
1164
+ * being asserted must still be verified on the caller's side — that is stated at the API boundary,
1165
+ * not enforced here, because we cannot see their auth system.
1166
+ */
1167
+ interface StoreCustomerLinkInput {
1168
+ /** The email the CALLER's auth system verified. Never a guessed or user-typed-but-unverified one. */
1169
+ email: string;
1170
+ /** The caller's own user id (e.g. a Supabase Auth user id). Idempotency key: same ref, same link. */
1171
+ externalRef: string;
1172
+ /** Optional display name; wins over an empty contact name, never overwrites a set one. */
1173
+ name?: string;
1174
+ phone?: string;
1175
+ }
1176
+ interface StoreCustomerLinkView {
1177
+ /** The buyer's account ref — what `customer.linked` carries and checkout accepts. */
1178
+ customerRef: string;
1179
+ /** The workspace contact this landed on. */
1180
+ contactRef: string;
1181
+ /** True when this call created the link; false when the externalRef was already linked. */
1182
+ alreadyLinked: boolean;
1183
+ }
1184
+
1045
1185
  /** A resolved commission rate (after applying any per-workspace override over the platform default). */
1046
1186
  interface EventCommissionConfig {
1047
1187
  /** Percentage cut on the gross (0..100). */
@@ -2341,7 +2481,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2341
2481
  idempotencyKey?: string;
2342
2482
  }) => Promise<T>;
2343
2483
  storefront: () => Promise<Storefront>;
2344
- products: () => Promise<PublicProductSummary[]>;
2484
+ /**
2485
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
2486
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
2487
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
2488
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
2489
+ */
2490
+ products: (params?: {
2491
+ updatedSince?: string;
2492
+ limit?: number;
2493
+ }) => Promise<PublicProductSummary[]>;
2345
2494
  product: (slug: string) => Promise<PublicProduct>;
2346
2495
  productSlots: (slug: string, opts: {
2347
2496
  variantRef: string;
@@ -2398,6 +2547,23 @@ declare function createStoreClient(opts: StoreClientOptions): {
2398
2547
  * stock until the session lapses. Cancelling an already-paid checkout returns 409.
2399
2548
  */
2400
2549
  cancelCheckout: (ref: string) => Promise<StoreCheckoutSessionView>;
2550
+ /**
2551
+ * Attach YOUR OWN signed-in user to this workspace's contacts. SERVER ONLY. Only send an email
2552
+ * your auth system verified. Idempotent per externalRef, and the response is identical whether
2553
+ * the contact existed before — it cannot be used to probe who is already a customer.
2554
+ */
2555
+ linkCustomer: (input: StoreCustomerLinkInput) => Promise<StoreCustomerLinkView>;
2556
+ /**
2557
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
2558
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
2559
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
2560
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
2561
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
2562
+ */
2563
+ orders: (params?: {
2564
+ updatedSince?: string;
2565
+ limit?: number;
2566
+ }) => Promise<StoreOrderRecord[]>;
2401
2567
  };
2402
2568
  type StoreClient = ReturnType<typeof createStoreClient>;
2403
2569
  /**
package/dist/index.d.ts CHANGED
@@ -576,7 +576,13 @@ type PublicProductInfo = Omit<ProductInfoView, 'hsCode'>;
576
576
  /** How a package product's options are chosen: one tier (radio) vs a menu (pick many + quantity). */
577
577
  type SelectionMode = 'choose_one' | 'choose_many';
578
578
  /** How the storefront draws a product's option picker. A variant missing an image/hex falls back to text. */
579
- type VariantDisplay = 'text' | 'image' | 'color';
579
+ type VariantDisplay = 'text' | 'image' | 'color' | 'dropdown';
580
+ /**
581
+ * Multi-group variations: how ONE variation group's picker is drawn. Per-group (Size = dropdown,
582
+ * Color = swatches), unlike the legacy product-level VariantDisplay, which keeps governing products
583
+ * that have no groups. A value missing its hex/image falls back to a text chip.
584
+ */
585
+ type OptionGroupDisplay = 'dropdown' | 'text' | 'color' | 'image';
580
586
  /** Product/option payment modes: the shipped package modes + Pay Later (Save-For-It deferred). */
581
587
  type ProductPricingMode = PackagePricingMode | 'pay_later';
582
588
  interface ProductAddonOption {
@@ -645,6 +651,23 @@ interface StoreAvailabilitySummary {
645
651
  leadTimeMinutes: number;
646
652
  }
647
653
  type PreorderDepositType = 'percent' | 'fixed';
654
+ interface PublicProductOptionValue {
655
+ ref: string;
656
+ label: string;
657
+ /** '#RRGGBB' for the colour swatch under display 'color'; absent/null falls back to a text chip. */
658
+ swatchHex?: string | null;
659
+ /** Selecting this value swaps the product gallery to these images. Absent = keep the gallery. */
660
+ media?: string[];
661
+ }
662
+ /** One variation axis of a multi-group product (e.g. Size, Color). The buyer picks one value per
663
+ * group; the picked set resolves to exactly one variant (see PublicProductVariant.optionValueRefs). */
664
+ interface PublicProductOptionGroup {
665
+ ref: string;
666
+ name: string;
667
+ /** How to draw THIS group's picker: a select, text chips, colour swatches, or image tiles. */
668
+ display: OptionGroupDisplay;
669
+ values: PublicProductOptionValue[];
670
+ }
648
671
  interface PublicProductVariant {
649
672
  ref: string;
650
673
  name: string;
@@ -668,6 +691,9 @@ interface PublicProductVariant {
668
691
  availableQty?: number | null;
669
692
  /** '#RRGGBB' for the colour swatch; null under 'color' mode falls back to a text chip. */
670
693
  swatchHex?: string | null;
694
+ /** Multi-group products: the option values this combination is made of (one value ref per group,
695
+ * in group sort order). Absent on ungrouped products — render the flat `variants` picker then. */
696
+ optionValueRefs?: string[];
671
697
  description?: string | null;
672
698
  badge?: string | null;
673
699
  features?: PackageFeature[];
@@ -702,6 +728,10 @@ interface PublicProduct {
702
728
  selectionMode?: SelectionMode;
703
729
  /** How the option picker is drawn: text chips (default), image swatches, or colour swatches. */
704
730
  variantDisplay?: VariantDisplay;
731
+ /** Multi-group variations: one picker per group, each drawn per its `display`. When present, the
732
+ * buyer picks one value per group and the picks resolve to a variant via `optionValueRefs`; when
733
+ * absent, render the flat `variants` list exactly as before (existing storefronts keep working). */
734
+ options?: PublicProductOptionGroup[];
705
735
  schedulingEnabled?: boolean;
706
736
  /** Add-on groups the buyer can pick from (option-scoped groups carry variantRef). */
707
737
  addonGroups?: ProductAddonGroup[];
@@ -822,6 +852,9 @@ interface PublicProductSummary {
822
852
  ratingAvg?: number;
823
853
  /** Published review count behind `ratingAvg`. Absent under the floor, for the same reason. */
824
854
  ratingCount?: number;
855
+ /** Last change to the product or its variants (ISO). On GET /v1/store/products this is the
856
+ * `updatedSince` delta cursor: pass the largest value you have seen back to get what changed. */
857
+ updatedAt?: string;
825
858
  }
826
859
  type CollectionCoverKind = 'image' | 'video';
827
860
  /** Buyer-facing collection in the storefront payload / public endpoints. */
@@ -860,6 +893,14 @@ interface DeliveryZone {
860
893
  * payloads and on single-currency stores. */
861
894
  prices?: ZoneFee[];
862
895
  }
896
+ /**
897
+ * Store order lifecycle — the union of the standard product-cart axis (pending → paid → fulfilled) and
898
+ * the package-BOOKING axis (requested → awaiting_payment → confirmed → in_progress → completed). One
899
+ * table (store_orders) carries both since Unified Products; the DB enum matches this set.
900
+ */
901
+ type StoreOrderStatus = 'pending' | 'awaiting_payment' | 'requested' | 'accepted' | 'declined' | 'confirmed' | 'in_progress' | 'paid' | 'completed' | 'fulfilled' | 'cancelled' | 'expired' | 'refunded' | 'archived'
902
+ /** Held while a Save For It plan funds toward this product (no stock reserved); flips to `paid` on completion. */
903
+ | 'saving';
863
904
  interface StoreCheckoutItem {
864
905
  variantRef: string;
865
906
  quantity: number;
@@ -879,6 +920,14 @@ interface StoreShippingAddress {
879
920
  lat?: number | null;
880
921
  lng?: number | null;
881
922
  }
923
+ /** `awaiting_stock` = paid, but the item was sold out when the Save For It plan completed. The merchant
924
+ * owes a unit they do not have; the order must stay OUT of the ship-now queue until they restock, at
925
+ * which point it clears back to `unfulfilled` on its own. */
926
+ /** `preordered` = the buyer deliberately bought something the merchant does not have yet (PO-1). Kept
927
+ * distinct from `awaiting_stock` because the merchant's queue has to tell "a saver's plan completed and
928
+ * I owe them" apart from "I pre-sold this on purpose", and because a part-paid pre-order must NOT rejoin
929
+ * the ship-now queue on restock. */
930
+ type StoreOrderFulfillmentStatus = 'unfulfilled' | 'fulfilled' | 'shipped' | 'delivered' | 'awaiting_stock' | 'preordered';
882
931
 
883
932
  /**
884
933
  * Packages — sellable offerings + priced tiers (options). Spec §13.1, §10.18 (PV-012/054).
@@ -966,7 +1015,12 @@ interface StoreTicketItem {
966
1015
  */
967
1016
  interface StoreEventCheckoutInput {
968
1017
  tickets: StoreTicketItem[];
969
- /** Products already attached to this event. Not the general catalogue. */
1018
+ /**
1019
+ * 🔴 NOT SUPPORTED YET — a non-empty array is refused with 400. It was declared with D18 and never
1020
+ * wired: the hosted ticket page has no product concept, so anything sent here reached the buyer as
1021
+ * neither a line nor a charge. Kept on the type because the contract is frozen (D13). Sell
1022
+ * merchandise with a separate POST /v1/store/checkouts until the hosted page can carry it.
1023
+ */
970
1024
  products?: StoreCheckoutItem[] | null;
971
1025
  contact: StoreCheckoutContact;
972
1026
  /** Buyer DOB, when the event uses order-level age checks. YYYY-MM-DD. */
@@ -1042,6 +1096,92 @@ interface StoreCheckoutSessionView {
1042
1096
  createdAt: string;
1043
1097
  }
1044
1098
 
1099
+ /**
1100
+ * GET /v1/store/orders — the merchant's own store orders, over their SECRET key.
1101
+ *
1102
+ * Built for delta sync (the Supabase mirror pulls through `updatedSince`), which is why the shape
1103
+ * is a snapshot a mirror can upsert whole: refs, money in minor units, line snapshots that survive
1104
+ * product edits. Live and test orders never mix — the key's env picks the set, exactly like
1105
+ * checkouts. What is NOT here is deliberate: no provider references, no ledger refs, no wallet or
1106
+ * payout figures — the standing constraint (no money surface behind an installed key) applies to
1107
+ * this read exactly as it does to mirrors.
1108
+ */
1109
+ interface StoreOrderRecordLine {
1110
+ /** Snapshot refs: null when the product/variant was deleted after purchase. */
1111
+ productRef: string | null;
1112
+ productName: string;
1113
+ variantRef: string | null;
1114
+ variantName: string;
1115
+ imageUrl: string | null;
1116
+ quantity: number;
1117
+ unitPriceMinor: number;
1118
+ subtotalMinor: number;
1119
+ }
1120
+ interface StoreOrderRecord {
1121
+ ref: string;
1122
+ /** The short human reference shown to the buyer (receipts, support). */
1123
+ shortReference: string;
1124
+ /** The full lifecycle union covers BOTH axes: product carts (pending → paid → fulfilled) and
1125
+ * package bookings (requested → awaiting_payment → confirmed → in_progress → completed).
1126
+ * `saving` (an in-progress savings plan) never appears here — it is not an order yet. */
1127
+ status: Exclude<StoreOrderStatus, 'saving'>;
1128
+ fulfillmentStatus: StoreOrderFulfillmentStatus;
1129
+ /** Paid with a test key: no real money moved, hard-deleted after 90 days (`order.deleted`). */
1130
+ test: boolean;
1131
+ currency: string;
1132
+ subtotalMinor: number;
1133
+ deliveryFeeMinor: number;
1134
+ totalMinor: number;
1135
+ buyer: {
1136
+ name: string;
1137
+ email: string | null;
1138
+ phone: string | null;
1139
+ };
1140
+ needsDelivery: boolean;
1141
+ shipping: {
1142
+ address1: string | null;
1143
+ address2: string | null;
1144
+ city: string | null;
1145
+ state: string | null;
1146
+ country: string | null;
1147
+ zoneRef: string | null;
1148
+ zoneName: string | null;
1149
+ } | null;
1150
+ lines: StoreOrderRecordLine[];
1151
+ paidAt: string | null;
1152
+ createdAt: string;
1153
+ /** The delta cursor: pass the largest value you have seen back as `updatedSince`. */
1154
+ updatedAt: string;
1155
+ }
1156
+
1157
+ /**
1158
+ * `POST /v1/store/customers/link` — attach the caller's own signed-in user to this workspace's
1159
+ * contacts (Supabase Phase 1c; design doc §7.6).
1160
+ *
1161
+ * TRUST MODEL: the caller holds a secret key, i.e. IS the merchant (or their installed backend).
1162
+ * They can already create and edit their own contacts in the portal, so accepting their assertion
1163
+ * that `email` was verified by THEIR auth system delegates nothing they do not have. The identity
1164
+ * being asserted must still be verified on the caller's side — that is stated at the API boundary,
1165
+ * not enforced here, because we cannot see their auth system.
1166
+ */
1167
+ interface StoreCustomerLinkInput {
1168
+ /** The email the CALLER's auth system verified. Never a guessed or user-typed-but-unverified one. */
1169
+ email: string;
1170
+ /** The caller's own user id (e.g. a Supabase Auth user id). Idempotency key: same ref, same link. */
1171
+ externalRef: string;
1172
+ /** Optional display name; wins over an empty contact name, never overwrites a set one. */
1173
+ name?: string;
1174
+ phone?: string;
1175
+ }
1176
+ interface StoreCustomerLinkView {
1177
+ /** The buyer's account ref — what `customer.linked` carries and checkout accepts. */
1178
+ customerRef: string;
1179
+ /** The workspace contact this landed on. */
1180
+ contactRef: string;
1181
+ /** True when this call created the link; false when the externalRef was already linked. */
1182
+ alreadyLinked: boolean;
1183
+ }
1184
+
1045
1185
  /** A resolved commission rate (after applying any per-workspace override over the platform default). */
1046
1186
  interface EventCommissionConfig {
1047
1187
  /** Percentage cut on the gross (0..100). */
@@ -2341,7 +2481,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2341
2481
  idempotencyKey?: string;
2342
2482
  }) => Promise<T>;
2343
2483
  storefront: () => Promise<Storefront>;
2344
- products: () => Promise<PublicProductSummary[]>;
2484
+ /**
2485
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
2486
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
2487
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
2488
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
2489
+ */
2490
+ products: (params?: {
2491
+ updatedSince?: string;
2492
+ limit?: number;
2493
+ }) => Promise<PublicProductSummary[]>;
2345
2494
  product: (slug: string) => Promise<PublicProduct>;
2346
2495
  productSlots: (slug: string, opts: {
2347
2496
  variantRef: string;
@@ -2398,6 +2547,23 @@ declare function createStoreClient(opts: StoreClientOptions): {
2398
2547
  * stock until the session lapses. Cancelling an already-paid checkout returns 409.
2399
2548
  */
2400
2549
  cancelCheckout: (ref: string) => Promise<StoreCheckoutSessionView>;
2550
+ /**
2551
+ * Attach YOUR OWN signed-in user to this workspace's contacts. SERVER ONLY. Only send an email
2552
+ * your auth system verified. Idempotent per externalRef, and the response is identical whether
2553
+ * the contact existed before — it cannot be used to probe who is already a customer.
2554
+ */
2555
+ linkCustomer: (input: StoreCustomerLinkInput) => Promise<StoreCustomerLinkView>;
2556
+ /**
2557
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
2558
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
2559
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
2560
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
2561
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
2562
+ */
2563
+ orders: (params?: {
2564
+ updatedSince?: string;
2565
+ limit?: number;
2566
+ }) => Promise<StoreOrderRecord[]>;
2401
2567
  };
2402
2568
  type StoreClient = ReturnType<typeof createStoreClient>;
2403
2569
  /**
package/dist/index.js CHANGED
@@ -83,7 +83,19 @@ function createStoreClient(opts) {
83
83
  request,
84
84
  // ------------------------------------------------------------- read (fd_pk_)
85
85
  storefront: /* @__PURE__ */ __name(() => request("/store/storefront"), "storefront"),
86
- products: /* @__PURE__ */ __name(() => request("/store/products"), "products"),
86
+ /**
87
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
88
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
89
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
90
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
91
+ */
92
+ products: /* @__PURE__ */ __name((params) => {
93
+ const q = new URLSearchParams();
94
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
95
+ if (params?.limit) q.set("limit", String(params.limit));
96
+ const qs = q.toString();
97
+ return request(`/store/products${qs ? `?${qs}` : ""}`);
98
+ }, "products"),
87
99
  product: /* @__PURE__ */ __name((slug) => request(`/store/products/${enc(slug)}`), "product"),
88
100
  productSlots: /* @__PURE__ */ __name((slug, opts2) => request(`/store/products/${enc(slug)}/slots?variantRef=${enc(opts2.variantRef)}&date=${enc(opts2.date)}`), "productSlots"),
89
101
  collections: /* @__PURE__ */ __name(() => request("/store/collections"), "collections"),
@@ -151,7 +163,30 @@ function createStoreClient(opts) {
151
163
  */
152
164
  cancelCheckout: /* @__PURE__ */ __name((ref) => request(`/store/checkouts/${enc(ref)}/cancel`, {
153
165
  method: "POST"
154
- }), "cancelCheckout")
166
+ }), "cancelCheckout"),
167
+ /**
168
+ * Attach YOUR OWN signed-in user to this workspace's contacts. SERVER ONLY. Only send an email
169
+ * your auth system verified. Idempotent per externalRef, and the response is identical whether
170
+ * the contact existed before — it cannot be used to probe who is already a customer.
171
+ */
172
+ linkCustomer: /* @__PURE__ */ __name((input) => request("/store/customers/link", {
173
+ method: "POST",
174
+ body: JSON.stringify(input)
175
+ }), "linkCustomer"),
176
+ /**
177
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
178
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
179
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
180
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
181
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
182
+ */
183
+ orders: /* @__PURE__ */ __name((params) => {
184
+ const q = new URLSearchParams();
185
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
186
+ if (params?.limit) q.set("limit", String(params.limit));
187
+ const qs = q.toString();
188
+ return request(`/store/orders${qs ? `?${qs}` : ""}`);
189
+ }, "orders")
155
190
  };
156
191
  }
157
192
  __name(createStoreClient, "createStoreClient");
package/dist/index.mjs CHANGED
@@ -49,7 +49,19 @@ function createStoreClient(opts) {
49
49
  request,
50
50
  // ------------------------------------------------------------- read (fd_pk_)
51
51
  storefront: /* @__PURE__ */ __name(() => request("/store/storefront"), "storefront"),
52
- products: /* @__PURE__ */ __name(() => request("/store/products"), "products"),
52
+ /**
53
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
54
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
55
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
56
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
57
+ */
58
+ products: /* @__PURE__ */ __name((params) => {
59
+ const q = new URLSearchParams();
60
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
61
+ if (params?.limit) q.set("limit", String(params.limit));
62
+ const qs = q.toString();
63
+ return request(`/store/products${qs ? `?${qs}` : ""}`);
64
+ }, "products"),
53
65
  product: /* @__PURE__ */ __name((slug) => request(`/store/products/${enc(slug)}`), "product"),
54
66
  productSlots: /* @__PURE__ */ __name((slug, opts2) => request(`/store/products/${enc(slug)}/slots?variantRef=${enc(opts2.variantRef)}&date=${enc(opts2.date)}`), "productSlots"),
55
67
  collections: /* @__PURE__ */ __name(() => request("/store/collections"), "collections"),
@@ -117,7 +129,30 @@ function createStoreClient(opts) {
117
129
  */
118
130
  cancelCheckout: /* @__PURE__ */ __name((ref) => request(`/store/checkouts/${enc(ref)}/cancel`, {
119
131
  method: "POST"
120
- }), "cancelCheckout")
132
+ }), "cancelCheckout"),
133
+ /**
134
+ * Attach YOUR OWN signed-in user to this workspace's contacts. SERVER ONLY. Only send an email
135
+ * your auth system verified. Idempotent per externalRef, and the response is identical whether
136
+ * the contact existed before — it cannot be used to probe who is already a customer.
137
+ */
138
+ linkCustomer: /* @__PURE__ */ __name((input) => request("/store/customers/link", {
139
+ method: "POST",
140
+ body: JSON.stringify(input)
141
+ }), "linkCustomer"),
142
+ /**
143
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
144
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
145
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
146
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
147
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
148
+ */
149
+ orders: /* @__PURE__ */ __name((params) => {
150
+ const q = new URLSearchParams();
151
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
152
+ if (params?.limit) q.set("limit", String(params.limit));
153
+ const qs = q.toString();
154
+ return request(`/store/orders${qs ? `?${qs}` : ""}`);
155
+ }, "orders")
121
156
  };
122
157
  }
123
158
  __name(createStoreClient, "createStoreClient");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontdesk-africa/store-js",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client for the FrontDesk Storefront API: catalogue, events, forms, hosted checkout and signed webhooks.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://api.frontdesk.africa/v1/store/docs",