@frontdesk-africa/store-js 0.2.0 → 0.4.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
@@ -16,7 +16,7 @@ MCP: `POST /v1/store/mcp`.
16
16
 
17
17
  | Key | Where it goes | What it does |
18
18
  |---|---|---|
19
- | `fd_pk_…` | your **page**, safe to ship | read-only, and only from origins the merchant registered |
19
+ | `fd_pk_…` | your **page**, safe to ship | reads, plus free event registration, and only from origins the merchant registered |
20
20
  | `fd_sk_…` | your **server**, never a page | opens checkouts |
21
21
 
22
22
  A publishable key is designed to be public. It is locked to exact origins, and an empty origin list
@@ -70,6 +70,28 @@ const chosen = item.variants.find(
70
70
  Dim a value when every variant still holding it is `soldOut` (or has no price in the buyer's
71
71
  currency), and swap your gallery to a value's `media` when it is selected.
72
72
 
73
+ ## Free event registration (browser)
74
+
75
+ A free RSVP page needs no server. Read the event, offer the tiers whose `kind` is `free`, ask the
76
+ questions in each tier's `formFields`, and post the registration with the publishable key.
77
+
78
+ ```ts
79
+ const event = await store.event('open-house')
80
+ const tier = event.ticketTypes.find((t) => t.kind === 'free')!
81
+
82
+ const done = await store.registerForEvent(event.slug, {
83
+ tickets: [{ ticketTypeRef: tier.ref, quantity: 2, attendees: [{}, { name: 'Tunde Obi', email: 'tunde@example.com' }] }],
84
+ contact: { name: 'Ada Obi', email: 'ada@example.com', phone: '+2348120700080' },
85
+ responses: { piggyvest_user: 'Yes', first_time: 'No' }, // keyed by formFields[].key
86
+ })
87
+ // done.status → 'confirmed' | 'pending_approval' | 'waitlisted'; done.reference → 'EV-7F3A2C'
88
+ ```
89
+
90
+ FrontDesk emails the guest their confirmation, calendar invite and ticket. A paid tier is refused
91
+ with `TIER_REQUIRES_PAYMENT`: open a checkout for it from your server. Registering the same email
92
+ twice returns the same answer and re-sends the confirmation instead of issuing a second seat, so a
93
+ retry is safe. To read registrations back, subscribe to the `attendee.registered` webhook.
94
+
73
95
  ## Checkout (server)
74
96
 
75
97
  You never handle card details and you never price a cart. The buyer pays on a FrontDesk-hosted page
@@ -88,6 +110,10 @@ const checkout = await store.createCheckout(
88
110
  redirect(checkout.hostedUrl)
89
111
  ```
90
112
 
113
+ Every `variantRef` must be a live one from the catalogue. An unknown or unpublished ref is refused
114
+ with `400 VALIDATION_ERROR` listing the bad refs in `error.details.invalid`, so a typo fails on your
115
+ server instead of in front of your buyer.
116
+
91
117
  Then, when the buyer comes back:
92
118
 
93
119
  ```ts
@@ -98,6 +124,10 @@ if (done.status === 'completed') fulfil(done.orderRef)
98
124
  **Do not fulfil on the redirect itself.** It is a browser navigation: it can be lost, replayed or
99
125
  forged. `getCheckout` or the `checkout.completed` webhook are the only proof.
100
126
 
127
+ Only a paid checkout reaches `returnUrl`. A buyer who backs out of a payment comes back to the hosted
128
+ page with the same cart and a link to your site, a retry there pays the same order rather than opening
129
+ another, and reopening `hostedUrl` resumes it, including a bank transfer already started.
130
+
101
131
  Event tickets work the same way, with the event telling you what to collect first:
102
132
 
103
133
  ```ts
@@ -152,15 +182,39 @@ the check will fail in a way that looks like a key mismatch.
152
182
  Payloads are thin — refs only. Re-fetch through the API for detail, so a replayed delivery can never
153
183
  present stale figures as current.
154
184
 
185
+ ## Orders + keeping a local copy in sync
186
+
187
+ `orders()` (server only, secret key) lists your own orders as a **delta read**: pass the largest
188
+ `updatedAt` you have seen back as `updatedSince` and upsert by `ref` — the `>=` comparison repeats
189
+ the boundary row on purpose so a same-second tie can never drop one. `products({ updatedSince })`
190
+ works the same way for the catalogue.
191
+
192
+ ```ts
193
+ let cursor = loadCursor() // null on first run = full backfill
194
+ for (;;) {
195
+ const page = await store.orders(cursor ? { updatedSince: cursor, limit: 200 } : { limit: 200 })
196
+ for (const order of page) upsertByRef(order)
197
+ if (page.length < 200) break
198
+ cursor = page[page.length - 1].updatedAt
199
+ }
200
+ ```
201
+
202
+ Deletions never show up as absence in a delta page: they arrive as the `product.deleted` and
203
+ `order.deleted` webhooks — remove those refs from your copy when they land. FrontDesk stays the
204
+ source of truth; on any conflict, its row wins.
205
+
206
+ Confirming a single purchase is still `getCheckout(ref)`, not a scan of `orders()`.
207
+
155
208
  ## Test mode
156
209
 
157
- A `fd_sk_test_…` key opens a test checkout. The hosted page shows a test banner and a
158
- **Simulate a successful payment** button, so you can build the whole loop with no bank account and no
159
- spend.
210
+ A `fd_sk_test_…` key opens a test checkout that completes with a **real charge on the provider's
211
+ sandbox** — pay it with a Paystack test card (the popup offers a Success option). There is exactly
212
+ one way to finish a checkout in either mode.
160
213
 
161
- A simulated payment completes the session, sets a synthetic `test_ord_…` ref, and fires
162
- `checkout.completed` with `"test": true`. It creates **no order**, credits nobody and posts no ledger
163
- entry — so do not try to fetch that order ref. Test mode proves your integration, not fulfilment.
214
+ The order it creates is REAL and marked `test` everywhere: no money moves, nothing is held from
215
+ stock, and `checkout.completed` fires with the real `orderRef`. Test orders show under a Test badge
216
+ in the portal, only a test key can read them (`orders()` never mixes envs), and they are
217
+ hard-deleted after 90 days — the `order.deleted` webhook tells your mirror when that happens.
164
218
 
165
219
  ---
166
220
 
@@ -180,6 +234,10 @@ Paste this, with your key:
180
234
  > Pages: home rendering `GET /v1/store/storefront`, a product list from `GET /v1/store/products`, and
181
235
  > a product page from `GET /v1/store/products/{slug}`.
182
236
  >
237
+ > For a free event RSVP page, call `POST /v1/store/events/{slug}/register` straight from the
238
+ > browser with the publishable key, sending `tickets`, `contact` and `responses` keyed by each
239
+ > tier's `formFields[].key`. FrontDesk sends the confirmation email itself.
240
+ >
183
241
  > For checkout, call `POST /v1/store/checkouts` **from a server route, never the browser**, using the
184
242
  > secret key, with an `Idempotency-Key` header derived from the cart id. Redirect the buyer to the
185
243
  > `hostedUrl` you get back. Do not build a payment form and do not ask for card details.
package/dist/index.d.mts CHANGED
@@ -852,6 +852,9 @@ interface PublicProductSummary {
852
852
  ratingAvg?: number;
853
853
  /** Published review count behind `ratingAvg`. Absent under the floor, for the same reason. */
854
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;
855
858
  }
856
859
  type CollectionCoverKind = 'image' | 'video';
857
860
  /** Buyer-facing collection in the storefront payload / public endpoints. */
@@ -890,6 +893,14 @@ interface DeliveryZone {
890
893
  * payloads and on single-currency stores. */
891
894
  prices?: ZoneFee[];
892
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';
893
904
  interface StoreCheckoutItem {
894
905
  variantRef: string;
895
906
  quantity: number;
@@ -909,6 +920,14 @@ interface StoreShippingAddress {
909
920
  lat?: number | null;
910
921
  lng?: number | null;
911
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';
912
931
 
913
932
  /**
914
933
  * Packages — sellable offerings + priced tiers (options). Spec §13.1, §10.18 (PV-012/054).
@@ -965,7 +984,8 @@ interface StoreCheckoutSessionInput {
965
984
  contact?: Partial<StoreCheckoutContact> | null;
966
985
  deliveryZoneRef?: string | null;
967
986
  shipping?: StoreShippingAddress | null;
968
- /** Must be a currency the store accepts. Omit for the store primary. */
987
+ /** The currency the buyer pays in. Must be one the store accepts, with every item priced in it, else
988
+ * VALIDATION_ERROR. Omit for the store primary. Fixed at open; never re-priced in another currency. */
969
989
  currency?: string | null;
970
990
  /** Where we send the buyer after payment. Must be https (or localhost while testing). */
971
991
  returnUrl: string;
@@ -996,7 +1016,12 @@ interface StoreTicketItem {
996
1016
  */
997
1017
  interface StoreEventCheckoutInput {
998
1018
  tickets: StoreTicketItem[];
999
- /** Products already attached to this event. Not the general catalogue. */
1019
+ /**
1020
+ * 🔴 NOT SUPPORTED YET — a non-empty array is refused with 400. It was declared with D18 and never
1021
+ * wired: the hosted ticket page has no product concept, so anything sent here reached the buyer as
1022
+ * neither a line nor a charge. Kept on the type because the contract is frozen (D13). Sell
1023
+ * merchandise with a separate POST /v1/store/checkouts until the hosted page can carry it.
1024
+ */
1000
1025
  products?: StoreCheckoutItem[] | null;
1001
1026
  contact: StoreCheckoutContact;
1002
1027
  /** Buyer DOB, when the event uses order-level age checks. YYYY-MM-DD. */
@@ -1059,6 +1084,7 @@ interface StoreCheckoutSessionView {
1059
1084
  /** Send the buyer here. Valid until `expiresAt`. */
1060
1085
  hostedUrl: string;
1061
1086
  returnUrl: string;
1087
+ /** The currency the checkout is priced and charged in. Always set on a product checkout; immutable. */
1062
1088
  currency: string | null;
1063
1089
  /**
1064
1090
  * The order this became, once the buyer paid. Null while open.
@@ -1072,6 +1098,64 @@ interface StoreCheckoutSessionView {
1072
1098
  createdAt: string;
1073
1099
  }
1074
1100
 
1101
+ /**
1102
+ * GET /v1/store/orders — the merchant's own store orders, over their SECRET key.
1103
+ *
1104
+ * Built for delta sync (the Supabase mirror pulls through `updatedSince`), which is why the shape
1105
+ * is a snapshot a mirror can upsert whole: refs, money in minor units, line snapshots that survive
1106
+ * product edits. Live and test orders never mix — the key's env picks the set, exactly like
1107
+ * checkouts. What is NOT here is deliberate: no provider references, no ledger refs, no wallet or
1108
+ * payout figures — the standing constraint (no money surface behind an installed key) applies to
1109
+ * this read exactly as it does to mirrors.
1110
+ */
1111
+ interface StoreOrderRecordLine {
1112
+ /** Snapshot refs: null when the product/variant was deleted after purchase. */
1113
+ productRef: string | null;
1114
+ productName: string;
1115
+ variantRef: string | null;
1116
+ variantName: string;
1117
+ imageUrl: string | null;
1118
+ quantity: number;
1119
+ unitPriceMinor: number;
1120
+ subtotalMinor: number;
1121
+ }
1122
+ interface StoreOrderRecord {
1123
+ ref: string;
1124
+ /** The short human reference shown to the buyer (receipts, support). */
1125
+ shortReference: string;
1126
+ /** The full lifecycle union covers BOTH axes: product carts (pending → paid → fulfilled) and
1127
+ * package bookings (requested → awaiting_payment → confirmed → in_progress → completed).
1128
+ * `saving` (an in-progress savings plan) never appears here — it is not an order yet. */
1129
+ status: Exclude<StoreOrderStatus, 'saving'>;
1130
+ fulfillmentStatus: StoreOrderFulfillmentStatus;
1131
+ /** Paid with a test key: no real money moved, hard-deleted after 90 days (`order.deleted`). */
1132
+ test: boolean;
1133
+ currency: string;
1134
+ subtotalMinor: number;
1135
+ deliveryFeeMinor: number;
1136
+ totalMinor: number;
1137
+ buyer: {
1138
+ name: string;
1139
+ email: string | null;
1140
+ phone: string | null;
1141
+ };
1142
+ needsDelivery: boolean;
1143
+ shipping: {
1144
+ address1: string | null;
1145
+ address2: string | null;
1146
+ city: string | null;
1147
+ state: string | null;
1148
+ country: string | null;
1149
+ zoneRef: string | null;
1150
+ zoneName: string | null;
1151
+ } | null;
1152
+ lines: StoreOrderRecordLine[];
1153
+ paidAt: string | null;
1154
+ createdAt: string;
1155
+ /** The delta cursor: pass the largest value you have seen back as `updatedSince`. */
1156
+ updatedAt: string;
1157
+ }
1158
+
1075
1159
  /**
1076
1160
  * `POST /v1/store/customers/link` — attach the caller's own signed-in user to this workspace's
1077
1161
  * contacts (Supabase Phase 1c; design doc §7.6).
@@ -2365,6 +2449,52 @@ interface PublicForm {
2365
2449
  uploadToken?: string;
2366
2450
  }
2367
2451
 
2452
+ /**
2453
+ * FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
2454
+ * merchant-built frontend receives, so they are additive-only: never remove a field, never retype
2455
+ * one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
2456
+ */
2457
+
2458
+ type StoreEventRegistrationStatus = 'confirmed' | 'pending_approval' | 'waitlisted';
2459
+ /** Like StoreCheckoutContact, except a phone may stand in for the email. One of the two is required. */
2460
+ interface StoreEventRegistrationContact {
2461
+ name: string;
2462
+ /** Receives the confirmation, the calendar invite and the ticket. */
2463
+ email?: string | null;
2464
+ /** Receives an SMS confirmation when the event sends them, or when there is no email. */
2465
+ phone?: string | null;
2466
+ }
2467
+ /**
2468
+ * A subset of StoreEventCheckoutInput: the same `tickets[]` (positional `attendees`, length
2469
+ * quantity x groupSize, a blank entry keeps that seat under the registrant), no returnUrl, no
2470
+ * currency, no discountCode. Every tier must be FREE.
2471
+ */
2472
+ interface StoreEventRegistrationInput {
2473
+ tickets: StoreTicketItem[];
2474
+ contact: StoreEventRegistrationContact;
2475
+ /** YYYY-MM-DD. Required when a chosen tier sets minAge and the event dobMode is order. */
2476
+ dateOfBirth?: string | null;
2477
+ /** Answers to the chosen tiers' formFields, keyed by each field's `key`. */
2478
+ responses?: Record<string, unknown> | null;
2479
+ /** Codes that reveal hidden or invite-only tiers. */
2480
+ unlockCodes?: string[] | null;
2481
+ /** Affiliate code from a share link. Ignored if unknown. */
2482
+ promoterCode?: string | null;
2483
+ }
2484
+ interface StoreEventRegistrationView {
2485
+ eventSlug: string;
2486
+ eventRef: string;
2487
+ ticketTypeRef: string;
2488
+ status: StoreEventRegistrationStatus;
2489
+ /** The guest's short reference (EV-XXXXXX), the same one printed on their email. Never a ticket token. */
2490
+ reference: string;
2491
+ /** Seats issued by this registration (quantity x groupSize across the lines). */
2492
+ attendeeCount: number;
2493
+ /** True from a test key: everything was validated, nothing was written, nothing was sent. */
2494
+ test: boolean;
2495
+ message: string;
2496
+ }
2497
+
2368
2498
  /**
2369
2499
  * Typed client for the FrontDesk Storefront API.
2370
2500
  *
@@ -2399,7 +2529,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2399
2529
  idempotencyKey?: string;
2400
2530
  }) => Promise<T>;
2401
2531
  storefront: () => Promise<Storefront>;
2402
- products: () => Promise<PublicProductSummary[]>;
2532
+ /**
2533
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
2534
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
2535
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
2536
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
2537
+ */
2538
+ products: (params?: {
2539
+ updatedSince?: string;
2540
+ limit?: number;
2541
+ }) => Promise<PublicProductSummary[]>;
2403
2542
  product: (slug: string) => Promise<PublicProduct>;
2404
2543
  productSlots: (slug: string, opts: {
2405
2544
  variantRef: string;
@@ -2423,6 +2562,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2423
2562
  availability: (variantRefs: string[]) => Promise<PublicStoreAvailability>;
2424
2563
  /** The payment rails this workspace can charge, for rendering your own picker (headless only). */
2425
2564
  paymentMethods: (currency?: string) => Promise<string[]>;
2565
+ /**
2566
+ * Register a guest for a FREE event tier. The one write a publishable key may make, so call it
2567
+ * straight from the page. `tickets` has the checkout shape (positional `attendees`, length
2568
+ * quantity x groupSize); `responses` answers the tier's `formFields` by `key`. A paid tier is
2569
+ * refused with TIER_REQUIRES_PAYMENT: open a checkout for it from your server. Duplicates are
2570
+ * quiet (an email that already holds a seat gets the same answer and its confirmation again,
2571
+ * never a second seat), so retrying is safe and no idempotency key is needed. FrontDesk sends
2572
+ * the confirmation, the .ics invite and the ticket itself.
2573
+ */
2574
+ registerForEvent: (slug: string, input: StoreEventRegistrationInput) => Promise<StoreEventRegistrationView>;
2426
2575
  /**
2427
2576
  * Open a hosted checkout. SERVER ONLY — needs a secret key.
2428
2577
  *
@@ -2462,6 +2611,17 @@ declare function createStoreClient(opts: StoreClientOptions): {
2462
2611
  * the contact existed before — it cannot be used to probe who is already a customer.
2463
2612
  */
2464
2613
  linkCustomer: (input: StoreCustomerLinkInput) => Promise<StoreCustomerLinkView>;
2614
+ /**
2615
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
2616
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
2617
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
2618
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
2619
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
2620
+ */
2621
+ orders: (params?: {
2622
+ updatedSince?: string;
2623
+ limit?: number;
2624
+ }) => Promise<StoreOrderRecord[]>;
2465
2625
  };
2466
2626
  type StoreClient = ReturnType<typeof createStoreClient>;
2467
2627
  /**
package/dist/index.d.ts CHANGED
@@ -852,6 +852,9 @@ interface PublicProductSummary {
852
852
  ratingAvg?: number;
853
853
  /** Published review count behind `ratingAvg`. Absent under the floor, for the same reason. */
854
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;
855
858
  }
856
859
  type CollectionCoverKind = 'image' | 'video';
857
860
  /** Buyer-facing collection in the storefront payload / public endpoints. */
@@ -890,6 +893,14 @@ interface DeliveryZone {
890
893
  * payloads and on single-currency stores. */
891
894
  prices?: ZoneFee[];
892
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';
893
904
  interface StoreCheckoutItem {
894
905
  variantRef: string;
895
906
  quantity: number;
@@ -909,6 +920,14 @@ interface StoreShippingAddress {
909
920
  lat?: number | null;
910
921
  lng?: number | null;
911
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';
912
931
 
913
932
  /**
914
933
  * Packages — sellable offerings + priced tiers (options). Spec §13.1, §10.18 (PV-012/054).
@@ -965,7 +984,8 @@ interface StoreCheckoutSessionInput {
965
984
  contact?: Partial<StoreCheckoutContact> | null;
966
985
  deliveryZoneRef?: string | null;
967
986
  shipping?: StoreShippingAddress | null;
968
- /** Must be a currency the store accepts. Omit for the store primary. */
987
+ /** The currency the buyer pays in. Must be one the store accepts, with every item priced in it, else
988
+ * VALIDATION_ERROR. Omit for the store primary. Fixed at open; never re-priced in another currency. */
969
989
  currency?: string | null;
970
990
  /** Where we send the buyer after payment. Must be https (or localhost while testing). */
971
991
  returnUrl: string;
@@ -996,7 +1016,12 @@ interface StoreTicketItem {
996
1016
  */
997
1017
  interface StoreEventCheckoutInput {
998
1018
  tickets: StoreTicketItem[];
999
- /** Products already attached to this event. Not the general catalogue. */
1019
+ /**
1020
+ * 🔴 NOT SUPPORTED YET — a non-empty array is refused with 400. It was declared with D18 and never
1021
+ * wired: the hosted ticket page has no product concept, so anything sent here reached the buyer as
1022
+ * neither a line nor a charge. Kept on the type because the contract is frozen (D13). Sell
1023
+ * merchandise with a separate POST /v1/store/checkouts until the hosted page can carry it.
1024
+ */
1000
1025
  products?: StoreCheckoutItem[] | null;
1001
1026
  contact: StoreCheckoutContact;
1002
1027
  /** Buyer DOB, when the event uses order-level age checks. YYYY-MM-DD. */
@@ -1059,6 +1084,7 @@ interface StoreCheckoutSessionView {
1059
1084
  /** Send the buyer here. Valid until `expiresAt`. */
1060
1085
  hostedUrl: string;
1061
1086
  returnUrl: string;
1087
+ /** The currency the checkout is priced and charged in. Always set on a product checkout; immutable. */
1062
1088
  currency: string | null;
1063
1089
  /**
1064
1090
  * The order this became, once the buyer paid. Null while open.
@@ -1072,6 +1098,64 @@ interface StoreCheckoutSessionView {
1072
1098
  createdAt: string;
1073
1099
  }
1074
1100
 
1101
+ /**
1102
+ * GET /v1/store/orders — the merchant's own store orders, over their SECRET key.
1103
+ *
1104
+ * Built for delta sync (the Supabase mirror pulls through `updatedSince`), which is why the shape
1105
+ * is a snapshot a mirror can upsert whole: refs, money in minor units, line snapshots that survive
1106
+ * product edits. Live and test orders never mix — the key's env picks the set, exactly like
1107
+ * checkouts. What is NOT here is deliberate: no provider references, no ledger refs, no wallet or
1108
+ * payout figures — the standing constraint (no money surface behind an installed key) applies to
1109
+ * this read exactly as it does to mirrors.
1110
+ */
1111
+ interface StoreOrderRecordLine {
1112
+ /** Snapshot refs: null when the product/variant was deleted after purchase. */
1113
+ productRef: string | null;
1114
+ productName: string;
1115
+ variantRef: string | null;
1116
+ variantName: string;
1117
+ imageUrl: string | null;
1118
+ quantity: number;
1119
+ unitPriceMinor: number;
1120
+ subtotalMinor: number;
1121
+ }
1122
+ interface StoreOrderRecord {
1123
+ ref: string;
1124
+ /** The short human reference shown to the buyer (receipts, support). */
1125
+ shortReference: string;
1126
+ /** The full lifecycle union covers BOTH axes: product carts (pending → paid → fulfilled) and
1127
+ * package bookings (requested → awaiting_payment → confirmed → in_progress → completed).
1128
+ * `saving` (an in-progress savings plan) never appears here — it is not an order yet. */
1129
+ status: Exclude<StoreOrderStatus, 'saving'>;
1130
+ fulfillmentStatus: StoreOrderFulfillmentStatus;
1131
+ /** Paid with a test key: no real money moved, hard-deleted after 90 days (`order.deleted`). */
1132
+ test: boolean;
1133
+ currency: string;
1134
+ subtotalMinor: number;
1135
+ deliveryFeeMinor: number;
1136
+ totalMinor: number;
1137
+ buyer: {
1138
+ name: string;
1139
+ email: string | null;
1140
+ phone: string | null;
1141
+ };
1142
+ needsDelivery: boolean;
1143
+ shipping: {
1144
+ address1: string | null;
1145
+ address2: string | null;
1146
+ city: string | null;
1147
+ state: string | null;
1148
+ country: string | null;
1149
+ zoneRef: string | null;
1150
+ zoneName: string | null;
1151
+ } | null;
1152
+ lines: StoreOrderRecordLine[];
1153
+ paidAt: string | null;
1154
+ createdAt: string;
1155
+ /** The delta cursor: pass the largest value you have seen back as `updatedSince`. */
1156
+ updatedAt: string;
1157
+ }
1158
+
1075
1159
  /**
1076
1160
  * `POST /v1/store/customers/link` — attach the caller's own signed-in user to this workspace's
1077
1161
  * contacts (Supabase Phase 1c; design doc §7.6).
@@ -2365,6 +2449,52 @@ interface PublicForm {
2365
2449
  uploadToken?: string;
2366
2450
  }
2367
2451
 
2452
+ /**
2453
+ * FrontDesk public storefront contract (Storefront API §3.2). These shapes are what a
2454
+ * merchant-built frontend receives, so they are additive-only: never remove a field, never retype
2455
+ * one, never narrow an enum. Guarded by `pnpm --filter pro-api store-contract:check`.
2456
+ */
2457
+
2458
+ type StoreEventRegistrationStatus = 'confirmed' | 'pending_approval' | 'waitlisted';
2459
+ /** Like StoreCheckoutContact, except a phone may stand in for the email. One of the two is required. */
2460
+ interface StoreEventRegistrationContact {
2461
+ name: string;
2462
+ /** Receives the confirmation, the calendar invite and the ticket. */
2463
+ email?: string | null;
2464
+ /** Receives an SMS confirmation when the event sends them, or when there is no email. */
2465
+ phone?: string | null;
2466
+ }
2467
+ /**
2468
+ * A subset of StoreEventCheckoutInput: the same `tickets[]` (positional `attendees`, length
2469
+ * quantity x groupSize, a blank entry keeps that seat under the registrant), no returnUrl, no
2470
+ * currency, no discountCode. Every tier must be FREE.
2471
+ */
2472
+ interface StoreEventRegistrationInput {
2473
+ tickets: StoreTicketItem[];
2474
+ contact: StoreEventRegistrationContact;
2475
+ /** YYYY-MM-DD. Required when a chosen tier sets minAge and the event dobMode is order. */
2476
+ dateOfBirth?: string | null;
2477
+ /** Answers to the chosen tiers' formFields, keyed by each field's `key`. */
2478
+ responses?: Record<string, unknown> | null;
2479
+ /** Codes that reveal hidden or invite-only tiers. */
2480
+ unlockCodes?: string[] | null;
2481
+ /** Affiliate code from a share link. Ignored if unknown. */
2482
+ promoterCode?: string | null;
2483
+ }
2484
+ interface StoreEventRegistrationView {
2485
+ eventSlug: string;
2486
+ eventRef: string;
2487
+ ticketTypeRef: string;
2488
+ status: StoreEventRegistrationStatus;
2489
+ /** The guest's short reference (EV-XXXXXX), the same one printed on their email. Never a ticket token. */
2490
+ reference: string;
2491
+ /** Seats issued by this registration (quantity x groupSize across the lines). */
2492
+ attendeeCount: number;
2493
+ /** True from a test key: everything was validated, nothing was written, nothing was sent. */
2494
+ test: boolean;
2495
+ message: string;
2496
+ }
2497
+
2368
2498
  /**
2369
2499
  * Typed client for the FrontDesk Storefront API.
2370
2500
  *
@@ -2399,7 +2529,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2399
2529
  idempotencyKey?: string;
2400
2530
  }) => Promise<T>;
2401
2531
  storefront: () => Promise<Storefront>;
2402
- products: () => Promise<PublicProductSummary[]>;
2532
+ /**
2533
+ * Every public product. With `updatedSince` (ISO 8601) it becomes a delta read for keeping a
2534
+ * local copy fresh: ordered oldest change first, `limit` pages it (max 200), and each summary's
2535
+ * `updatedAt` is the next cursor. The comparison is >= so the boundary row repeats — upsert by
2536
+ * ref. Deleted products arrive as the `product.deleted` webhook, not as an absence here.
2537
+ */
2538
+ products: (params?: {
2539
+ updatedSince?: string;
2540
+ limit?: number;
2541
+ }) => Promise<PublicProductSummary[]>;
2403
2542
  product: (slug: string) => Promise<PublicProduct>;
2404
2543
  productSlots: (slug: string, opts: {
2405
2544
  variantRef: string;
@@ -2423,6 +2562,16 @@ declare function createStoreClient(opts: StoreClientOptions): {
2423
2562
  availability: (variantRefs: string[]) => Promise<PublicStoreAvailability>;
2424
2563
  /** The payment rails this workspace can charge, for rendering your own picker (headless only). */
2425
2564
  paymentMethods: (currency?: string) => Promise<string[]>;
2565
+ /**
2566
+ * Register a guest for a FREE event tier. The one write a publishable key may make, so call it
2567
+ * straight from the page. `tickets` has the checkout shape (positional `attendees`, length
2568
+ * quantity x groupSize); `responses` answers the tier's `formFields` by `key`. A paid tier is
2569
+ * refused with TIER_REQUIRES_PAYMENT: open a checkout for it from your server. Duplicates are
2570
+ * quiet (an email that already holds a seat gets the same answer and its confirmation again,
2571
+ * never a second seat), so retrying is safe and no idempotency key is needed. FrontDesk sends
2572
+ * the confirmation, the .ics invite and the ticket itself.
2573
+ */
2574
+ registerForEvent: (slug: string, input: StoreEventRegistrationInput) => Promise<StoreEventRegistrationView>;
2426
2575
  /**
2427
2576
  * Open a hosted checkout. SERVER ONLY — needs a secret key.
2428
2577
  *
@@ -2462,6 +2611,17 @@ declare function createStoreClient(opts: StoreClientOptions): {
2462
2611
  * the contact existed before — it cannot be used to probe who is already a customer.
2463
2612
  */
2464
2613
  linkCustomer: (input: StoreCustomerLinkInput) => Promise<StoreCustomerLinkView>;
2614
+ /**
2615
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
2616
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
2617
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
2618
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
2619
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
2620
+ */
2621
+ orders: (params?: {
2622
+ updatedSince?: string;
2623
+ limit?: number;
2624
+ }) => Promise<StoreOrderRecord[]>;
2465
2625
  };
2466
2626
  type StoreClient = ReturnType<typeof createStoreClient>;
2467
2627
  /**
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"),
@@ -104,6 +116,20 @@ function createStoreClient(opts) {
104
116
  availability: /* @__PURE__ */ __name((variantRefs) => request(`/store/availability?variantRefs=${enc(variantRefs.join(","))}`), "availability"),
105
117
  /** The payment rails this workspace can charge, for rendering your own picker (headless only). */
106
118
  paymentMethods: /* @__PURE__ */ __name((currency) => request(`/store/payment-methods${currency ? `?currency=${enc(currency)}` : ""}`), "paymentMethods"),
119
+ // ------------------------------------------ register (fd_pk_ in the browser, or fd_sk_)
120
+ /**
121
+ * Register a guest for a FREE event tier. The one write a publishable key may make, so call it
122
+ * straight from the page. `tickets` has the checkout shape (positional `attendees`, length
123
+ * quantity x groupSize); `responses` answers the tier's `formFields` by `key`. A paid tier is
124
+ * refused with TIER_REQUIRES_PAYMENT: open a checkout for it from your server. Duplicates are
125
+ * quiet (an email that already holds a seat gets the same answer and its confirmation again,
126
+ * never a second seat), so retrying is safe and no idempotency key is needed. FrontDesk sends
127
+ * the confirmation, the .ics invite and the ticket itself.
128
+ */
129
+ registerForEvent: /* @__PURE__ */ __name((slug, input) => request(`/store/events/${enc(slug)}/register`, {
130
+ method: "POST",
131
+ body: JSON.stringify(input)
132
+ }), "registerForEvent"),
107
133
  // --------------------------------------------------- checkout (fd_sk_, server)
108
134
  /**
109
135
  * Open a hosted checkout. SERVER ONLY — needs a secret key.
@@ -160,7 +186,21 @@ function createStoreClient(opts) {
160
186
  linkCustomer: /* @__PURE__ */ __name((input) => request("/store/customers/link", {
161
187
  method: "POST",
162
188
  body: JSON.stringify(input)
163
- }), "linkCustomer")
189
+ }), "linkCustomer"),
190
+ /**
191
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
192
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
193
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
194
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
195
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
196
+ */
197
+ orders: /* @__PURE__ */ __name((params) => {
198
+ const q = new URLSearchParams();
199
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
200
+ if (params?.limit) q.set("limit", String(params.limit));
201
+ const qs = q.toString();
202
+ return request(`/store/orders${qs ? `?${qs}` : ""}`);
203
+ }, "orders")
164
204
  };
165
205
  }
166
206
  __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"),
@@ -70,6 +82,20 @@ function createStoreClient(opts) {
70
82
  availability: /* @__PURE__ */ __name((variantRefs) => request(`/store/availability?variantRefs=${enc(variantRefs.join(","))}`), "availability"),
71
83
  /** The payment rails this workspace can charge, for rendering your own picker (headless only). */
72
84
  paymentMethods: /* @__PURE__ */ __name((currency) => request(`/store/payment-methods${currency ? `?currency=${enc(currency)}` : ""}`), "paymentMethods"),
85
+ // ------------------------------------------ register (fd_pk_ in the browser, or fd_sk_)
86
+ /**
87
+ * Register a guest for a FREE event tier. The one write a publishable key may make, so call it
88
+ * straight from the page. `tickets` has the checkout shape (positional `attendees`, length
89
+ * quantity x groupSize); `responses` answers the tier's `formFields` by `key`. A paid tier is
90
+ * refused with TIER_REQUIRES_PAYMENT: open a checkout for it from your server. Duplicates are
91
+ * quiet (an email that already holds a seat gets the same answer and its confirmation again,
92
+ * never a second seat), so retrying is safe and no idempotency key is needed. FrontDesk sends
93
+ * the confirmation, the .ics invite and the ticket itself.
94
+ */
95
+ registerForEvent: /* @__PURE__ */ __name((slug, input) => request(`/store/events/${enc(slug)}/register`, {
96
+ method: "POST",
97
+ body: JSON.stringify(input)
98
+ }), "registerForEvent"),
73
99
  // --------------------------------------------------- checkout (fd_sk_, server)
74
100
  /**
75
101
  * Open a hosted checkout. SERVER ONLY — needs a secret key.
@@ -126,7 +152,21 @@ function createStoreClient(opts) {
126
152
  linkCustomer: /* @__PURE__ */ __name((input) => request("/store/customers/link", {
127
153
  method: "POST",
128
154
  body: JSON.stringify(input)
129
- }), "linkCustomer")
155
+ }), "linkCustomer"),
156
+ /**
157
+ * Your own store orders, as a delta read. SERVER ONLY — needs a secret key, and the key's env
158
+ * picks the set (a live key never sees test orders). Page forward on `updatedSince` using each
159
+ * order's `updatedAt` as the next cursor and upsert by ref (the >= comparison repeats the
160
+ * boundary row). A page shorter than `limit` means you are caught up. This is a sync and
161
+ * back-office read: to confirm a single purchase, keep using `getCheckout`.
162
+ */
163
+ orders: /* @__PURE__ */ __name((params) => {
164
+ const q = new URLSearchParams();
165
+ if (params?.updatedSince) q.set("updatedSince", params.updatedSince);
166
+ if (params?.limit) q.set("limit", String(params.limit));
167
+ const qs = q.toString();
168
+ return request(`/store/orders${qs ? `?${qs}` : ""}`);
169
+ }, "orders")
130
170
  };
131
171
  }
132
172
  __name(createStoreClient, "createStoreClient");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontdesk-africa/store-js",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",