@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
@@ -2,22 +2,34 @@
2
2
  * Cart Hook & Context Provider
3
3
  *
4
4
  * React Query hooks for cart operations with unified drawer state sharing.
5
- * config and accessToken are optional — if omitted, they are read from SdkContext.
5
+ * All cart operations route directly to kasuvia-cart-management via the SDK
6
+ * service functions — never through framework-coupled proxy routes.
7
+ *
8
+ * - Authenticated users: use Authorization Bearer token
9
+ * - Guest users: use guestSessionId (X-Guest-Session header)
10
+ *
11
+ * config and accessToken are optional — if omitted they are read from SdkContext.
6
12
  */
7
13
  import React, { createContext, useContext, useState, type ReactNode } from 'react'
8
14
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
9
- import { getCart, addToCart, updateCartItem, removeFromCart, clearCart, mergeGuestCartIfPresent } from '../services/cart.service'
15
+ import {
16
+ getCart,
17
+ getGuestCart,
18
+ addToCart,
19
+ addToGuestCart,
20
+ updateCartItem,
21
+ updateGuestCartItemQuantity,
22
+ removeFromCart,
23
+ removeFromGuestCart,
24
+ clearCart,
25
+ clearGuestCart,
26
+ mergeGuestCartIfPresent,
27
+ } from '../services/cart.service'
28
+ import { logger } from '../utils/logger'
10
29
  import type { CartItem, CartResponse, Product } from '../types/store'
11
30
  import type { SdkConfig } from '../config'
12
31
  import { useSdkContext, resolveHookArgs } from '../context/sdk-context'
13
32
 
14
- interface ProductTypeMismatchDetail {
15
- code?: string
16
- message?: string
17
- existing_type?: string
18
- new_type?: string
19
- }
20
-
21
33
  export interface UseCartReturn {
22
34
  cart: CartResponse | undefined
23
35
  items: CartItem[]
@@ -28,15 +40,15 @@ export interface UseCartReturn {
28
40
  error: Error | null
29
41
  addToCart: (item: {
30
42
  product_id: string
31
- product_slug: string
32
- product_name: string
43
+ product_slug?: string
44
+ product_name?: string
33
45
  image?: string
34
46
  variant_id?: string
35
47
  variant_attributes?: string
36
- unit_price: number
48
+ unit_price?: number
37
49
  quantity: number
38
50
  custom_measurements?: string
39
- is_bespoke: boolean
51
+ is_bespoke?: boolean
40
52
  }) => void
41
53
  updateCartItem: (variables: { itemId: string; quantity: number }) => void
42
54
  removeFromCart: (itemId: string) => void
@@ -44,267 +56,327 @@ export interface UseCartReturn {
44
56
  clear: () => void
45
57
  mergeGuestCart: (guestSessionId: string) => void
46
58
  addItem: (product: Product, variantId?: string, quantity?: number, customMeasurements?: Record<string, string>) => void
47
- updateQty: (itemId: string, quantity: number) => void
48
- removeItem: (itemId: string) => void
59
+ /** Rejects with the real backend error on failure — the optimistic UI update it made is already rolled back by then; catch this to show why. */
60
+ updateQty: (itemId: string, quantity: number) => Promise<CartResponse>
61
+ /** Rejects with the real backend error on failure — the optimistic UI update it made is already rolled back by then; catch this to show why. */
62
+ removeItem: (itemId: string) => Promise<void>
49
63
  subtotal: number
50
- productTypeMismatch: {
51
- message: string
52
- existing_type: string
53
- new_type: string
54
- } | null
64
+ productTypeMismatch: ProductTypeMismatchDetail | null
55
65
  handleClearCartAndAdd: () => void
56
66
  handleKeepCurrentCart: () => void
57
67
  }
58
68
 
59
69
  // ── React Context for Shared Drawer State ─────────────────────────────────────
60
70
 
71
+ export interface ProductTypeMismatchDetail {
72
+ message: string
73
+ existing_type: string
74
+ new_type: string
75
+ }
76
+
61
77
  export interface CartContextValue {
62
78
  isOpen: boolean
63
79
  setOpen: (open: boolean) => void
80
+ // REAL BUG FIXED (2026-09-24): this used to be a plain useState() local to
81
+ // every individual useCart() call - since each component invoking
82
+ // useCart() gets its own separate hook instance, a mismatch set by
83
+ // ProductDetailClient's addToCartMutation could never be seen by
84
+ // CartClient's own "keep cart or clear it" dialog (a different component,
85
+ // a different useCart() instance, a different local state). Lifted into
86
+ // this same shared context isOpen/setOpen already use, for the same
87
+ // reason: real cross-component cart state belongs here, not in a hook-
88
+ // local useState.
89
+ productTypeMismatch: ProductTypeMismatchDetail | null
90
+ setProductTypeMismatch: (detail: ProductTypeMismatchDetail | null) => void
64
91
  }
65
92
 
66
93
  export const CartCtx = React.createContext ? React.createContext<CartContextValue | null>(null) : null
67
94
 
68
95
  export function CartProvider({ children }: { children: ReactNode }) {
69
96
  const [isOpen, setIsOpen] = useState(false)
97
+ const [productTypeMismatch, setProductTypeMismatch] = useState<ProductTypeMismatchDetail | null>(null)
70
98
  if (!CartCtx) return children
71
- return React.createElement(CartCtx.Provider, { value: { isOpen, setOpen: setIsOpen } }, children)
99
+ return React.createElement(CartCtx.Provider, { value: { isOpen, setOpen: setIsOpen, productTypeMismatch, setProductTypeMismatch } }, children)
72
100
  }
73
101
 
74
102
  // ── Hook ──────────────────────────────────────────────────────────────────────
75
103
 
76
- export function useCart(config?: SdkConfig, accessToken?: string | null): UseCartReturn {
104
+ export function useCart(
105
+ config?: SdkConfig,
106
+ accessToken?: string | null,
107
+ guestSessionId?: string | null
108
+ ): UseCartReturn {
77
109
  const ctx = useSdkContext();
78
- try {
79
- const resolved = resolveHookArgs(config, accessToken, ctx);
80
- const resolvedConfig = resolved.config;
81
- const resolvedToken = resolved.accessToken;
82
- const queryClient = useQueryClient()
83
- const context = CartCtx ? useContext(CartCtx) : null
110
+ const resolved = resolveHookArgs(config, accessToken, ctx);
111
+ const resolvedConfig = resolved.config;
112
+ const resolvedToken = resolved.accessToken;
113
+ // Falls back to SdkContext's guestSessionId (set by SdkProvider) when not
114
+ // passed explicitly — matches config/accessToken's own resolution pattern,
115
+ // so every real call site (none of which ever passed this explicitly) picks
116
+ // it up automatically. See utils/guest-session.ts for why this matters:
117
+ // without it, anonymous add-to-cart has never worked at all.
118
+ guestSessionId = guestSessionId !== undefined ? guestSessionId : (ctx?.guestSessionId ?? null)
119
+ const queryClient = useQueryClient()
120
+ const context = CartCtx ? useContext(CartCtx) : null
84
121
 
85
- const isOpen = context ? context.isOpen : false
86
- const setOpen = context ? context.setOpen : () => {}
122
+ const isOpen = context ? context.isOpen : false
123
+ const setOpen = context ? context.setOpen : () => {}
124
+ // See CartContextValue's own doc comment: this must be context-shared
125
+ // (not a local useState) so the "keep cart or clear it" dialog on the
126
+ // cart page can actually see a mismatch that happened while adding from
127
+ // a product page - a different component, a different useCart() call.
128
+ const productTypeMismatch = context ? context.productTypeMismatch : null
129
+ const setProductTypeMismatch = context ? context.setProductTypeMismatch : () => {}
87
130
 
88
- // Track product type mismatch errors from cart operations
89
- const [productTypeMismatch, setProductTypeMismatch] = useState<{
90
- message: string
91
- existing_type: string
92
- new_type: string
93
- } | null>(null)
131
+ // Single source of truth for this identity's cart query key — every
132
+ // mutation's optimistic-update/rollback logic below targets this same key,
133
+ // so it must never be computed two different ways.
134
+ const cartQueryKey = ['cart', resolvedToken ?? guestSessionId ?? 'anon']
94
135
 
95
- const cartQuery = useQuery({
96
- queryKey: ['cart'],
97
- queryFn: async () => {
98
- if (resolvedToken) {
99
- return getCart(resolvedConfig, resolvedToken)
100
- }
101
- if (typeof window !== 'undefined') {
102
- const res = await fetch('/api/cart', { credentials: 'include' })
103
- if (!res.ok) throw new Error('Failed to fetch cart')
104
- return res.json()
105
- }
106
- return undefined
107
- },
108
- enabled: true,
109
- })
136
+ // ── Cart Query ─────────────────────────────────────────────────────────────
137
+ // Authenticated → getCart(token)
138
+ // Guest → getGuestCart(guestSessionId)
139
+ // No identity → return empty cart (do not throw)
140
+ const cartQuery = useQuery({
141
+ queryKey: cartQueryKey,
142
+ queryFn: async (): Promise<CartResponse> => {
143
+ if (resolvedToken) {
144
+ return getCart(resolvedConfig, resolvedToken)
145
+ }
146
+ if (guestSessionId) {
147
+ return getGuestCart(resolvedConfig, guestSessionId)
148
+ }
149
+ // No identity — return an empty cart shape; caller can render an empty state.
150
+ return { items: [], subtotal: 0, total: 0, item_count: 0, product_type: undefined }
151
+ },
152
+ enabled: !!(resolvedToken || guestSessionId),
153
+ })
110
154
 
111
- const addToCartMutation = useMutation({
112
- mutationFn: async (item: Parameters<typeof addToCart>[2]) => {
113
- if (resolvedToken) {
114
- return addToCart(resolvedConfig, resolvedToken, item)
115
- }
116
- if (typeof window !== 'undefined') {
117
- const res = await fetch('/api/cart', {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/json' },
120
- credentials: 'include',
121
- body: JSON.stringify(item),
122
- })
123
- if (!res.ok) {
124
- const data = await res.json().catch(() => ({}))
125
- throw { status: res.status, data }
126
- }
127
- return res.json()
128
- }
129
- throw new Error('No access token and not in browser context')
130
- },
131
- onSuccess: () => {
132
- setProductTypeMismatch(null)
133
- queryClient.invalidateQueries({ queryKey: ['cart'] })
134
- },
135
- onError: (error: unknown) => {
136
- // Handle product type mismatch errors from backend
137
- const err = error as { status?: number; data?: { detail?: ProductTypeMismatchDetail } }
138
- if (err.status === 409 && err.data?.detail?.code === 'CART_PRODUCT_TYPE_MISMATCH') {
139
- const errorDetail = err.data.detail
140
- setProductTypeMismatch({
141
- message: errorDetail.message ?? 'Your cart contains incompatible items.',
142
- existing_type: errorDetail.existing_type ?? 'physical',
143
- new_type: errorDetail.new_type ?? 'physical',
144
- })
145
- }
146
- },
147
- })
155
+ // ── Add to Cart Mutation ──────────────────────────────────────────────────
156
+ const addToCartMutation = useMutation({
157
+ mutationFn: async (item: Parameters<typeof addToCart>[2]) => {
158
+ if (resolvedToken) {
159
+ return addToCart(resolvedConfig, resolvedToken, item)
160
+ }
161
+ if (guestSessionId) {
162
+ return addToGuestCart(resolvedConfig, guestSessionId, item)
163
+ }
164
+ throw new Error('Cannot add to cart: no access token or guest session ID provided.')
165
+ },
166
+ onSuccess: () => {
167
+ setProductTypeMismatch(null)
168
+ queryClient.invalidateQueries({ queryKey: ['cart'] })
169
+ },
170
+ // REAL BUG FIXED (2026-09-24), live-reported ("mixed products between
171
+ // physical to digital"): this read `error.status`/`error.data` - but
172
+ // apiRequest throws a real `ApiError` (utils/http-client.ts), whose
173
+ // fields are `.status`/`.body`/`.message`, not `.data` - `err.data` was
174
+ // always `undefined`, so this branch could never fire even when the
175
+ // backend genuinely sent a 409. Separately, kasuvia-cart-management's
176
+ // error body is a huma ErrorModel (`{detail: "plain string"}`), never a
177
+ // nested `{detail: {code, ...}}` object - this service's own
178
+ // TranslateServiceError doc comment already documents that convention
179
+ // (a bracketed "[CODE] ..." prefix in the message string, matching
180
+ // every other cart error). Parses that same convention here instead of
181
+ // a response shape the backend never produces.
182
+ onError: (error: unknown) => {
183
+ const err = error as { status?: number; message?: string }
184
+ if (err.status !== 409 || !err.message?.startsWith('[CART_PRODUCT_TYPE_MISMATCH]')) return
185
+ const match = err.message.match(/contains (\w+) items\. Add (\w+) items/i)
186
+ setProductTypeMismatch({
187
+ message: err.message.replace(/^\[CART_PRODUCT_TYPE_MISMATCH\]\s*/, ''),
188
+ existing_type: match?.[1] ?? 'unknown',
189
+ new_type: match?.[2] ?? 'unknown',
190
+ })
191
+ },
192
+ })
148
193
 
149
- const updateCartItemMutation = useMutation({
150
- mutationFn: async ({ itemId, quantity }: { itemId: string; quantity: number }) => {
151
- if (resolvedToken) {
152
- return updateCartItem(resolvedConfig, resolvedToken, itemId, quantity)
153
- }
154
- if (typeof window !== 'undefined') {
155
- const res = await fetch(`/api/cart/${itemId}`, {
156
- method: 'PATCH',
157
- headers: { 'Content-Type': 'application/json' },
158
- credentials: 'include',
159
- body: JSON.stringify({ quantity }),
160
- })
161
- if (!res.ok) throw new Error('Failed to update cart item')
162
- return res.json()
163
- }
164
- throw new Error('No access token')
165
- },
166
- onSuccess: () => {
167
- queryClient.invalidateQueries({ queryKey: ['cart'] })
168
- },
169
- })
194
+ // Recomputes subtotal/total/item_count for a locally-mutated items array —
195
+ // mirrors kasuvia-cart-management's own BuildResponse exactly (Total ===
196
+ // Subtotal at the cart stage; delivery/tax are only added at checkout).
197
+ // Deliberately does NOT recompute `product_type` (DeriveProductType is
198
+ // real backend logic this shouldn't reproduce client-side) - onSettled's
199
+ // invalidateQueries reconciles that within one round-trip regardless.
200
+ function recomputeCartTotals(previous: CartResponse, items: CartItem[]): CartResponse {
201
+ const subtotal = items.reduce((sum, i) => sum + i.unit_price * i.quantity, 0)
202
+ const item_count = items.reduce((sum, i) => sum + i.quantity, 0)
203
+ return { ...previous, items, subtotal, total: subtotal, item_count }
204
+ }
170
205
 
171
- const removeFromCartMutation = useMutation({
172
- mutationFn: async (itemId: string) => {
173
- if (resolvedToken) {
174
- return removeFromCart(resolvedConfig, resolvedToken, itemId)
175
- }
176
- if (typeof window !== 'undefined') {
177
- const res = await fetch(`/api/cart/${itemId}`, {
178
- method: 'DELETE',
179
- credentials: 'include',
180
- })
181
- if (!res.ok) throw new Error('Failed to remove cart item')
182
- return res.json()
183
- }
184
- throw new Error('No access token')
185
- },
186
- onSuccess: () => {
187
- queryClient.invalidateQueries({ queryKey: ['cart'] })
188
- },
189
- })
206
+ // ── Update Cart Item Mutation ─────────────────────────────────────────────
207
+ // REAL GAP FIXED (2026-09-23), owner-requested: add-to-cart already felt
208
+ // instant (the caller shows its own success toast without waiting for the
209
+ // mutation to settle), but remove/update-quantity only updated the visible
210
+ // list once the server round-trip finished and the query refetched -
211
+ // sluggish by comparison. Both now write the predicted result straight
212
+ // into the query cache in onMutate (matching kasuvia-cart-management's own
213
+ // upsertLine merge/removal semantics), roll back to the exact pre-mutation
214
+ // snapshot in onError, and always reconcile with the server's real state
215
+ // in onSettled - the cache can never end up out of sync with the backend,
216
+ // even if two of these fire concurrently.
217
+ const updateCartItemMutation = useMutation({
218
+ mutationFn: async ({ itemId, quantity }: { itemId: string; quantity: number }) => {
219
+ if (resolvedToken) {
220
+ return updateCartItem(resolvedConfig, resolvedToken, itemId, quantity)
221
+ }
222
+ if (guestSessionId) {
223
+ return updateGuestCartItemQuantity(resolvedConfig, guestSessionId, itemId, quantity)
224
+ }
225
+ throw new Error('Cannot update cart: no access token or guest session ID provided.')
226
+ },
227
+ onMutate: async ({ itemId, quantity }) => {
228
+ await queryClient.cancelQueries({ queryKey: cartQueryKey })
229
+ const previous = queryClient.getQueryData<CartResponse>(cartQueryKey)
230
+ if (previous) {
231
+ // Matches the real backend's own "quantity<=0 removes the line"
232
+ // contract (UpdateItemQuantity's doc comment in cart_service.go).
233
+ const items = quantity > 0
234
+ ? previous.items.map((i) => i.id === itemId ? { ...i, quantity } : i)
235
+ : previous.items.filter((i) => i.id !== itemId)
236
+ queryClient.setQueryData<CartResponse>(cartQueryKey, recomputeCartTotals(previous, items))
237
+ }
238
+ return { previous }
239
+ },
240
+ onError: (error, _variables, context) => {
241
+ if (context?.previous) {
242
+ queryClient.setQueryData(cartQueryKey, context.previous)
243
+ }
244
+ logger.error('[useCart] Failed to update item quantity, rolled back:', error)
245
+ },
246
+ onSettled: () => {
247
+ queryClient.invalidateQueries({ queryKey: ['cart'] })
248
+ },
249
+ })
190
250
 
191
- const clearCartMutation = useMutation({
192
- mutationFn: async () => {
193
- if (resolvedToken) {
194
- return clearCart(resolvedConfig, resolvedToken)
195
- }
196
- if (typeof window !== 'undefined') {
197
- const res = await fetch('/api/cart/clear', {
198
- method: 'DELETE',
199
- credentials: 'include',
200
- })
201
- if (!res.ok) throw new Error('Failed to clear cart')
202
- return res.json()
203
- }
204
- throw new Error('No access token')
205
- },
206
- onSuccess: () => {
207
- queryClient.invalidateQueries({ queryKey: ['cart'] })
208
- },
209
- })
251
+ // ── Remove from Cart Mutation ─────────────────────────────────────────────
252
+ // Same optimistic/rollback pattern as updateCartItemMutation above.
253
+ const removeFromCartMutation = useMutation({
254
+ mutationFn: async (itemId: string) => {
255
+ if (resolvedToken) {
256
+ return removeFromCart(resolvedConfig, resolvedToken, itemId)
257
+ }
258
+ if (guestSessionId) {
259
+ return removeFromGuestCart(resolvedConfig, guestSessionId, itemId)
260
+ }
261
+ throw new Error('Cannot remove from cart: no access token or guest session ID provided.')
262
+ },
263
+ onMutate: async (itemId) => {
264
+ await queryClient.cancelQueries({ queryKey: cartQueryKey })
265
+ const previous = queryClient.getQueryData<CartResponse>(cartQueryKey)
266
+ if (previous) {
267
+ const items = previous.items.filter((i) => i.id !== itemId)
268
+ queryClient.setQueryData<CartResponse>(cartQueryKey, recomputeCartTotals(previous, items))
269
+ }
270
+ return { previous }
271
+ },
272
+ onError: (error, _itemId, context) => {
273
+ if (context?.previous) {
274
+ queryClient.setQueryData(cartQueryKey, context.previous)
275
+ }
276
+ logger.error('[useCart] Failed to remove item, rolled back:', error)
277
+ },
278
+ onSettled: () => {
279
+ queryClient.invalidateQueries({ queryKey: ['cart'] })
280
+ },
281
+ })
210
282
 
211
- const mergeGuestCartMutation = useMutation({
212
- mutationFn: (guestSessionId: string) =>
213
- resolvedToken ? mergeGuestCartIfPresent(resolvedConfig, resolvedToken, guestSessionId) : Promise.resolve(),
214
- onSuccess: () => {
215
- queryClient.invalidateQueries({ queryKey: ['cart'] })
216
- },
217
- })
283
+ // ── Clear Cart Mutation ───────────────────────────────────────────────────
284
+ const clearCartMutation = useMutation({
285
+ mutationFn: async () => {
286
+ if (resolvedToken) {
287
+ return clearCart(resolvedConfig, resolvedToken)
288
+ }
289
+ if (guestSessionId) {
290
+ return clearGuestCart(resolvedConfig, guestSessionId)
291
+ }
292
+ throw new Error('Cannot clear cart: no access token or guest session ID provided.')
293
+ },
294
+ onSuccess: () => {
295
+ queryClient.invalidateQueries({ queryKey: ['cart'] })
296
+ },
297
+ })
218
298
 
219
- // Convenience method for adding items with product object
220
- const addItem = (product: Product, variantId?: string, quantity: number = 1, customMeasurements?: Record<string, string>) => {
221
- const variant = variantId ? product.variants.find(v => v.id === variantId) : undefined
222
- return addToCartMutation.mutate({
223
- product_id: product.id,
224
- product_slug: product.slug,
225
- product_name: product.name,
226
- image: product.images[0] || undefined,
227
- variant_id: variantId,
228
- variant_attributes: variant?.attributes ? JSON.stringify(variant.attributes) : undefined,
229
- unit_price: variant?.price ?? product.base_price,
230
- quantity,
231
- custom_measurements: customMeasurements ? JSON.stringify(customMeasurements) : undefined,
232
- is_bespoke: product.is_bespoke,
233
- })
234
- }
299
+ // ── Merge Guest Cart Mutation ─────────────────────────────────────────────
300
+ const mergeGuestCartMutation = useMutation({
301
+ mutationFn: (sessionId: string) => {
302
+ if (!resolvedToken) {
303
+ logger.warn('[useCart] mergeGuestCart called without an access token — skipping merge.')
304
+ return Promise.resolve()
305
+ }
306
+ return mergeGuestCartIfPresent(resolvedConfig, resolvedToken, sessionId)
307
+ },
308
+ onSuccess: () => {
309
+ queryClient.invalidateQueries({ queryKey: ['cart'] })
310
+ },
311
+ })
312
+
313
+ // ── Convenience: add by Product object ───────────────────────────────────
314
+ // NOTE: product_slug, product_name, image_url, unit_price, is_bespoke are
315
+ // intentionally NOT forwarded to the backend — kasuvia-cart-management
316
+ // resolves them server-side. They're extracted from the Product here only
317
+ // for any local optimistic-UI the caller may implement; the backend response
318
+ // is the authoritative cart state.
319
+ const addItem = (product: Product, variantId?: string, quantity: number = 1, customMeasurements?: Record<string, string>) => {
320
+ return addToCartMutation.mutate({
321
+ product_id: product.id,
322
+ variant_id: variantId,
323
+ quantity,
324
+ custom_measurements: customMeasurements ? JSON.stringify(customMeasurements) : undefined,
325
+ // Below are accepted by the mutation signature for UI convenience but stripped before the fetch:
326
+ product_slug: product.slug || "",
327
+ product_name: product.name,
328
+ image_url: product.images[0] || undefined,
329
+ })
330
+ }
235
331
 
236
- // Helper methods for storefront compatibility
237
- const items = cartQuery.data?.items || []
238
- const subtotal = cartQuery.data?.subtotal || 0
239
- const count = items.reduce((total: number, item: CartItem) => total + item.quantity, 0)
332
+ const items = cartQuery.data?.items ?? []
333
+ const subtotal = cartQuery.data?.subtotal ?? 0
334
+ const count = items.reduce((total: number, item: CartItem) => total + item.quantity, 0)
240
335
 
241
- const updateQty = (itemId: string, quantity: number) => {
242
- updateCartItemMutation.mutate({ itemId, quantity })
243
- }
336
+ // mutateAsync (not mutate) so a caller's `await updateQty(...)`/`await
337
+ // removeItem(...)` actually rejects on failure - both already have callers
338
+ // using `await` + try/catch expecting this (CartClient.tsx/CartDrawer.tsx),
339
+ // but `.mutate()` returns void, so those awaits previously resolved
340
+ // instantly regardless of outcome and never caught a real failure. The
341
+ // optimistic cache update above already applies/rolls back independently
342
+ // of whether the caller awaits this at all.
343
+ const updateQty = (itemId: string, quantity: number) => {
344
+ return updateCartItemMutation.mutateAsync({ itemId, quantity })
345
+ }
244
346
 
245
- const removeItem = (itemId: string) => {
246
- removeFromCartMutation.mutate(itemId)
247
- }
347
+ const removeItem = (itemId: string) => {
348
+ return removeFromCartMutation.mutateAsync(itemId)
349
+ }
248
350
 
249
- const handleClearCartAndAdd = () => {
250
- clearCartMutation.mutate()
251
- setProductTypeMismatch(null)
252
- }
351
+ const handleClearCartAndAdd = () => {
352
+ clearCartMutation.mutate()
353
+ setProductTypeMismatch(null)
354
+ }
253
355
 
254
- const handleKeepCurrentCart = () => {
255
- setProductTypeMismatch(null)
256
- }
356
+ const handleKeepCurrentCart = () => {
357
+ setProductTypeMismatch(null)
358
+ }
257
359
 
258
- return {
259
- cart: cartQuery.data,
260
- items,
261
- count,
262
- isOpen,
263
- setOpen,
264
- isLoading: cartQuery.isLoading,
265
- error: cartQuery.error,
266
- addToCart: addToCartMutation.mutate,
267
- updateCartItem: updateCartItemMutation.mutate,
268
- removeFromCart: removeFromCartMutation.mutate,
269
- clearCart: clearCartMutation.mutate,
270
- clear: clearCartMutation.mutate, // Alias for storefront compatibility
271
- mergeGuestCart: mergeGuestCartMutation.mutate,
272
- addItem,
273
- updateQty,
274
- removeItem,
275
- subtotal,
276
- productTypeMismatch,
277
- handleClearCartAndAdd,
278
- handleKeepCurrentCart,
279
- }
280
- } catch (error) {
281
- // If called outside QueryClientProvider, return safe fallback
282
- console.warn('[useCart] Called outside QueryClientProvider, returning empty cart')
283
- const context = CartCtx ? useContext(CartCtx) : null
284
- const isOpen = context ? context.isOpen : false
285
- const setOpen = context ? context.setOpen : () => {}
286
-
287
- return {
288
- cart: undefined,
289
- items: [],
290
- count: 0,
291
- isOpen,
292
- setOpen,
293
- isLoading: false,
294
- error: null,
295
- addToCart: () => {},
296
- updateCartItem: () => {},
297
- removeFromCart: () => {},
298
- clearCart: () => {},
299
- clear: () => {},
300
- mergeGuestCart: () => {},
301
- addItem: () => {},
302
- updateQty: () => {},
303
- removeItem: () => {},
304
- subtotal: 0,
305
- productTypeMismatch: null,
306
- handleClearCartAndAdd: () => {},
307
- handleKeepCurrentCart: () => {},
308
- } as any
360
+ return {
361
+ cart: cartQuery.data,
362
+ items,
363
+ count,
364
+ isOpen,
365
+ setOpen,
366
+ isLoading: cartQuery.isLoading,
367
+ error: cartQuery.error as Error | null,
368
+ addToCart: addToCartMutation.mutate,
369
+ updateCartItem: updateCartItemMutation.mutate,
370
+ removeFromCart: removeFromCartMutation.mutate,
371
+ clearCart: clearCartMutation.mutate,
372
+ clear: clearCartMutation.mutate,
373
+ mergeGuestCart: mergeGuestCartMutation.mutate,
374
+ addItem,
375
+ updateQty,
376
+ removeItem,
377
+ subtotal,
378
+ productTypeMismatch,
379
+ handleClearCartAndAdd,
380
+ handleKeepCurrentCart,
309
381
  }
310
382
  }