@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.
- package/README.md +42 -15
- package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
- package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
- package/dist/components/index.d.mts +497 -0
- package/dist/components/index.d.ts +497 -0
- package/dist/components/index.js +4217 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +4194 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/server.d.mts +26 -0
- package/dist/config/server.d.ts +26 -0
- package/dist/config/server.js +38 -0
- package/dist/config/server.js.map +1 -0
- package/dist/config/server.mjs +13 -0
- package/dist/config/server.mjs.map +1 -0
- package/dist/hooks/index.d.mts +393 -160
- package/dist/hooks/index.d.ts +393 -160
- package/dist/hooks/index.js +2792 -2137
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/index.mjs +2734 -2121
- package/dist/hooks/index.mjs.map +1 -1
- package/dist/index-CNW_LINy.d.mts +578 -0
- package/dist/index-CNW_LINy.d.ts +578 -0
- package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
- package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
- package/dist/index-NkI10J_E.d.mts +40 -0
- package/dist/index-NkI10J_E.d.ts +40 -0
- package/dist/index.d.mts +20437 -103
- package/dist/index.d.ts +20437 -103
- package/dist/index.js +2111 -1802
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2006 -1786
- package/dist/index.mjs.map +1 -1
- package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
- package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
- package/dist/services/index.d.mts +908 -2
- package/dist/services/index.d.ts +908 -2
- package/dist/services/index.js +1492 -1686
- package/dist/services/index.js.map +1 -1
- package/dist/services/index.mjs +1431 -1670
- package/dist/services/index.mjs.map +1 -1
- package/package.json +46 -6
- package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
- package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
- package/src/components/bespoke/index.ts +3 -0
- package/src/components/booking/SlotPicker.tsx +97 -0
- package/src/components/booking/index.ts +1 -0
- package/src/components/cart/CartItem.tsx +81 -0
- package/src/components/cart/CartSidebar.tsx +115 -0
- package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
- package/src/components/cart/index.ts +3 -0
- package/src/components/index.ts +8 -0
- package/src/components/orders/DeliverableReviewer.tsx +139 -0
- package/src/components/orders/index.ts +1 -0
- package/src/components/product/ProductCard.tsx +350 -0
- package/src/components/product/ProductGrid.tsx +84 -0
- package/src/components/product/index.ts +2 -0
- package/src/components/shared/AddressAutocomplete.tsx +212 -0
- package/src/components/shared/AnalyticsScripts.tsx +56 -0
- package/src/components/shared/CookieConsentBanner.tsx +98 -0
- package/src/components/shared/ImageEditorModal.tsx +1066 -0
- package/src/components/shared/ImageViewerModal.tsx +598 -0
- package/src/components/shared/LucideReactIcon.tsx +52 -0
- package/src/components/shared/MapPinDrop.tsx +101 -0
- package/src/components/shared/MultiSelectPicker.tsx +193 -0
- package/src/components/shared/OfflineBanner.tsx +46 -0
- package/src/components/shared/SearchableInput.tsx +153 -0
- package/src/components/shared/SearchablePopover.tsx +166 -0
- package/src/components/shared/SmartImage.tsx +311 -0
- package/src/components/shared/TruncatedValue.tsx +27 -0
- package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
- package/src/components/shared/index.ts +15 -0
- package/src/components/skeletons/CardSkeleton.tsx +26 -0
- package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
- package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
- package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
- package/src/components/skeletons/TableSkeleton.tsx +42 -0
- package/src/components/skeletons/index.ts +5 -0
- package/src/config/index.ts +22 -4
- package/src/config/server.ts +45 -0
- package/src/context/sdk-context.tsx +5 -2
- package/src/generated/auth-openapi.ts +2935 -0
- package/src/generated/cart-openapi.ts +652 -0
- package/src/generated/cms-openapi.ts +1668 -0
- package/src/generated/crm-openapi.ts +986 -0
- package/src/generated/graphql.ts +126 -0
- package/src/generated/index.ts +11 -0
- package/src/generated/media-openapi.ts +613 -0
- package/src/generated/notification-openapi.ts +862 -0
- package/src/generated/payment-openapi.ts +2713 -0
- package/src/generated/store-openapi.ts +9510 -0
- package/src/graphql/operations/collections.graphql +32 -0
- package/src/graphql/operations/products.graphql +197 -0
- package/src/graphql/operations/wishlist.graphql +25 -0
- package/src/hooks/index.ts +9 -2
- package/src/hooks/use-auth.ts +163 -69
- package/src/hooks/use-bespoke-availability.ts +39 -22
- package/src/hooks/use-business-config.ts +51 -0
- package/src/hooks/use-cart.ts +310 -238
- package/src/hooks/use-cms.ts +107 -127
- package/src/hooks/use-collections.ts +35 -55
- package/src/hooks/use-consultations.ts +9 -136
- package/src/hooks/use-countries.ts +19 -28
- package/src/hooks/use-currencies.ts +8 -20
- package/src/hooks/use-customer-address.ts +64 -0
- package/src/hooks/use-feedback.ts +34 -0
- package/src/hooks/use-languages.ts +26 -39
- package/src/hooks/use-media-upload.ts +23 -0
- package/src/hooks/use-notification-stream.ts +88 -0
- package/src/hooks/use-notifications.ts +163 -0
- package/src/hooks/use-order-stream.ts +29 -29
- package/src/hooks/use-orders.ts +91 -73
- package/src/hooks/use-payment.ts +116 -84
- package/src/hooks/use-products.ts +65 -269
- package/src/hooks/use-push-notifications.ts +122 -0
- package/src/hooks/use-reviews.ts +175 -0
- package/src/hooks/use-service-booking.ts +109 -132
- package/src/hooks/use-states.ts +16 -30
- package/src/hooks/use-wishlist.ts +44 -69
- package/src/index.ts +15 -11
- package/src/services/auth.service.ts +422 -132
- package/src/services/bespoke.service.ts +69 -10
- package/src/services/cart.service.ts +155 -186
- package/src/services/cms.service.ts +41 -50
- package/src/services/collection.service.ts +25 -63
- package/src/services/config.service.ts +10 -21
- package/src/services/consultation.service.ts +36 -93
- package/src/services/feedback.service.ts +76 -0
- package/src/services/index.ts +7 -1
- package/src/services/legal.service.ts +62 -0
- package/src/services/logistics.service.ts +235 -53
- package/src/services/media.service.ts +115 -0
- package/src/services/notifications.service.ts +204 -20
- package/src/services/order.service.ts +498 -127
- package/src/services/payment.service.ts +149 -100
- package/src/services/product.service.ts +157 -814
- package/src/services/review.service.ts +211 -0
- package/src/services/service-booking.service.ts +198 -99
- package/src/services/wishlist.service.ts +27 -74
- package/src/types/auth/index.ts +161 -18
- package/src/types/business/index.ts +9 -1
- package/src/types/cms/index.ts +91 -6
- package/src/types/cms/legal.ts +18 -0
- package/src/types/consultations/index.ts +60 -68
- package/src/types/index.ts +5 -3
- package/src/types/media/index.ts +35 -3
- package/src/types/payments/index.ts +80 -16
- package/src/types/reviews/index.ts +116 -0
- package/src/types/services/index.ts +113 -38
- package/src/types/store/checkout.ts +53 -0
- package/src/types/store/index.ts +273 -52
- package/src/types/store/invoice.ts +31 -0
- package/src/types/store/shipping.ts +84 -3
- package/src/types/ui/index.ts +1 -1
- package/src/utils/checkout.ts +19 -3
- package/src/utils/cookieConsent.ts +34 -0
- package/src/utils/format.ts +408 -0
- package/src/utils/geolocation.ts +134 -0
- package/src/utils/graphql-url.ts +47 -0
- package/src/utils/guest-session.ts +59 -0
- package/src/utils/http-client.ts +147 -0
- package/src/utils/index.ts +11 -4
- package/src/utils/status-colors.ts +79 -0
- package/src/utils/token-refresh.ts +115 -0
- package/src/utils/validators.ts +13 -0
- package/src/utils/youtube.ts +18 -0
- package/dist/index-C5YO9gTl.d.mts +0 -507
- package/dist/index-C5YO9gTl.d.ts +0 -507
- package/dist/index-CefG7_xq.d.mts +0 -793
- package/dist/index-DeHCf3zI.d.ts +0 -793
- package/src/graphql/collections.graphql.ts +0 -48
- package/src/graphql/index.ts +0 -7
- package/src/graphql/products.graphql.ts +0 -860
- package/src/graphql/wishlist.graphql.ts +0 -34
- package/src/hooks/use-consultation-stream.ts +0 -142
- 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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 =
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
headers
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
189
|
-
headers:
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
|