@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
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Guest Cart Session
3
+ *
4
+ * REAL BUG FIXED (2026-09-21): kasuvia-cart-management's real
5
+ * `POST /api/v1/cart/*` routes hard-require an `X-Guest-Session` header for
6
+ * any anonymous (unauthenticated) caller — confirmed against the live
7
+ * backend (cart.go's `resolveTenant`/guest-path branches): with no
8
+ * Authorization and no X-Guest-Session, add-to-cart returns 401
9
+ * "Authentication required. Please log in or provide a guest session."
10
+ * `useCart()` has always accepted a `guestSessionId` parameter for exactly
11
+ * this, but nothing anywhere in kasuvia-storefront ever generated or passed
12
+ * one — every real `useCart()` call site called it with zero arguments. That
13
+ * meant anonymous add-to-cart has never worked at all on the real storefront:
14
+ * `addToCartMutation` throws 'Cannot add to cart: no access token or guest
15
+ * session ID provided.' client-side before a request is even sent.
16
+ *
17
+ * This generates and persists one real guest session id per browser (via the
18
+ * existing tenant-isolated `localStore` — already prefixed per business_id,
19
+ * so multiple businesses tested on the same origin never collide), reused by
20
+ * `SdkProvider` to feed every `useCart()` call through context automatically
21
+ * (see sdk-context.tsx), the same way `config`/`accessToken` already are.
22
+ */
23
+ import { localStore } from './storage'
24
+
25
+ const GUEST_SESSION_KEY = 'guest_session_id'
26
+
27
+ /**
28
+ * Returns the current browser's guest cart session id for this business,
29
+ * generating and persisting one on first call. SSR-safe: returns null on
30
+ * the server (no localStorage) — callers should only invoke this from a
31
+ * client-side effect, matching every other browser-storage read in this SDK.
32
+ */
33
+ export function getOrCreateGuestSessionId(businessId: string): string | null {
34
+ if (typeof window === 'undefined') return null
35
+ try {
36
+ const existing = localStore.get(businessId, GUEST_SESSION_KEY)
37
+ if (existing) return existing
38
+ const generated = crypto.randomUUID()
39
+ localStore.set(businessId, GUEST_SESSION_KEY, generated)
40
+ return generated
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Clears the persisted guest session id — call after a successful guest
48
+ * cart merge into an authenticated customer's cart, so a subsequent logout
49
+ * on the same browser starts a fresh guest session rather than reusing one
50
+ * the backend has already deleted server-side.
51
+ */
52
+ export function clearGuestSessionId(businessId: string): void {
53
+ if (typeof window === 'undefined') return
54
+ try {
55
+ localStore.remove(businessId, GUEST_SESSION_KEY)
56
+ } catch {
57
+ // Best-effort cleanup — a failure here shouldn't surface to the caller.
58
+ }
59
+ }
@@ -0,0 +1,147 @@
1
+ import type { SdkConfig } from '../config'
2
+ import { refreshAccessToken, hasRefreshHandler } from './token-refresh'
3
+
4
+ /**
5
+ * Thrown by apiRequest on any non-2xx response. Carries the real HTTP status
6
+ * and parsed body so a caller that needs to branch on status (e.g. a 409
7
+ * "confirmation required" two-step flow) can catch this instead of the
8
+ * generic client special-casing every such flow.
9
+ */
10
+ export class ApiError extends Error {
11
+ status: number
12
+ body: unknown
13
+ constructor(message: string, status: number, body: unknown) {
14
+ super(message)
15
+ this.name = 'ApiError'
16
+ this.status = status
17
+ this.body = body
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Extract the real, specific error message from a Kasuvia backend's error
23
+ * body, whichever of the two real shapes this platform's services return:
24
+ *
25
+ * - FastAPI/Pydantic (store-management, cart-management, cms, crm-go,
26
+ * notification-go, payment-go): `{"detail": "plain string"}` for a
27
+ * manually-raised HTTPException, or `{"detail": [{"loc": [...], "msg":
28
+ * "...", "type": "..."}]}` for an automatic Pydantic validation failure.
29
+ * - Django REST Framework (kasuvia-auth): `{"detail": "plain string"}` for
30
+ * AuthenticationFailed/NotFound/PermissionDenied-style exceptions, or a
31
+ * bare validation-error dict with no top-level `detail` at all -
32
+ * `{"non_field_errors": [...]}` or `{"email": [...]}"` - when a
33
+ * serializer's own `is_valid(raise_exception=True)` fires.
34
+ *
35
+ * Previously two separately-named, independently-adopted copies of this
36
+ * logic existed (`extractHttpErrorDetail`, `extractAuthErrorMessage`) - only
37
+ * 7 of 18 SDK service files used either one at all. Merged into one function
38
+ * every call site now goes through via apiRequest, so error quality no
39
+ * longer depends on which service function happens to have remembered to
40
+ * check for it.
41
+ */
42
+ export function extractApiErrorMessage(body: unknown, fallback: string): string {
43
+ if (typeof body !== 'object' || body === null) return fallback
44
+ const record = body as Record<string, unknown>
45
+
46
+ if (typeof record.detail === 'string') return record.detail
47
+
48
+ if (Array.isArray(record.detail) && record.detail.length > 0) {
49
+ const first = record.detail[0] as Record<string, unknown> | undefined
50
+ if (first && typeof first.msg === 'string') {
51
+ const loc = first.loc
52
+ const field = Array.isArray(loc) ? loc[loc.length - 1] : undefined
53
+ return typeof field === 'string' || typeof field === 'number' ? `${field}: ${first.msg}` : first.msg
54
+ }
55
+ }
56
+
57
+ const firstStringOrArrayItem = (value: unknown): string | undefined => {
58
+ if (typeof value === 'string') return value
59
+ if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
60
+ return undefined
61
+ }
62
+
63
+ const nonFieldMessage = firstStringOrArrayItem(record.non_field_errors)
64
+ if (nonFieldMessage) return nonFieldMessage
65
+
66
+ for (const [key, value] of Object.entries(record)) {
67
+ if (key === 'detail' || key === 'non_field_errors') continue
68
+ const message = firstStringOrArrayItem(value)
69
+ if (message) return message
70
+ }
71
+
72
+ return fallback
73
+ }
74
+
75
+ /** `{'X-Storefront-Key': ..., 'X-Business-ID': ...}` - every storefront-scoped call. */
76
+ export function storefrontHeaders(config: SdkConfig): Record<string, string> {
77
+ return {
78
+ 'X-Storefront-Key': config.storefrontApiKey,
79
+ 'X-Business-ID': config.businessId,
80
+ }
81
+ }
82
+
83
+ /** storefrontHeaders plus `Authorization: Bearer <token>` - every authenticated customer call. */
84
+ export function authHeaders(config: SdkConfig, accessToken: string): Record<string, string> {
85
+ return {
86
+ ...storefrontHeaders(config),
87
+ Authorization: `Bearer ${accessToken}`,
88
+ }
89
+ }
90
+
91
+ export const JSON_HEADERS: Record<string, string> = { 'Content-Type': 'application/json' }
92
+
93
+ export interface ApiRequestOptions extends RequestInit {
94
+ /** Used when the response is a non-2xx status whose body carries nothing usable. */
95
+ fallbackErrorMessage?: string
96
+ }
97
+
98
+ /**
99
+ * The one real HTTP call path every REST SDK service function should use.
100
+ * Replaces each function independently doing fetch + response.ok check +
101
+ * error-body parsing (100 call sites across 18 files, only 7 of which
102
+ * consistently surfaced the real backend error before this existed).
103
+ *
104
+ * A 204/empty body resolves to `undefined` rather than throwing on the
105
+ * `response.json()` call - real for logout and a few other no-content
106
+ * responses on this platform.
107
+ *
108
+ * Silent-refresh-and-retry on 401 (see token-refresh.ts's own doc comment
109
+ * for the full real-world bug this fixes and the kasuvia/ pattern it
110
+ * ports): only triggers for a request that actually carried an
111
+ * `Authorization: Bearer` header AND has a refresh handler registered -
112
+ * a storefront-key-only public call's 401 means something else entirely
113
+ * (wrong key/business) and must never trigger a token refresh attempt.
114
+ * Retries at most once per call, mirroring withAuthInterceptors' own
115
+ * `_retry` guard - a fresh token that still 401s is a genuine, final
116
+ * failure, not something to loop on.
117
+ */
118
+ export async function apiRequest<T>(url: string, options: ApiRequestOptions = {}): Promise<T> {
119
+ const { fallbackErrorMessage = 'Request failed', ...init } = options
120
+ const headers = new Headers(init.headers)
121
+ const hadBearerToken = headers.get('Authorization')?.startsWith('Bearer ') ?? false
122
+
123
+ const response = await fetch(url, { ...init, headers })
124
+
125
+ if (response.status === 401 && hadBearerToken && hasRefreshHandler()) {
126
+ const freshToken = await refreshAccessToken()
127
+ if (freshToken) {
128
+ const retryHeaders = new Headers(headers)
129
+ retryHeaders.set('Authorization', `Bearer ${freshToken}`)
130
+ const retryResponse = await fetch(url, { ...init, headers: retryHeaders })
131
+ if (retryResponse.ok) {
132
+ const retryText = await retryResponse.text()
133
+ return (retryText ? JSON.parse(retryText) : undefined) as T
134
+ }
135
+ const retryBody = await retryResponse.json().catch(() => ({}))
136
+ throw new ApiError(extractApiErrorMessage(retryBody, fallbackErrorMessage), retryResponse.status, retryBody)
137
+ }
138
+ }
139
+
140
+ if (!response.ok) {
141
+ const body = await response.json().catch(() => ({}))
142
+ throw new ApiError(extractApiErrorMessage(body, fallbackErrorMessage), response.status, body)
143
+ }
144
+
145
+ const text = await response.text()
146
+ return (text ? JSON.parse(text) : undefined) as T
147
+ }
@@ -1,10 +1,17 @@
1
- /**
2
- * Utils Barrel Export
3
- */
4
-
5
1
  export * from './cache'
6
2
  export * from './logger'
3
+ export * from './storage'
7
4
  export * from './sanitize-url'
8
5
  export * from './customer'
9
6
  export * from './checkout'
10
7
  export * from './error-handler'
8
+ export * from './http-client'
9
+ export * from './token-refresh'
10
+ export * from './format'
11
+ export * from './geolocation'
12
+ export * from './status-colors'
13
+ export * from './youtube'
14
+ export * from './cookieConsent'
15
+ export * from './validators'
16
+ export * from './graphql-url'
17
+ export * from './guest-session'
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Status colour mappings for Kasuvia entity statuses.
3
+ * These are Tailwind CSS class strings; consuming apps must have Tailwind configured.
4
+ * Migrated from kasuvia-storefront to kasuvia-sdk for shared use.
5
+ */
6
+
7
+ import type { ServiceBookingStatus, MilestoneStatus } from "../types/services";
8
+ import type { OrderStatus } from "../types/store";
9
+
10
+ // REAL BUG FIXED (2026-09-20): keyed on a fictional `AppointmentStatus`
11
+ // ("pending" as the initial state) that never matched the real backend enum
12
+ // (kasuvia-store-management's ServiceBookingStatus — the initial state is
13
+ // "scheduled", not "pending"). Renamed/rekeyed to match reality.
14
+ export const appointmentStatusColors: Record<ServiceBookingStatus, string> = {
15
+ scheduled: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
16
+ confirmed: "bg-green-100 text-green-800 hover:bg-green-200",
17
+ in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-200",
18
+ completed: "bg-gray-100 text-gray-800 hover:bg-gray-200",
19
+ voided: "bg-red-100 text-red-800 hover:bg-red-200",
20
+ no_show: "bg-red-100 text-red-800 hover:bg-red-200",
21
+ };
22
+
23
+ // REAL BUG FIXED (2026-09-20): the "deliverable" concept these colors were
24
+ // keyed on (pending/in_progress/review/approved/rejected) has no backend
25
+ // counterpart at all — the real analogous feature is the service booking's
26
+ // milestone/escrow sub-resource (MilestoneStatus), a different status set.
27
+ export const milestoneStatusColors: Record<MilestoneStatus, string> = {
28
+ pending: "bg-gray-100 text-gray-800",
29
+ funded: "bg-blue-100 text-blue-800",
30
+ submitted: "bg-yellow-100 text-yellow-800",
31
+ approved: "bg-green-100 text-green-800",
32
+ released: "bg-green-100 text-green-800",
33
+ disputed: "bg-red-100 text-red-800",
34
+ refunded: "bg-gray-100 text-gray-800",
35
+ };
36
+
37
+ /**
38
+ * REAL GAP FIXED (2026-09-22): no shared order-status mapping existed at
39
+ * all (only appointments/milestones did) — kasuvia-storefront's
40
+ * `OrderStatusCard.tsx` and `DashboardOrders.tsx` each independently
41
+ * rendered order status differently (one a hand-rolled ternary chain with
42
+ * friendlier labels like "Preparing"/"On the way", the other the raw
43
+ * backend enum value capitalized, no color at all) — the same order shows
44
+ * two different things depending which page the customer is on. Keyed on
45
+ * the real `OrderStatus` union (types/store/index.ts).
46
+ */
47
+ export const orderStatusColors: Record<OrderStatus, string> = {
48
+ pending: "bg-gray-100 text-gray-800",
49
+ confirmed: "bg-blue-100 text-blue-800",
50
+ processing: "bg-yellow-100 text-yellow-800",
51
+ packed: "bg-yellow-100 text-yellow-800",
52
+ picked_up: "bg-blue-100 text-blue-800",
53
+ ready_for_pickup: "bg-blue-100 text-blue-800",
54
+ picked_up_by_customer: "bg-green-100 text-green-800",
55
+ in_transit: "bg-blue-100 text-blue-800",
56
+ shipped: "bg-blue-100 text-blue-800",
57
+ delivered: "bg-green-100 text-green-800",
58
+ voided: "bg-red-100 text-red-800",
59
+ refunded: "bg-gray-100 text-gray-800",
60
+ failed: "bg-red-100 text-red-800",
61
+ };
62
+
63
+ /** Human-friendly order status labels — the single source every page should use instead of its own ternary chain or the raw enum value. */
64
+ export const orderStatusLabels: Record<OrderStatus, string> = {
65
+ pending: "Order Placed",
66
+ confirmed: "Confirmed",
67
+ processing: "Preparing",
68
+ packed: "Packed",
69
+ picked_up: "Picked Up",
70
+ ready_for_pickup: "Ready for Pickup",
71
+ picked_up_by_customer: "Collected",
72
+ in_transit: "In Transit",
73
+ shipped: "On the Way",
74
+ delivered: "Delivered",
75
+ voided: "Voided",
76
+ refunded: "Refunded",
77
+ failed: "Failed",
78
+ };
79
+
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Silent-refresh registry for apiRequest's automatic 401 retry.
3
+ *
4
+ * REAL GAP FIXED (2026-09-28), live-reported ("authentication in
5
+ * kasuvia-storefront is terrible, sometimes it gets cut off while using
6
+ * the app"): AuthProvider only ever called /api/auth/refresh once, in a
7
+ * useEffect on mount (restoreSession()) — every one of apiRequest's ~100+
8
+ * real call sites had zero awareness of token expiry at all. The moment a
9
+ * customer's access token expired mid-session (JWT_ACCESS_TOKEN_LIFETIME_
10
+ * MINUTES defaults to 30 on kasuvia-auth — see common/utils/tokens.py),
11
+ * every subsequent authenticated call 401'd and stayed broken for the rest
12
+ * of that browser tab, with no recovery short of a full page reload
13
+ * (which re-runs restoreSession() exactly once more, then the cycle
14
+ * repeats). This is a fundamentally different failure mode than "signed
15
+ * out on refresh" (investigated exhaustively earlier this session and
16
+ * found structurally sound) - it only shows up while actively using the
17
+ * app between page loads, matching the user's own description exactly.
18
+ *
19
+ * Compared directly against kasuvia/ (the merchant dashboard)'s own proven
20
+ * architecture (lib/api/interceptors.ts's withAuthInterceptors): every
21
+ * axios instance there has a response interceptor that, on any 401 from a
22
+ * non-refresh endpoint, transparently calls the refresh endpoint once
23
+ * (coalescing concurrent 401s into a single in-flight refresh via a
24
+ * pending-request queue - never a stampede of parallel refresh calls),
25
+ * updates the in-memory access token, and retries the original request
26
+ * with the new token - completely invisible to the calling code and the
27
+ * user. kasuvia/ can do this because its access token lives in a plain,
28
+ * synchronously-readable/writable module-level variable
29
+ * (lib/api/auth/axios.ts's tokenStore), not React state - exactly what
30
+ * makes an imperative, outside-the-render-cycle interceptor possible.
31
+ *
32
+ * This module ports that same pattern into kasuvia-sdk's fetch-based
33
+ * apiRequest, which every one of the storefront's ~100+ authenticated SDK
34
+ * calls already funnels through - fixing it once here reaches every call
35
+ * site with zero changes to any of them. A consumer (AuthProvider) calls
36
+ * registerRefreshHandler() once with a function that knows how to obtain a
37
+ * fresh token (POST /api/auth/refresh, in the storefront's case) and keeps
38
+ * its own React state in sync; apiRequest below calls it automatically
39
+ * whenever a request carrying an Authorization header gets a real 401,
40
+ * with the same in-flight-coalescing guarantee kasuvia/'s interceptor has.
41
+ * If no handler is registered (e.g. a server-side call, or a consumer that
42
+ * never opts in), behavior is completely unchanged - a 401 throws
43
+ * immediately, exactly as before this module existed.
44
+ *
45
+ * REAL BUG FOUND AND FIXED while live-verifying this exact mechanism
46
+ * (2026-09-28): kasuvia-sdk's tsup config builds each package entry point
47
+ * (`.` / `./services` / `./hooks` / `./components`) as a fully independent
48
+ * bundle with no shared chunk - confirmed by grepping the built output,
49
+ * every one of the 3 real dist files contains its OWN separate copy of this
50
+ * file's code. A plain module-level `let refreshHandler` therefore gave
51
+ * each entry point its own invisible-to-each-other copy of this state:
52
+ * registering via `@kasuvia/sdk` (the root entry, what AuthProvider
53
+ * imports from) silently did nothing for any authenticated call made
54
+ * through `@kasuvia/sdk/hooks` (a different physical bundle, and the one
55
+ * real hooks like useWalletBalance/useCart actually route through) - the
56
+ * exact majority-of-real-traffic case this whole fix exists for. Storing
57
+ * the registry on `globalThis` under a fixed key instead makes it a true
58
+ * process/tab-wide singleton no matter how many separate bundle copies of
59
+ * this module end up loaded side by side in the same browser tab - the
60
+ * standard fix for this exact "multiple bundled copies of one logical
61
+ * module" bug class, without needing to change the package's build/
62
+ * splitting setup (out of scope for an auth fix, wider blast radius).
63
+ */
64
+
65
+ export type RefreshHandler = () => Promise<string | null>
66
+
67
+ interface RefreshRegistry {
68
+ handler: RefreshHandler | null
69
+ inFlight: Promise<string | null> | null
70
+ }
71
+
72
+ const GLOBAL_KEY = '__kasuviaSdkTokenRefreshRegistry__'
73
+
74
+ function getRegistry(): RefreshRegistry {
75
+ const g = globalThis as unknown as Record<string, RefreshRegistry | undefined>
76
+ if (!g[GLOBAL_KEY]) {
77
+ g[GLOBAL_KEY] = { handler: null, inFlight: null }
78
+ }
79
+ return g[GLOBAL_KEY]!
80
+ }
81
+
82
+ /**
83
+ * Registers the function apiRequest calls to obtain a fresh access token
84
+ * after a 401. Call this once, e.g. in AuthProvider's top-level mount
85
+ * effect - not tied to login state, since the handler itself is stateless
86
+ * (it always attempts a real refresh call and returns null on failure,
87
+ * which is exactly correct behavior whether or not a customer happens to be
88
+ * logged in right now). `null` is accepted for a consumer that genuinely
89
+ * unmounts its provider (e.g. route-level teardown) and wants to stop
90
+ * apiRequest from calling a handler whose closure is about to go stale.
91
+ */
92
+ export function registerRefreshHandler(handler: RefreshHandler | null): void {
93
+ getRegistry().handler = handler
94
+ }
95
+
96
+ /**
97
+ * Runs the registered refresh handler, coalescing concurrent callers into
98
+ * the single in-flight attempt (mirrors withAuthInterceptors' `_isRefreshing`
99
+ * / `_pendingQueue` exactly - several requests failing with a 401 at once
100
+ * must never each independently call the refresh endpoint).
101
+ */
102
+ export async function refreshAccessToken(): Promise<string | null> {
103
+ const registry = getRegistry()
104
+ if (!registry.handler) return null
105
+ if (registry.inFlight) return registry.inFlight
106
+
107
+ registry.inFlight = registry.handler().finally(() => {
108
+ registry.inFlight = null
109
+ })
110
+ return registry.inFlight
111
+ }
112
+
113
+ export function hasRefreshHandler(): boolean {
114
+ return getRegistry().handler !== null
115
+ }
@@ -83,3 +83,16 @@ export function validateMessage(message: string): string | null {
83
83
  }
84
84
  return null;
85
85
  }
86
+
87
+ /**
88
+ * Validates that a country code is a proper 2-letter ISO 3166-1 alpha-2 code.
89
+ * Defensive guard against backend or session data that might send full country names.
90
+ *
91
+ * @param code - The country code string to validate
92
+ * @returns true when the code is a valid 2-uppercase-letter ISO code
93
+ */
94
+ export function isValidCountryCode(code: string | undefined): code is string {
95
+ if (!code) return false;
96
+ // Must be exactly 2 uppercase letters (valid ISO 3166-1 alpha-2)
97
+ return /^[A-Z]{2}$/.test(code) && code.length === 2;
98
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * YouTube utility functions
3
+ */
4
+
5
+ /**
6
+ * Extract YouTube video ID from various URL formats.
7
+ * Supports:
8
+ * - youtube.com/watch?v=VIDEO_ID
9
+ * - youtu.be/VIDEO_ID
10
+ * - youtube.com/embed/VIDEO_ID
11
+ * - youtube.com/v/VIDEO_ID
12
+ * - youtube.com/u/USERNAME/watch?v=VIDEO_ID
13
+ */
14
+ export function getYouTubeID(url: string): string | null {
15
+ const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
16
+ const match = url.match(regExp);
17
+ return (match && match[2].length === 11) ? match[2] : null;
18
+ }