@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
package/src/hooks/use-cart.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
|
32
|
-
product_name
|
|
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
|
|
48
|
+
unit_price?: number
|
|
37
49
|
quantity: number
|
|
38
50
|
custom_measurements?: string
|
|
39
|
-
is_bespoke
|
|
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
|
-
|
|
48
|
-
|
|
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(
|
|
104
|
+
export function useCart(
|
|
105
|
+
config?: SdkConfig,
|
|
106
|
+
accessToken?: string | null,
|
|
107
|
+
guestSessionId?: string | null
|
|
108
|
+
): UseCartReturn {
|
|
77
109
|
const ctx = useSdkContext();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
347
|
+
const removeItem = (itemId: string) => {
|
|
348
|
+
return removeFromCartMutation.mutateAsync(itemId)
|
|
349
|
+
}
|
|
248
350
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
351
|
+
const handleClearCartAndAdd = () => {
|
|
352
|
+
clearCartMutation.mutate()
|
|
353
|
+
setProductTypeMismatch(null)
|
|
354
|
+
}
|
|
253
355
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
356
|
+
const handleKeepCurrentCart = () => {
|
|
357
|
+
setProductTypeMismatch(null)
|
|
358
|
+
}
|
|
257
359
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
}
|