@kasuvia/sdk 0.0.8 → 0.0.9

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 (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -6,20 +6,82 @@
6
6
  */
7
7
  import type { SdkConfig } from '../config'
8
8
  import type { Order, OrderStatus, FindOrderParams, CreateOrderParams, OrderItem } from '../types/store'
9
- import type { DeliveryAddress } from '../types/store'
9
+ import type { components } from '../generated/store-openapi'
10
+ import { apiRequest, ApiError, storefrontHeaders, authHeaders, JSON_HEADERS } from '../utils/http-client'
11
+
12
+ // ─── Generated types (openapi/README.md — regenerate via `pnpm run codegen:rest`) ─
13
+ // REAL BUG FOUND (2026-09-23) via this exact codegen initiative: the real
14
+ // OrderResponse schema (kasuvia-store-management's app/schemas/order.py)
15
+ // carries NO tracking_number/delivery_status/carrier_name/carrier_slug/
16
+ // terminal_rate_id/terminal_shipment_id/shipping_address/shipping_surcharge_*/
17
+ // declared_weight_kg/actual_weight_kg/pickup_verification_*/pickup_qr_payload
18
+ // fields at its top level at all - confirmed by reading OrderBase/
19
+ // OrderResponse's actual field lists directly, not just the generated
20
+ // output. Every one of these genuinely lives nested under
21
+ // `physical_fulfillment` (see PhysicalFulfillmentResponse below) - "One
22
+ // nested block per product type, instead of ~35 flat fields on OrderBase"
23
+ // per that file's own Order Schema Refactor comment. RawOrder/
24
+ // normaliseStorefrontOrder below used to read every one of these as a flat
25
+ // top-level field, so they were silently undefined on every real order -
26
+ // a customer's tracking number, carrier, delivery status, and shipping
27
+ // address never actually rendered anywhere on the storefront. Fixed to
28
+ // read from the real nested location, same pattern already correctly
29
+ // applied to bespoke_fulfillment (see deriveCustomerName/
30
+ // normaliseStorefrontOrder's existing bespoke_fulfillment-first reads).
31
+ type OrderResponseSchema = components['schemas']['OrderResponse']
32
+ type PhysicalFulfillmentSchema = components['schemas']['PhysicalFulfillmentResponse']
33
+ type DigitalFulfillmentSchema = components['schemas']['DigitalFulfillmentResponse']
10
34
 
11
35
  export type { FindOrderParams, CreateOrderParams, OrderStatus }
12
36
 
13
- interface RawOrderItem {
14
- id: string
15
- product_name?: string
16
- productName?: string
17
- image?: string
18
- quantity?: number
19
- unit_price?: number
20
- price?: number
37
+ /**
38
+ * Request body for the real POST /orders/storefront/bespoke atomic checkout
39
+ * (kasuvia-store-management's BespokeBookingCheckoutRequest). Bespoke
40
+ * products can never be added to a cart (ErrBespokeNotCartable) — this is
41
+ * the only real path to a paid bespoke commission from the storefront.
42
+ */
43
+ export interface CreateBespokeOrderParams {
44
+ product_id: string
45
+ brief: string
46
+ reference_images?: string[]
47
+ measurements?: Record<string, string>
48
+ preferred_timeline?: string
49
+ special_instructions?: string
50
+ deposit_ratio?: number
51
+ physical?: {
52
+ shipping_address: Record<string, unknown>
53
+ }
54
+ notes?: string
55
+ discount_code?: string
56
+ guest_email?: string
57
+ guest_name?: string
58
+ guest_phone?: string
59
+ checkout_session_id?: string
60
+ }
61
+
62
+ /**
63
+ * Request body for the real POST /orders/storefront/service atomic PAID
64
+ * checkout (kasuvia-store-management's ServiceBookingCheckoutRequest) —
65
+ * distinct from the free self-service `bookAppointment` flow
66
+ * (service-booking.service.ts, POST /service/bookings/schedule). Service
67
+ * products can never be added to a cart (ErrServiceNotCartable) — this is
68
+ * the only path to a PAID service booking from the storefront.
69
+ */
70
+ export interface CreateServiceBookingOrderParams {
71
+ product_id: string
72
+ scheduled_date: string
73
+ scheduled_time: string
74
+ staff_id?: string
75
+ notes?: string
76
+ discount_code?: string
77
+ guest_email?: string
78
+ guest_name?: string
79
+ guest_phone?: string
80
+ checkout_session_id?: string
21
81
  }
22
82
 
83
+ type RawOrderItem = NonNullable<OrderResponseSchema['items']>[number]
84
+
23
85
  interface RawShippingAddress {
24
86
  first_name?: string
25
87
  last_name?: string
@@ -32,40 +94,44 @@ interface RawShippingAddress {
32
94
  lga?: string
33
95
  }
34
96
 
35
- interface RawOrder {
36
- id: string
37
- display_id?: string
38
- items?: RawOrderItem[]
39
- total_amount?: number
40
- total?: number
41
- status: OrderStatus
42
- customer_email?: string
43
- customer_name?: string
44
- guest_email?: string
45
- guest_phone?: string
46
- guest_name?: string
47
- guest_token?: string
48
- shipping_address?: RawShippingAddress | string | null
49
- delivery_method?: string
50
- tracking_number?: string
51
- delivery_status?: string
52
- carrier_name?: string
53
- terminal_shipment_id?: string
54
- product_type?: string
55
- bespoke_stage?: string
56
- bespoke_brief?: string
57
- created_at?: string
58
- updated_at?: string
59
- cancellation_reason?: string
97
+ /**
98
+ * The real backend response, typed from the generated schema (see the
99
+ * codegen comment above `OrderResponseSchema`). `status` is narrowed from
100
+ * the schema's bare `string` (OrderResponse.status has no Literal/Enum on
101
+ * the backend either — it's genuinely just `str` there) to the SDK's own
102
+ * real OrderStatus union.
103
+ *
104
+ * `cancellation_reason` and `bespoke_stage` are NOT part of this type
105
+ * (confirmed absent from OrderResponse/PhysicalFulfillmentResponse/
106
+ * BespokeFulfillmentResponse's real field lists) — see
107
+ * normaliseStorefrontOrder's own comments on both for what actually backs
108
+ * (or doesn't back) the public Order type's equivalent fields.
109
+ */
110
+ // NOTE: deliberately NOT `Omit<OrderResponseSchema, 'status'> & {...}` -
111
+ // OrderResponseSchema is intersected with a string-index signature
112
+ // (`& {[key: string]: unknown}`, from the backend's `extra: "allow"`
113
+ // model_config), and `Omit`'s `Pick<T, Exclude<keyof T, K>>` definition
114
+ // makes `keyof` collapse to just `string` on a type with a string index
115
+ // signature, silently losing every specific field (every property access
116
+ // on the "Omit" result then wrongly resolves to `{}`). A plain
117
+ // intersection avoids `keyof` entirely and works correctly instead.
118
+ type RawOrder = OrderResponseSchema & { status: OrderStatus }
119
+
120
+ /** The real, non-null shipping_address shape (PhysicalFulfillmentResponse's
121
+ * own field types as a schemaless `Dict[str, Any]` on the backend, so
122
+ * openapi-typescript can't infer it beyond "some object" — this is the same
123
+ * defensive cast the code already did before this fix, just now sourced
124
+ * from the real nested location). */
125
+ function extractShippingAddress(raw: RawOrder): RawShippingAddress | null {
126
+ const val = raw.physical_fulfillment?.shipping_address
127
+ return typeof val === 'object' && val !== null ? val as unknown as RawShippingAddress : null
60
128
  }
61
129
 
62
130
  function deriveCustomerName(raw: RawOrder): string {
63
131
  if (raw.customer_name) return raw.customer_name
64
132
  if (raw.guest_name) return raw.guest_name
65
133
 
66
- const addr = typeof raw.shipping_address === 'object' && raw.shipping_address !== null
67
- ? raw.shipping_address as RawShippingAddress
68
- : null
134
+ const addr = extractShippingAddress(raw)
69
135
  if (addr) {
70
136
  const full = `${addr.first_name ?? ''} ${addr.last_name ?? ''}`.trim()
71
137
  if (full) return full
@@ -80,33 +146,144 @@ function deriveCustomerName(raw: RawOrder): string {
80
146
  function normaliseStorefrontOrder(raw: RawOrder): Order {
81
147
  const items: OrderItem[] = (raw.items ?? []).map((item) => ({
82
148
  id: item.id,
83
- productName: item.product_name ?? item.productName ?? '',
84
- image: item.image ?? '',
85
- quantity: item.quantity ?? 1,
86
- price: item.unit_price ?? item.price ?? 0,
149
+ order_id: item.order_id,
150
+ product_id: item.product_id ?? undefined,
151
+ variant_id: item.variant_id ?? undefined,
152
+ product_name: item.product_name ?? '',
153
+ quantity: item.quantity,
154
+ unit_price: item.unit_price,
155
+ variant_details: item.variant_details as Record<string, unknown> | undefined,
156
+ custom_measurements: item.custom_measurements as Record<string, unknown> | undefined,
157
+ image_url: item.image_url ?? undefined,
87
158
  }))
88
159
 
89
- const addr = typeof raw.shipping_address === 'object' && raw.shipping_address !== null
90
- ? raw.shipping_address as RawShippingAddress
91
- : null
160
+ const addr = extractShippingAddress(raw)
161
+
162
+ const customerName = deriveCustomerName(raw)
163
+ const customerEmail = raw.guest_email ?? raw.customer_email ?? ''
164
+ const physical = raw.physical_fulfillment
165
+
166
+ // No silent fallback: an order's currency determines what a subsequent
167
+ // payment actually gets charged in. A missing value here means the backend
168
+ // response is malformed — defaulting to a guessed currency would make the
169
+ // failure invisible until money changes hands in the wrong currency
170
+ // (see plan: Order Currency Fix).
171
+ if (!raw.currency) {
172
+ throw new Error(`Order ${raw.id} response is missing a currency field.`)
173
+ }
174
+ // REAL BUG FIXED (2026-09-22): same class of bug as the currency check
175
+ // above, previously missed. `created_at`/`updated_at` silently fell back
176
+ // to `new Date().toISOString()` (right now) when absent — fabricating a
177
+ // plausible but wrong "placed on" date instead of surfacing a malformed
178
+ // response. `business_id` silently fell back to `''`.
179
+ if (!raw.created_at) {
180
+ throw new Error(`Order ${raw.id} response is missing a created_at field.`)
181
+ }
182
+ if (!raw.updated_at) {
183
+ throw new Error(`Order ${raw.id} response is missing an updated_at field.`)
184
+ }
185
+ if (!raw.business_id) {
186
+ throw new Error(`Order ${raw.id} response is missing a business_id field.`)
187
+ }
92
188
 
93
189
  return {
94
190
  id: raw.id,
95
- display_id: raw.display_id,
191
+ display_id: raw.display_id || raw.id,
192
+ business_id: raw.business_id,
193
+ customer_id: raw.customer_id ?? undefined,
96
194
  items,
97
- total: raw.total_amount ?? raw.total ?? 0,
98
195
  status: raw.status,
99
- customerEmail: raw.guest_email ?? raw.customer_email ?? '',
100
- customerName: deriveCustomerName(raw),
101
- createdAt: raw.created_at ? new Date(raw.created_at).getTime() : Date.now(),
102
- updatedAt: raw.updated_at ? new Date(raw.updated_at).getTime() : Date.now(),
103
- deliveryMethod: raw.delivery_method,
104
- trackingNumber: raw.tracking_number,
105
- deliveryAddress: typeof raw.shipping_address === 'string'
106
- ? raw.shipping_address
107
- : addr
108
- ? `${addr.street}, ${addr.city}, ${addr.state}, ${addr.country}`
109
- : undefined,
196
+ payment_status: raw.payment_status || 'pending',
197
+ product_type: (raw.product_type ?? undefined) as Order['product_type'],
198
+
199
+ // Canonical backend fields
200
+ total_amount: raw.total_amount,
201
+ currency: raw.currency,
202
+ subtotal_amount: raw.subtotal_amount ?? undefined,
203
+ shipping_amount: raw.shipping_amount ?? undefined,
204
+ discount_amount: raw.discount_amount ?? undefined,
205
+ tax_amount: raw.tax_amount ?? undefined,
206
+ customer_name: customerName,
207
+ customer_email: customerEmail,
208
+ // REAL BUG FIXED (2026-09-23) via the REST codegen initiative: every
209
+ // field below this point used to read a flat `raw.<field>` that does
210
+ // NOT exist anywhere on the real OrderResponse - confirmed by reading
211
+ // the actual Pydantic classes, not just the generated types. They
212
+ // genuinely live nested under `physical_fulfillment` (shipping/
213
+ // tracking/carrier/pickup/surcharge/weight) or `digital_fulfillment`
214
+ // (buyer_country) - "One nested block per product type, instead of
215
+ // ~35 flat fields on OrderBase" per that schema file's own Order
216
+ // Schema Refactor comment. Every one of these was silently `undefined`
217
+ // on every real order: a customer's tracking number, carrier, delivery
218
+ // status, and shipping address never actually rendered anywhere on the
219
+ // storefront's order tracking page.
220
+ tracking_number: physical?.tracking_number ?? undefined,
221
+ delivery_status: physical?.delivery_status ?? undefined,
222
+ carrier_name: physical?.carrier_name ?? undefined,
223
+ carrier_slug: physical?.carrier_slug ?? undefined,
224
+ terminal_rate_id: physical?.terminal_rate_id ?? undefined,
225
+ guest_token: raw.guest_token ?? undefined,
226
+ guest_email: raw.guest_email ?? undefined,
227
+ guest_phone: raw.guest_phone ?? undefined,
228
+ guest_name: raw.guest_name ?? undefined,
229
+ buyer_country: raw.digital_fulfillment?.buyer_country ?? undefined,
230
+ created_at: raw.created_at,
231
+ updated_at: raw.updated_at,
232
+
233
+ // Weight discrepancy & surcharge fields (Task 2) — physical_fulfillment
234
+ shipping_surcharge_amount: physical?.shipping_surcharge_amount ?? undefined,
235
+ shipping_surcharge_reason: physical?.shipping_surcharge_reason ?? undefined,
236
+ shipping_surcharge_description: physical?.shipping_surcharge_description ?? undefined,
237
+ shipping_surcharge_applied_at: physical?.shipping_surcharge_applied_at ?? undefined,
238
+ declared_weight_kg: physical?.declared_weight_kg ?? undefined,
239
+ actual_weight_kg: physical?.actual_weight_kg ?? undefined,
240
+
241
+ // Self-pickup verification fields (Task 3) — physical_fulfillment
242
+ pickup_verification_code: physical?.pickup_verification_code ?? undefined,
243
+ pickup_verification_code_generated_at: physical?.pickup_verification_code_generated_at ?? undefined,
244
+ pickup_verification_code_used_at: physical?.pickup_verification_code_used_at ?? undefined,
245
+ pickup_verification_failed_attempts: physical?.pickup_verification_failed_attempts,
246
+ pickup_verification_locked_until: physical?.pickup_verification_locked_until ?? undefined,
247
+ pickup_qr_payload: physical?.pickup_qr_payload ?? undefined,
248
+
249
+ // Bespoke fields — real backend nests these under `bespoke_fulfillment`.
250
+ bespoke_brief: raw.bespoke_fulfillment?.brief ?? undefined,
251
+ bespoke_reference_images: raw.bespoke_fulfillment?.reference_images ?? undefined,
252
+ bespoke_measurements: raw.bespoke_fulfillment?.measurements ?? undefined,
253
+ bespoke_preferred_timeline: raw.bespoke_fulfillment?.preferred_timeline ?? undefined,
254
+ bespoke_special_instructions: raw.bespoke_fulfillment?.special_instructions ?? undefined,
255
+ bespoke_deposit_ratio: raw.bespoke_fulfillment?.deposit_ratio ?? undefined,
256
+ bespoke_deposit_amount: raw.bespoke_fulfillment?.deposit_amount ?? undefined,
257
+ bespoke_balance_amount: raw.bespoke_fulfillment?.balance_amount ?? undefined,
258
+ bespoke_status: raw.bespoke_fulfillment?.status ?? undefined,
259
+ bespoke_deliverable_submitted_at: raw.bespoke_fulfillment?.deliverable_submitted_at ?? undefined,
260
+ bespoke_deliverable_url: raw.bespoke_fulfillment?.deliverable_url ?? undefined,
261
+ bespoke_deliverable_notes: raw.bespoke_fulfillment?.deliverable_notes ?? undefined,
262
+ bespoke_buyer_approved_at: raw.bespoke_fulfillment?.buyer_approved_at ?? undefined,
263
+ bespoke_revision_count: raw.bespoke_fulfillment?.revision_count ?? 0,
264
+ bespoke_revision_notes: raw.bespoke_fulfillment?.revision_notes ?? undefined,
265
+
266
+ // SDK camelCase convenience aliases
267
+ total: raw.total_amount ?? undefined,
268
+ shippingAmount: raw.shipping_amount ?? undefined,
269
+ discountAmount: raw.discount_amount ?? undefined,
270
+ customerEmail: customerEmail,
271
+ customerName: customerName,
272
+ createdAt: new Date(raw.created_at).getTime(),
273
+ updatedAt: new Date(raw.updated_at).getTime(),
274
+ // REAL BUG FIXED (2026-09-23), found alongside the nesting bug above:
275
+ // `deliveryMethod` reads physical_fulfillment.shipping_method now
276
+ // (was reading a flat `delivery_method` field that never existed at
277
+ // all, anywhere - not even nested). The real value is "carrier" or
278
+ // "self_pickup" (PhysicalFulfillmentCreate.shipping_method's own
279
+ // docstring) - see the matching fix in kasuvia-storefront's
280
+ // OrderDeliveryInfo.tsx, which used to compare against the wrong
281
+ // string "pickup".
282
+ deliveryMethod: physical?.shipping_method ?? undefined,
283
+ trackingNumber: physical?.tracking_number ?? undefined,
284
+ deliveryAddress: addr
285
+ ? `${addr.street}, ${addr.city}, ${addr.state}, ${addr.country}`
286
+ : undefined,
110
287
  shippingAddress: addr
111
288
  ? {
112
289
  first_name: addr.first_name ?? '',
@@ -115,67 +292,124 @@ function normaliseStorefrontOrder(raw: RawOrder): Order {
115
292
  street: addr.street ?? '',
116
293
  city: addr.city ?? '',
117
294
  state: addr.state ?? '',
118
- lga: addr.lga ?? '',
295
+ lga: addr.lga,
119
296
  country: addr.country ?? '',
120
- postal: addr.postal ?? '',
297
+ postal: addr.postal,
121
298
  }
122
299
  : undefined,
123
- cancellationReason: raw.cancellation_reason,
300
+ // REAL GAP (2026-09-23), NOT fixed here: confirmed cancellation_reason
301
+ // does not exist anywhere in OrderResponse or any of its fulfillment
302
+ // sub-schemas (kasuvia-store-management's app/schemas/order.py) - the
303
+ // cancel-order endpoint accepts a `reason` in its request body but the
304
+ // backend never persists/returns it back on the order. Permanently
305
+ // undefined until the backend adds real storage for it; kept as a
306
+ // field on the public Order type rather than silently removed, since
307
+ // deleting a public SDK field is a breaking change this fix's scope
308
+ // doesn't cover.
309
+ cancellationReason: undefined,
124
310
  hasBespoke: raw.product_type === 'bespoke',
125
- bespokeStage: raw.bespoke_stage as Order['bespokeStage'],
126
- bespokeBrief: raw.bespoke_brief,
127
- deliveryStatus: raw.delivery_status,
128
- carrierName: raw.carrier_name,
129
- terminalShipmentId: raw.terminal_shipment_id,
130
- guestToken: raw.guest_token,
131
- guestEmail: raw.guest_email,
132
- guestPhone: raw.guest_phone,
133
- guestName: raw.guest_name,
311
+ // REAL GAP (2026-09-23), NOT fixed here: same as cancellationReason -
312
+ // confirmed no `bespoke_stage` field exists anywhere; the real,
313
+ // equivalent concept is bespoke_fulfillment.status (already exposed
314
+ // above as `bespoke_status`). Reads that here rather than staying
315
+ // permanently undefined, since it's genuinely the same real data the
316
+ // consuming components (BespokeProgress) want to key off of.
317
+ bespokeStage: raw.bespoke_fulfillment?.status ?? undefined,
318
+ bespokeBrief: raw.bespoke_fulfillment?.brief ?? undefined,
319
+ deliveryStatus: physical?.delivery_status ?? undefined,
320
+ carrierName: physical?.carrier_name ?? undefined,
321
+ terminalShipmentId: physical?.terminal_shipment_id ?? undefined,
322
+ guestToken: raw.guest_token ?? undefined,
323
+ guestEmail: raw.guest_email ?? undefined,
324
+ guestPhone: raw.guest_phone ?? undefined,
325
+ guestName: raw.guest_name ?? undefined,
134
326
  }
135
327
  }
136
328
 
137
- export async function createOrder(config: SdkConfig, accessToken: string, payload: CreateOrderParams): Promise<Order> {
138
- const response = await fetch(`${config.backendUrls.store}/orders/storefront`, {
329
+ /**
330
+ * REAL BUG FIXED (2026-09-22): `accessToken` was mandatory (`string`, not
331
+ * optional) — the real backend endpoint (`POST /orders/storefront`,
332
+ * create_storefront_order) explicitly supports guest checkout: `if not
333
+ * user_id and not x_guest_session: raise 401`, matching the same
334
+ * X-Guest-Session pattern cart.service.ts already uses. This endpoint
335
+ * redeems the customer's real server-side cart (guest or authenticated) —
336
+ * `payload.items`/`payload.product_type` are ignored by the backend
337
+ * entirely regardless (see CreateOrderParams' own doc comment) — so a
338
+ * guest with items already in their guest cart had no way to check out at
339
+ * all through this SDK, even though the backend was built for exactly
340
+ * that. `guestSessionId` is a separate parameter (not a body field) since
341
+ * the backend reads it as a header, not JSON.
342
+ */
343
+ export async function createOrder(
344
+ config: SdkConfig,
345
+ accessToken: string | undefined,
346
+ payload: CreateOrderParams,
347
+ guestSessionId?: string
348
+ ): Promise<Order> {
349
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
350
+ if (accessToken) {
351
+ headers['Authorization'] = `Bearer ${accessToken}`
352
+ } else if (guestSessionId) {
353
+ headers['X-Guest-Session'] = guestSessionId
354
+ }
355
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/storefront`, {
139
356
  method: 'POST',
140
- headers: {
141
- 'Content-Type': 'application/json',
142
- "Authorization": `Bearer ${accessToken}`,
143
- "X-Storefront-Key": config.storefrontApiKey,
144
- "X-Business-ID": config.businessId,
145
- },
357
+ headers,
146
358
  body: JSON.stringify(payload),
359
+ fallbackErrorMessage: 'Failed to create order',
147
360
  })
148
- if (!response.ok) throw new Error('Failed to create order')
149
- const data = await response.json()
150
361
  return normaliseStorefrontOrder(data)
151
362
  }
152
363
 
153
- export async function findOrder(config: SdkConfig, params: FindOrderParams & { guest_token?: string }): Promise<Order> {
154
- const queryParams = new URLSearchParams()
155
- if (params.email) queryParams.set('email', params.email)
156
- if (params.order_id) queryParams.set('order_id', params.order_id)
157
- if (params.guest_token) queryParams.set('guest_token', params.guest_token)
158
-
159
- const response = await fetch(`${config.backendUrls.store}/orders/find/?${queryParams.toString()}`, {
160
- headers: {
161
- "X-Storefront-Key": config.storefrontApiKey,
162
- "X-Business-ID": config.businessId,
163
- },
364
+ /** Atomic, cart-less bespoke commission checkout — POST /orders/storefront/bespoke. */
365
+ export async function createBespokeOrder(config: SdkConfig, accessToken: string | undefined, payload: CreateBespokeOrderParams): Promise<Order> {
366
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
367
+ if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`
368
+
369
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/storefront/bespoke`, {
370
+ method: 'POST',
371
+ headers,
372
+ body: JSON.stringify(payload),
373
+ fallbackErrorMessage: 'Failed to create bespoke order',
374
+ })
375
+ return normaliseStorefrontOrder(data)
376
+ }
377
+
378
+ /** Atomic, cart-less PAID service booking checkout — POST /orders/storefront/service. */
379
+ export async function createServiceBookingOrder(config: SdkConfig, accessToken: string | undefined, payload: CreateServiceBookingOrderParams): Promise<Order> {
380
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
381
+ if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`
382
+
383
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/storefront/service`, {
384
+ method: 'POST',
385
+ headers,
386
+ body: JSON.stringify(payload),
387
+ fallbackErrorMessage: 'Failed to create service booking order',
164
388
  })
165
- if (!response.ok) throw new Error('Failed to find order')
166
- const data = await response.json()
167
389
  return normaliseStorefrontOrder(data)
168
390
  }
169
391
 
170
- export async function findOrderByToken(config: SdkConfig, token: string): Promise<Order> {
171
- const response = await fetch(`${config.backendUrls.store}/orders/find/?guest_token=${token}`, {
172
- headers: {
173
- "X-Storefront-Key": config.storefrontApiKey,
174
- "X-Business-ID": config.businessId,
175
- },
392
+ /**
393
+ * REAL BUG FIXED (2026-09-22): called `GET /orders/find/?email=...&order_id=
394
+ * ...&guest_token=...` — this route does not exist anywhere on the real
395
+ * backend (confirmed against every route in app/api/v1/endpoints/sales/
396
+ * orders.py). Every real call 404'd — the entire "track my order by email +
397
+ * order ID" flow (`/track-order`, the actual page kasuvia-storefront's own
398
+ * checkout confirmation links to) has been broken. The real, working
399
+ * mechanism is `POST /orders/track` with a `GuestTrackRequest` body
400
+ * (`{order_id, email}` — both required, no `guest_token` field exists on
401
+ * this or any other real request shape). Fixed to match.
402
+ */
403
+ export async function findOrder(config: SdkConfig, params: FindOrderParams): Promise<Order> {
404
+ if (!params.email || !params.order_id) {
405
+ throw new Error('Both email and order_id are required to find an order.')
406
+ }
407
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/track`, {
408
+ method: 'POST',
409
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
410
+ body: JSON.stringify({ order_id: params.order_id, email: params.email }),
411
+ fallbackErrorMessage: 'Failed to find order',
176
412
  })
177
- if (!response.ok) throw new Error('Failed to find order by token')
178
- const data = await response.json()
179
413
  return normaliseStorefrontOrder(data)
180
414
  }
181
415
 
@@ -184,45 +418,182 @@ export async function getOrders(config: SdkConfig, accessToken: string, params?:
184
418
  if (params?.email) queryParams.set('email', params.email)
185
419
  if (params?.page) queryParams.set('page', params.page.toString())
186
420
  if (params?.page_size) queryParams.set('page_size', params.page_size.toString())
187
-
188
- const response = await fetch(`${config.backendUrls.store}/orders/?${queryParams.toString()}`, {
189
- headers: {
190
- "Authorization": `Bearer ${accessToken}`,
191
- "X-Storefront-Key": config.storefrontApiKey,
192
- "X-Business-ID": config.businessId,
193
- },
421
+
422
+ const data = await apiRequest<RawOrder[] | { items: RawOrder[] }>(`${config.backendUrls.store}/orders/?${queryParams.toString()}`, {
423
+ headers: authHeaders(config, accessToken),
424
+ fallbackErrorMessage: 'Failed to get orders',
194
425
  })
195
- if (!response.ok) throw new Error('Failed to get orders')
196
- const data = await response.json()
197
426
  const items: RawOrder[] = Array.isArray(data) ? data : (data.items ?? [])
198
427
  return items.map(normaliseStorefrontOrder)
199
428
  }
200
429
 
201
430
  export async function cancelOrder(config: SdkConfig, accessToken: string, orderId: string, reason: string): Promise<Order> {
202
- const response = await fetch(`${config.backendUrls.store}/orders/${orderId}/cancel/`, {
431
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/${orderId}/cancel/`, {
203
432
  method: 'POST',
204
- headers: {
205
- 'Content-Type': 'application/json',
206
- "Authorization": `Bearer ${accessToken}`,
207
- "X-Storefront-Key": config.storefrontApiKey,
208
- "X-Business-ID": config.businessId,
209
- },
433
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
210
434
  body: JSON.stringify({ reason }),
435
+ fallbackErrorMessage: 'Failed to cancel order',
211
436
  })
212
- if (!response.ok) throw new Error('Failed to cancel order')
213
- const data = await response.json()
214
437
  return normaliseStorefrontOrder(data)
215
438
  }
216
439
 
217
- export async function trackOrder(config: SdkConfig, orderId: string, token?: string): Promise<Order> {
218
- const queryParams = token ? `?token=${token}` : ''
219
- const response = await fetch(`${config.backendUrls.store}/orders/track/${orderId}${queryParams}`, {
220
- headers: {
221
- "X-Storefront-Key": config.storefrontApiKey,
222
- "X-Business-ID": config.businessId,
223
- },
440
+ /**
441
+ * REAL BUG FIXED (2026-09-22): called `GET /orders/track/{orderId}?token=
442
+ * ...` — this route does not exist on the real backend at all (confirmed
443
+ * against every route in orders.py). The `token` query param has no
444
+ * corresponding capability anywhere: the real single-order lookup (`GET
445
+ * /{order_id}`) requires a genuine authenticated JWT
446
+ * (`Depends(get_current_user_claims)`) and grants access only to the
447
+ * order's own customer (matching customer_id, or matching guest_email
448
+ * against the JWT's email for a guest-checkout order that later registered)
449
+ * — it has no anonymous/guest-token path at all. There is no real backend
450
+ * capability today for "look up one order by ID + a standalone token, no
451
+ * login required" — the only real guest lookup is findOrder (email +
452
+ * order_id via POST /orders/track, a different, plural-sounding but
453
+ * distinct endpoint). Fixed to call the real endpoint with its real,
454
+ * mandatory-JWT contract; the fictional guest-token parameter is removed
455
+ * rather than kept as a parameter that can never do anything.
456
+ */
457
+ export async function trackOrder(config: SdkConfig, orderId: string, accessToken: string): Promise<Order> {
458
+ const data = await apiRequest<RawOrder>(`${config.backendUrls.store}/orders/${orderId}`, {
459
+ headers: authHeaders(config, accessToken),
460
+ fallbackErrorMessage: 'Failed to track order',
224
461
  })
225
- if (!response.ok) throw new Error('Failed to track order')
226
- const data = await response.json()
227
462
  return normaliseStorefrontOrder(data)
228
463
  }
464
+
465
+ export interface PromoValidationResult {
466
+ valid: boolean;
467
+ discount_amount: number;
468
+ discount_type: 'percentage' | 'fixed';
469
+ message: string;
470
+ }
471
+
472
+ /**
473
+ * Validate a promo / discount code against kasuvia-store-management.
474
+ *
475
+ * Routes directly to POST /sales/discounts/validate on the store backend
476
+ * using X-Storefront-Key + X-Business-ID for tenant isolation.
477
+ * The backend is the single source of truth for discount validity,
478
+ * amount calculation, and usage tracking.
479
+ */
480
+ export async function validatePromoCode(
481
+ config: SdkConfig,
482
+ code: string,
483
+ cartTotal: number,
484
+ ): Promise<PromoValidationResult> {
485
+ try {
486
+ return await apiRequest<PromoValidationResult>(`${config.backendUrls.store}/sales/discounts/validate`, {
487
+ method: 'POST',
488
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
489
+ body: JSON.stringify({ code, cart_total: cartTotal }),
490
+ fallbackErrorMessage: 'Invalid promo code',
491
+ })
492
+ } catch (err) {
493
+ const message = err instanceof ApiError ? err.message : 'Invalid promo code'
494
+ return { valid: false, discount_amount: 0, discount_type: 'fixed', message }
495
+ }
496
+ }
497
+
498
+ // ── Digital download delivery ───────────────────────────────────────────────
499
+ // REAL GAP FIXED (2026-09-22): GET /orders/{order_id}/download/{token} (the
500
+ // exact link every "your download is ready" email sends) had ZERO kasuvia-sdk
501
+ // coverage - the raw backend URL was embedded directly in the email instead,
502
+ // and for a 2+-file product the route returns a raw JSON listing with no
503
+ // page anywhere to render it (a customer clicking "Download" would see raw
504
+ // JSON text in their browser). These are server-only functions (real,
505
+ // un-proxied config.backendUrls.store, like legal.service.ts) meant to be
506
+ // called from a storefront Server Component, never client-side - the real
507
+ // backend route itself needs no business/storefront headers at all (the
508
+ // token IS the capability, by design - see the route's own doc comment),
509
+ // so no config beyond backendUrls.store is required either.
510
+ //
511
+ // Single file: the real route does an HTTP redirect straight to a freshly
512
+ // presigned R2 URL - `redirect: 'manual'` lets a server-side fetch read
513
+ // that Location header directly instead of the runtime silently following
514
+ // it (which would otherwise download the whole file server-side for no
515
+ // reason). 2+ files: the real route returns JSON instead, since a redirect
516
+ // can only ever point at one destination.
517
+
518
+ export interface DigitalDownloadRedirect {
519
+ type: 'redirect'
520
+ url: string
521
+ }
522
+
523
+ export interface DigitalDownloadFileEntry {
524
+ id: string
525
+ display_name: string
526
+ sort_order: number
527
+ /** Raw backend URL - never render this directly; resolve it via resolveDigitalDownloadFile instead. */
528
+ download_url: string
529
+ }
530
+
531
+ export interface DigitalDownloadListing {
532
+ type: 'listing'
533
+ order_id: string
534
+ product_name: string
535
+ download_count: number
536
+ max_downloads: number
537
+ expires_at: string | null
538
+ files: DigitalDownloadFileEntry[]
539
+ }
540
+
541
+ export type DigitalDownloadResult = DigitalDownloadRedirect | DigitalDownloadListing
542
+
543
+ async function resolveRedirectOrJson<T>(url: string, fallbackErrorMessage: string): Promise<{ type: 'redirect'; url: string } | { type: 'json'; data: T }> {
544
+ const response = await fetch(url, { redirect: 'manual' })
545
+
546
+ if (response.status >= 300 && response.status < 400) {
547
+ const location = response.headers.get('location')
548
+ if (!location) {
549
+ throw new Error(`${fallbackErrorMessage}: server redirected with no destination URL.`)
550
+ }
551
+ return { type: 'redirect', url: location }
552
+ }
553
+
554
+ const body = await response.json().catch(() => ({}))
555
+ if (!response.ok) {
556
+ throw new ApiError(extractDigitalDownloadError(body, fallbackErrorMessage), response.status, body)
557
+ }
558
+ return { type: 'json', data: body as T }
559
+ }
560
+
561
+ function extractDigitalDownloadError(body: unknown, fallback: string): string {
562
+ if (typeof body === 'object' && body !== null && 'detail' in body && typeof (body as { detail?: unknown }).detail === 'string') {
563
+ return (body as { detail: string }).detail
564
+ }
565
+ return fallback
566
+ }
567
+
568
+ /** GET /orders/{order_id}/download/{token} - server-only, no config beyond backendUrls.store needed. */
569
+ export async function resolveDigitalDownload(
570
+ config: Pick<SdkConfig, 'backendUrls'>,
571
+ orderId: string,
572
+ token: string
573
+ ): Promise<DigitalDownloadResult> {
574
+ const result = await resolveRedirectOrJson<Omit<DigitalDownloadListing, 'type'>>(
575
+ `${config.backendUrls.store}/orders/${orderId}/download/${token}`,
576
+ 'Failed to resolve digital download link'
577
+ )
578
+ if (result.type === 'redirect') return result
579
+ return { type: 'listing', ...result.data }
580
+ }
581
+
582
+ /** GET /orders/{order_id}/download/{token}/{file_id} - resolves ONE file's real, freshly-presigned URL. */
583
+ export async function resolveDigitalDownloadFile(
584
+ config: Pick<SdkConfig, 'backendUrls'>,
585
+ orderId: string,
586
+ token: string,
587
+ fileId: string
588
+ ): Promise<string> {
589
+ const result = await resolveRedirectOrJson<never>(
590
+ `${config.backendUrls.store}/orders/${orderId}/download/${token}/${fileId}`,
591
+ 'Failed to resolve this file\'s download link'
592
+ )
593
+ if (result.type !== 'redirect') {
594
+ throw new Error('Failed to resolve this file\'s download link: server did not return a redirect.')
595
+ }
596
+ return result.url
597
+ }
598
+
599
+