@kasuvia/sdk 0.0.1

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 (73) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +112 -0
  3. package/dist/index.d.mts +1891 -0
  4. package/dist/index.d.ts +1891 -0
  5. package/dist/index.js +3540 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +3314 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +59 -0
  10. package/src/client/authClient.ts +135 -0
  11. package/src/client/graphqlClient.ts +53 -0
  12. package/src/client/httpClient.ts +298 -0
  13. package/src/config/index.ts +102 -0
  14. package/src/graphql/collections.graphql.ts +56 -0
  15. package/src/graphql/index.ts +7 -0
  16. package/src/graphql/products.graphql.ts +710 -0
  17. package/src/graphql/wishlist.graphql.ts +33 -0
  18. package/src/hooks/index.ts +26 -0
  19. package/src/hooks/use-auth.ts +182 -0
  20. package/src/hooks/use-bespoke-availability.ts +20 -0
  21. package/src/hooks/use-cart.ts +207 -0
  22. package/src/hooks/use-cms.ts +165 -0
  23. package/src/hooks/use-collections.ts +40 -0
  24. package/src/hooks/use-consultation-stream.ts +141 -0
  25. package/src/hooks/use-consultations.ts +76 -0
  26. package/src/hooks/use-countries.ts +24 -0
  27. package/src/hooks/use-currencies.ts +27 -0
  28. package/src/hooks/use-google-translate.ts +164 -0
  29. package/src/hooks/use-is-mounted.ts +9 -0
  30. package/src/hooks/use-languages.ts +33 -0
  31. package/src/hooks/use-order-stream.ts +195 -0
  32. package/src/hooks/use-orders.ts +64 -0
  33. package/src/hooks/use-payment.ts +68 -0
  34. package/src/hooks/use-products.ts +113 -0
  35. package/src/hooks/use-service-booking.ts +118 -0
  36. package/src/hooks/use-states.ts +28 -0
  37. package/src/hooks/use-wishlist.ts +121 -0
  38. package/src/index.ts +65 -0
  39. package/src/services/app.service.ts +17 -0
  40. package/src/services/auth.service.ts +332 -0
  41. package/src/services/bespoke.service.ts +17 -0
  42. package/src/services/cart.service.ts +245 -0
  43. package/src/services/cms.service.ts +112 -0
  44. package/src/services/collection.service.ts +77 -0
  45. package/src/services/config.service.ts +42 -0
  46. package/src/services/consultation.service.ts +51 -0
  47. package/src/services/index.ts +21 -0
  48. package/src/services/logistics.service.ts +95 -0
  49. package/src/services/notifications.service.ts +50 -0
  50. package/src/services/order.service.ts +169 -0
  51. package/src/services/payment.service.ts +127 -0
  52. package/src/services/product.service.ts +678 -0
  53. package/src/services/service-booking.service.ts +86 -0
  54. package/src/services/wishlist.service.ts +84 -0
  55. package/src/types/auth/index.ts +126 -0
  56. package/src/types/bespoke/index.ts +39 -0
  57. package/src/types/business/index.ts +6 -0
  58. package/src/types/cms/index.ts +77 -0
  59. package/src/types/consultations/index.ts +79 -0
  60. package/src/types/index.ts +27 -0
  61. package/src/types/media/index.ts +41 -0
  62. package/src/types/payments/index.ts +120 -0
  63. package/src/types/services/index.ts +80 -0
  64. package/src/types/store/index.ts +282 -0
  65. package/src/types/store/shipping.ts +38 -0
  66. package/src/types/ui/index.ts +32 -0
  67. package/src/utils/cache.ts +33 -0
  68. package/src/utils/checkout.ts +39 -0
  69. package/src/utils/customer.ts +20 -0
  70. package/src/utils/error-handler.ts +14 -0
  71. package/src/utils/index.ts +10 -0
  72. package/src/utils/logger.ts +79 -0
  73. package/src/utils/sanitize-url.ts +55 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Customer auth service — all API calls scoped to this storefront's business tenant.
3
+ * Every request automatically receives:
4
+ * - Authorization: Bearer <accessToken> (from interceptors)
5
+ * - X-Storefront-Key: <storefrontApiKey> (from interceptors)
6
+ *
7
+ * The `tenant_id` field in login/register payloads is the permanent business_id (B-XXXXXXXX).
8
+ */
9
+ import { authAxios, refreshAxios } from '../client/httpClient'
10
+ import { tokenStore } from '../client/authClient'
11
+ import { resetGraphQLClient } from '../client/graphqlClient'
12
+ import { getRuntimeConfig } from '../config'
13
+ import { logger } from '../utils/logger'
14
+ import { mergeGuestCartIfPresent } from './cart.service'
15
+ import type {
16
+ CustomerAuthResponse,
17
+ CustomerLoginPayload,
18
+ CustomerRegisterPayload,
19
+ LogoutPayload,
20
+ RefreshTokenResponse,
21
+ CustomerUser,
22
+ AuthUserResponse,
23
+ UpdateProfilePayload,
24
+ } from '../types/auth'
25
+
26
+ // ── Auth API Calls ────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * POST /auth/login/customer/
30
+ * The `tenant_id` is automatically filled from config.api.storefrontBusinessId.
31
+ */
32
+ export async function loginCustomer(payload: Omit<CustomerLoginPayload, 'tenant_id'>): Promise<CustomerAuthResponse> {
33
+ const config = getRuntimeConfig()
34
+ const { data } = await authAxios.post<CustomerAuthResponse>('/auth/login/customer/', {
35
+ ...payload,
36
+ tenant_id: config.businessId,
37
+ })
38
+
39
+ tokenStore.setAccessToken(data.access)
40
+ tokenStore.setRefreshToken(data.refresh)
41
+ resetGraphQLClient()
42
+
43
+ // Merge guest cart after successful login
44
+ await mergeGuestCartIfPresent()
45
+
46
+ return data
47
+ }
48
+
49
+ /**
50
+ * POST /auth/register/customer/
51
+ * The `tenant_id` is automatically filled from config.api.storefrontBusinessId.
52
+ */
53
+ export async function registerCustomer(payload: Omit<CustomerRegisterPayload, 'tenant_id'>): Promise<CustomerAuthResponse> {
54
+ const config = getRuntimeConfig()
55
+ const { data } = await authAxios.post<CustomerAuthResponse>('/auth/register/customer/', {
56
+ ...payload,
57
+ tenant_id: config.businessId,
58
+ })
59
+
60
+ tokenStore.setAccessToken(data.access)
61
+ tokenStore.setRefreshToken(data.refresh)
62
+ resetGraphQLClient()
63
+
64
+ // Merge guest cart after successful registration
65
+ await mergeGuestCartIfPresent()
66
+
67
+ return data
68
+ }
69
+
70
+ /** POST /auth/token/refresh/
71
+ * CRITICAL: Uses refreshAxios (no interceptors) to prevent interceptor recursion.
72
+ */
73
+ export async function refreshToken(refresh: string): Promise<RefreshTokenResponse> {
74
+ const { data } = await refreshAxios.post<RefreshTokenResponse>('/auth/token/refresh/', { refresh })
75
+ return data
76
+ }
77
+
78
+ /** POST /auth/logout/ — revokes the server-side session. Always clears local tokens. */
79
+ export async function logout(): Promise<void> {
80
+ const refresh = tokenStore.getRefreshToken()
81
+ const payload: LogoutPayload = refresh ? { refresh } : {}
82
+ try {
83
+ await authAxios.post('/auth/logout/', payload)
84
+ } catch {
85
+ // Swallow server errors (401 = already expired, network = offline).
86
+ // Local token cleanup in `finally` is what actually logs the user out.
87
+ } finally {
88
+ tokenStore.clearAll()
89
+ resetGraphQLClient()
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Verify a social provider id_token (or access_token for Facebook) with the backend.
95
+ * The backend validates the token with the provider and creates/finds the customer.
96
+ *
97
+ * Industry-standard "ID Token Exchange" pattern:
98
+ * 1. Customer completes Google/Facebook sign-in on the storefront
99
+ * 2. Storefront receives an id_token / access_token from the provider
100
+ * 3. This function sends it to the backend for server-side verification
101
+ * 4. Backend returns our JWT tokens
102
+ */
103
+ export async function verifySocialToken(
104
+ provider: 'google' | 'facebook',
105
+ token: string
106
+ ): Promise<CustomerAuthResponse> {
107
+ const config = getRuntimeConfig()
108
+ const { data } = await authAxios.post<CustomerAuthResponse>(
109
+ `/auth/social/verify/${provider}/`,
110
+ {
111
+ id_token: token,
112
+ storefront_key: config.storefrontApiKey,
113
+ }
114
+ )
115
+ tokenStore.setAccessToken(data.access)
116
+ tokenStore.setRefreshToken(data.refresh)
117
+ resetGraphQLClient()
118
+ return data
119
+ }
120
+
121
+ /** Decode a JWT payload without verifying signature (client-side use only). */
122
+ function parseJwtPayload(token: string): Record<string, unknown> {
123
+ try {
124
+ return JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
125
+ } catch {
126
+ return {}
127
+ }
128
+ }
129
+
130
+ function resolveProvider(data: Pick<AuthUserResponse, 'is_google_auth_enabled' | 'is_facebook_auth_enabled'>): CustomerUser['provider'] {
131
+ if (data.is_google_auth_enabled) return 'google'
132
+ if (data.is_facebook_auth_enabled) return 'facebook'
133
+ return 'email'
134
+ }
135
+
136
+ function resolveAvatar(profilePicture: unknown): string | null {
137
+ if (!profilePicture) return null
138
+ if (typeof profilePicture === 'string') return profilePicture
139
+ if (typeof profilePicture === 'object' && profilePicture !== null && 'url' in profilePicture) {
140
+ const url = (profilePicture as { url?: string }).url
141
+ return typeof url === 'string' ? url : null
142
+ }
143
+ return null
144
+ }
145
+
146
+ /** Map luxe-auth UserSerializer response to storefront CustomerUser. */
147
+ export function mapAuthUserToCustomer(data: AuthUserResponse, provider?: CustomerUser['provider']): CustomerUser {
148
+ const firstname = data.profile?.firstname?.trim() || undefined
149
+ const lastname = data.profile?.lastname?.trim() || undefined
150
+ const phone = data.profile?.phone?.trim() || undefined
151
+
152
+ return {
153
+ id: String(data.id),
154
+ email: data.email,
155
+ firstname,
156
+ lastname,
157
+ phone,
158
+ full_name: buildFullName(firstname, lastname),
159
+ avatar: resolveAvatar(data.profile?.profile_picture),
160
+ provider: provider ?? resolveProvider(data),
161
+ }
162
+ }
163
+
164
+ function buildCustomerFromClaims(claims: Record<string, unknown>): CustomerUser {
165
+ const provider: CustomerUser['provider'] = claims.is_google_auth_enabled
166
+ ? 'google'
167
+ : claims.is_facebook_auth_enabled
168
+ ? 'facebook'
169
+ : 'email'
170
+
171
+ const jwtFullName = String(claims.full_name ?? claims.name ?? '').trim() || undefined
172
+ const { firstname: splitFirst, lastname: splitLast } = splitFullName(jwtFullName)
173
+
174
+ return {
175
+ id: String(claims.user_id ?? claims.sub ?? ''),
176
+ email: String(claims.email ?? ''),
177
+ firstname: splitFirst || undefined,
178
+ lastname: splitLast || undefined,
179
+ phone: String(claims.phone ?? '').trim() || undefined,
180
+ full_name: jwtFullName,
181
+ avatar: claims.avatar as string | null,
182
+ provider,
183
+ }
184
+ }
185
+
186
+ /** GET /auth/profile/ */
187
+ export async function getMe(): Promise<CustomerUser> {
188
+ const { data } = await authAxios.get<AuthUserResponse>('/auth/profile/')
189
+ return mapAuthUserToCustomer(data)
190
+ }
191
+
192
+ /** GET /auth/profile/ - alias for getMe for backward compatibility */
193
+ export async function getAuthUser(): Promise<AuthUserResponse> {
194
+ const { data } = await authAxios.get<AuthUserResponse>('/auth/profile/')
195
+ return data
196
+ }
197
+
198
+ /** PUT /auth/profile/ — update firstname, lastname, phone */
199
+ export async function updateProfile(payload: UpdateProfilePayload): Promise<CustomerUser> {
200
+ const { data } = await authAxios.put<AuthUserResponse>('/auth/profile/', payload)
201
+ return mapAuthUserToCustomer(data)
202
+ }
203
+
204
+ let refreshPromise: Promise<{ user: CustomerUser; provider: CustomerUser['provider'] } | null> | null = null;
205
+
206
+ /**
207
+ * Helper to classify error types for proper handling.
208
+ */
209
+ function classifyError(error: unknown): { isNetworkError: boolean; isAuthError: boolean; isBlacklistedError: boolean } {
210
+ const err = error as { response?: { status?: number; data?: { detail?: string } }; code?: string; message?: string }
211
+ const isNetworkError = !!(!err.response && (err.code === 'ECONNABORTED' || err.code === 'ERR_NETWORK' || err.message?.includes('Network Error')))
212
+ const isAuthError = !!(err.response?.status === 401 || err.response?.status === 403)
213
+ const isBlacklistedError = !!(err.response?.data?.detail === 'Token is blacklisted' || err.message === 'Token is blacklisted')
214
+ return { isNetworkError, isAuthError, isBlacklistedError }
215
+ }
216
+
217
+ /**
218
+ * Helper to retry with exponential backoff for transient network errors.
219
+ */
220
+ async function retryWithBackoff<T>(
221
+ fn: () => Promise<T>,
222
+ maxRetries: number = 2,
223
+ baseDelay: number = 1000
224
+ ): Promise<T> {
225
+ let lastError: unknown
226
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
227
+ try {
228
+ return await fn()
229
+ } catch (error) {
230
+ lastError = error
231
+ const { isNetworkError, isAuthError } = classifyError(error)
232
+
233
+ // Only retry on network errors, not auth errors
234
+ if (!isNetworkError || isAuthError) {
235
+ throw error
236
+ }
237
+
238
+ // Don't retry on the last attempt
239
+ if (attempt === maxRetries) {
240
+ throw error
241
+ }
242
+
243
+ // Exponential backoff: 1s, 2s, 4s, etc.
244
+ const delay = baseDelay * Math.pow(2, attempt)
245
+ await new Promise(resolve => setTimeout(resolve, delay))
246
+ }
247
+ }
248
+ throw lastError
249
+ }
250
+
251
+ /**
252
+ * Silently refresh the access token on app boot.
253
+ * Builds CustomerUser from JWT claims — no additional profile API call needed.
254
+ * Returns null if no refresh token exists or if the refresh fails.
255
+ * Includes a concurrent lock to prevent React Strict Mode from double-firing and blacklisting the token.
256
+ *
257
+ * Network error resilience:
258
+ * - Classifies errors as network vs auth
259
+ * - Retries transient network errors with exponential backoff
260
+ * - Immediately fails on auth errors (401/403) to avoid infinite loops
261
+ * - Clears auth state on any failure to ensure consistent logout behavior
262
+ */
263
+ export async function silentRefresh(): Promise<{ user: CustomerUser; provider: CustomerUser['provider'] } | null> {
264
+ const refresh = tokenStore.getRefreshToken()
265
+ if (!refresh) return null
266
+
267
+ if (refreshPromise) return refreshPromise;
268
+
269
+ refreshPromise = (async () => {
270
+ try {
271
+ // Use retry with backoff for network resilience
272
+ const tokens = await retryWithBackoff(() => refreshToken(refresh), 2, 1000)
273
+
274
+ tokenStore.setAccessToken(tokens.access)
275
+ if (tokens.refresh) tokenStore.setRefreshToken(tokens.refresh, tokenStore.isPersistent())
276
+
277
+ // Decode user identity from the new access token — avoids a fragile /profile/ round-trip
278
+ const claims = parseJwtPayload(tokens.access)
279
+ const user = buildCustomerFromClaims(claims)
280
+
281
+ return { user, provider: user.provider }
282
+ } catch (error: unknown) {
283
+ const { isNetworkError, isAuthError, isBlacklistedError } = classifyError(error)
284
+
285
+ if (isNetworkError) {
286
+ logger.warn('[silentRefresh] Network error during token refresh (offline/CORS), clearing session:', error)
287
+ } else if (isBlacklistedError) {
288
+ logger.log('[silentRefresh] Token was blacklisted (likely from logout), clearing session')
289
+ } else if (isAuthError) {
290
+ logger.log('[silentRefresh] Token refresh failed with auth error (expired/invalid), clearing session')
291
+ } else {
292
+ logger.error('[silentRefresh] Unexpected error during token refresh, clearing session:', error)
293
+ }
294
+
295
+ // CRITICAL: Clear auth state immediately on ANY refresh failure
296
+ // This prevents partial session state and ensures consistent logout behavior
297
+ tokenStore.clearAll()
298
+ return null
299
+ } finally {
300
+ refreshPromise = null;
301
+ }
302
+ })();
303
+
304
+ return refreshPromise;
305
+ }
306
+
307
+ /** Store tokens after OAuth callback or login/register response. */
308
+ export function storeTokens(access: string, refresh: string, remember = true): void {
309
+ tokenStore.setAccessToken(access)
310
+ tokenStore.setRefreshToken(refresh, remember)
311
+ }
312
+
313
+ // ── Helper Functions ─────────────────────────────────────────────────────────
314
+
315
+ function buildFullName(firstname?: string, lastname?: string): string {
316
+ if (firstname && lastname) return `${firstname} ${lastname}`
317
+ return firstname || lastname || ''
318
+ }
319
+
320
+ function splitFullName(fullName?: string): { firstname: string; lastname: string } {
321
+ if (!fullName) return { firstname: '', lastname: '' }
322
+
323
+ const parts = fullName.trim().split(/\s+/)
324
+ if (parts.length === 1) {
325
+ return { firstname: parts[0], lastname: '' }
326
+ }
327
+
328
+ return {
329
+ firstname: parts[0],
330
+ lastname: parts.slice(1).join(' ')
331
+ }
332
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Bespoke Service
3
+ *
4
+ * Handles bespoke product availability checks.
5
+ */
6
+ import { storeManagementAxios } from '../client/httpClient'
7
+ import type { BespokeAvailability } from '../types/consultations'
8
+
9
+ export async function checkAvailability(product_id: string): Promise<BespokeAvailability> {
10
+ const { data } = await storeManagementAxios.get<BespokeAvailability>(`bespoke/availability/${product_id}`)
11
+ return data
12
+ }
13
+
14
+ // Service object export for backward compatibility
15
+ export const bespokeService = {
16
+ checkAvailability,
17
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Cart Service
3
+ *
4
+ * Handles cart operations for both guest and authenticated users.
5
+ */
6
+ import { storeManagementAxios, getOrCreateGuestSessionId, getGuestSessionId, clearGuestSessionId } from '../client/httpClient'
7
+ import type { CartItem, CartResponse } from '../types/store'
8
+
9
+ const GUEST_SESSION_COOKIE = "luxe_guest_sid"
10
+ const GUEST_CART_TTL_DAYS = 7
11
+
12
+ function parseItems(items: CartItem[]): CartItem[] {
13
+ return items.map((item) => ({
14
+ ...item,
15
+ variant_attributes:
16
+ item.variant_attributes && typeof item.variant_attributes === "string"
17
+ ? JSON.parse(item.variant_attributes)
18
+ : item.variant_attributes,
19
+ }));
20
+ }
21
+
22
+ /**
23
+ * Fetch guest cart from backend (Redis-backed).
24
+ */
25
+ export async function getGuestCart(): Promise<CartItem[]> {
26
+ const sid = getGuestSessionId()
27
+ if (!sid) return []
28
+
29
+ try {
30
+ const { data } = await storeManagementAxios.get<CartResponse>("cart/", {
31
+ headers: { "X-Guest-Session": sid },
32
+ })
33
+ return parseItems(data.items || [])
34
+ } catch {
35
+ return []
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Add item to guest cart (Redis-backed).
41
+ */
42
+ export async function addToGuestCart(item: CartItem): Promise<CartItem[]> {
43
+ const sid = getOrCreateGuestSessionId()
44
+ if (!sid) return []
45
+
46
+ const payload = {
47
+ product_id: item.product_id,
48
+ product_slug: item.product_slug,
49
+ product_name: item.product_name,
50
+ image: item.image,
51
+ variant_id: item.variant_id,
52
+ variant_attributes:
53
+ typeof item.variant_attributes === "string"
54
+ ? item.variant_attributes
55
+ : JSON.stringify(item.variant_attributes),
56
+ unit_price: item.unit_price,
57
+ quantity: item.quantity,
58
+ custom_measurements: item.custom_measurements,
59
+ is_bespoke: item.is_bespoke,
60
+ }
61
+
62
+ const { data } = await storeManagementAxios.post<CartResponse>("cart/", payload, {
63
+ headers: { "X-Guest-Session": sid },
64
+ })
65
+ return parseItems(data.items || [])
66
+ }
67
+
68
+ /**
69
+ * Update quantity of a guest cart item.
70
+ */
71
+ export async function updateGuestCartItemQuantity(
72
+ itemId: string,
73
+ quantity: number
74
+ ): Promise<CartItem[]> {
75
+ const sid = getGuestSessionId()
76
+ if (!sid) return []
77
+
78
+ const { data } = await storeManagementAxios.patch<CartResponse>(
79
+ `cart/${itemId}`,
80
+ { quantity },
81
+ { headers: { "X-Guest-Session": sid } }
82
+ )
83
+ return parseItems(data.items || [])
84
+ }
85
+
86
+ /**
87
+ * Remove an item from the guest cart.
88
+ */
89
+ export async function removeFromGuestCart(itemId: string): Promise<CartItem[]> {
90
+ const sid = getGuestSessionId()
91
+ if (!sid) return []
92
+
93
+ await storeManagementAxios.delete(`cart/${itemId}`, {
94
+ headers: { "X-Guest-Session": sid },
95
+ })
96
+
97
+ return getGuestCart()
98
+ }
99
+
100
+ /**
101
+ * Clear the entire guest cart.
102
+ */
103
+ export async function clearGuestCart(): Promise<void> {
104
+ const sid = getGuestSessionId()
105
+ if (!sid) return
106
+
107
+ await storeManagementAxios.delete("cart/clear", {
108
+ headers: { "X-Guest-Session": sid },
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Fetch authenticated user's cart from backend.
114
+ */
115
+ export async function getCart(): Promise<CartResponse> {
116
+ const { data } = await storeManagementAxios.get<CartResponse>("cart/")
117
+ const parsedItems = parseItems(data.items || [])
118
+ return { ...data, items: parsedItems }
119
+ }
120
+
121
+ /**
122
+ * Add item to authenticated user's cart.
123
+ */
124
+ export async function addToCart(item: {
125
+ product_id: string
126
+ product_slug: string
127
+ product_name: string
128
+ image?: string
129
+ variant_id?: string
130
+ variant_attributes?: string
131
+ unit_price: number
132
+ quantity: number
133
+ custom_measurements?: string
134
+ is_bespoke: boolean
135
+ }): Promise<CartResponse> {
136
+ const { data } = await storeManagementAxios.post<CartResponse>("cart/", item)
137
+ const parsedItems = parseItems(data.items || [])
138
+ return { ...data, items: parsedItems }
139
+ }
140
+
141
+ /**
142
+ * Update item quantity in authenticated user's cart.
143
+ */
144
+ export async function updateCartItem(
145
+ itemId: string,
146
+ quantity: number
147
+ ): Promise<CartResponse> {
148
+ const { data } = await storeManagementAxios.patch<CartResponse>(
149
+ `cart/${itemId}`,
150
+ { quantity }
151
+ )
152
+ const parsedItems = parseItems(data.items || [])
153
+ return { ...data, items: parsedItems }
154
+ }
155
+
156
+ /**
157
+ * Remove item from authenticated user's cart.
158
+ */
159
+ export async function removeFromCart(itemId: string): Promise<CartResponse> {
160
+ await storeManagementAxios.delete(`cart/${itemId}`)
161
+ return getCart()
162
+ }
163
+
164
+ /**
165
+ * Clear authenticated user's cart.
166
+ */
167
+ export async function clearCart(): Promise<void> {
168
+ await storeManagementAxios.delete("cart/clear")
169
+ }
170
+
171
+ /**
172
+ * Merge guest cart (from Redis) into authenticated user's DB cart.
173
+ */
174
+ export async function mergeGuestCart(): Promise<CartResponse> {
175
+ const sid = getGuestSessionId()
176
+ const guestItems = sid ? await getGuestCart() : []
177
+
178
+ if (guestItems.length === 0) {
179
+ return getCart()
180
+ }
181
+
182
+ const payloadItems = guestItems.map((item) => ({
183
+ product_id: item.product_id,
184
+ product_slug: item.product_slug,
185
+ product_name: item.product_name,
186
+ image: item.image,
187
+ variant_id: item.variant_id,
188
+ variant_attributes:
189
+ typeof item.variant_attributes === "string"
190
+ ? item.variant_attributes
191
+ : JSON.stringify(item.variant_attributes ?? {}),
192
+ unit_price: item.unit_price,
193
+ quantity: item.quantity,
194
+ custom_measurements:
195
+ typeof item.custom_measurements === "string"
196
+ ? item.custom_measurements
197
+ : JSON.stringify(item.custom_measurements ?? {}),
198
+ is_bespoke: item.is_bespoke,
199
+ }))
200
+
201
+ const { data } = await storeManagementAxios.post<CartResponse>(
202
+ "cart/merge",
203
+ { items: payloadItems },
204
+ sid ? { headers: { "X-Guest-Session": sid } } : {}
205
+ )
206
+
207
+ clearGuestSessionId()
208
+ return { ...data, items: parseItems(data.items || []) }
209
+ }
210
+
211
+ /**
212
+ * Convenience wrapper: merge guest cart if it has items (called post-login).
213
+ */
214
+ export async function mergeGuestCartIfPresent(): Promise<void> {
215
+ try {
216
+ const guestItems = await getGuestCart()
217
+ if (guestItems.length === 0) return
218
+ await mergeGuestCart()
219
+ } catch (error) {
220
+ console.warn("[Cart] Failed to merge guest cart:", error)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Returns 0 — count is now server-driven. Use the hook instead.
226
+ * Kept for backward compatibility.
227
+ */
228
+ export function getGuestCartCount(): number {
229
+ return 0
230
+ }
231
+
232
+ /**
233
+ * Returns 0 — subtotal is now server-driven.
234
+ * Kept for backward compatibility.
235
+ */
236
+ export function getGuestCartSubtotal(): number {
237
+ return 0
238
+ }
239
+
240
+ /**
241
+ * Derive product type from items (guest cart, no backend lookup needed).
242
+ */
243
+ export function getGuestCartProductType(): "physical" | "digital" | "bespoke" {
244
+ return "physical"
245
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * CMS Service
3
+ *
4
+ * Handles CMS model and entry operations using the kasuvia-cms SDK endpoints.
5
+ * Protected by X-Storefront-Key and X-Business-ID headers.
6
+ */
7
+ import { cmsAxios } from '../client/httpClient'
8
+ import { getRuntimeConfig } from '../config'
9
+ import type {
10
+ CmsEntry,
11
+ CmsEntryListResponse,
12
+ CreateCmsEntryParams,
13
+ UpdateCmsEntryParams,
14
+ ListCmsEntriesParams,
15
+ } from '../types/cms'
16
+
17
+ /**
18
+ * List CMS entries for a specific model.
19
+ * Requires public_api.read permission on the model.
20
+ */
21
+ export async function listCmsEntries(
22
+ modelSlug: string,
23
+ params?: ListCmsEntriesParams
24
+ ): Promise<CmsEntryListResponse> {
25
+ const config = getRuntimeConfig()
26
+ const { businessId } = config
27
+
28
+ const response = await cmsAxios.get<CmsEntryListResponse>(
29
+ `/sdk/${businessId}/${modelSlug}/entries`,
30
+ {
31
+ params: {
32
+ page: params?.page || 1,
33
+ page_size: params?.page_size || 50,
34
+ },
35
+ }
36
+ )
37
+
38
+ return response.data
39
+ }
40
+
41
+ /**
42
+ * Get a single CMS entry by ID.
43
+ * Requires public_api.read permission on the model.
44
+ */
45
+ export async function getCmsEntry(
46
+ modelSlug: string,
47
+ entryId: string
48
+ ): Promise<CmsEntry> {
49
+ const config = getRuntimeConfig()
50
+ const { businessId } = config
51
+
52
+ const response = await cmsAxios.get<CmsEntry>(
53
+ `/sdk/${businessId}/${modelSlug}/entries/${entryId}`
54
+ )
55
+
56
+ return response.data
57
+ }
58
+
59
+ /**
60
+ * Create a new CMS entry.
61
+ * Requires public_api.create permission on the model.
62
+ */
63
+ export async function createCmsEntry(
64
+ modelSlug: string,
65
+ params: CreateCmsEntryParams
66
+ ): Promise<CmsEntry> {
67
+ const config = getRuntimeConfig()
68
+ const { businessId } = config
69
+
70
+ const response = await cmsAxios.post<CmsEntry>(
71
+ `/sdk/${businessId}/${modelSlug}/entries`,
72
+ params
73
+ )
74
+
75
+ return response.data
76
+ }
77
+
78
+ /**
79
+ * Update an existing CMS entry.
80
+ * Requires public_api.update permission on the model.
81
+ */
82
+ export async function updateCmsEntry(
83
+ modelSlug: string,
84
+ entryId: string,
85
+ params: UpdateCmsEntryParams
86
+ ): Promise<CmsEntry> {
87
+ const config = getRuntimeConfig()
88
+ const { businessId } = config
89
+
90
+ const response = await cmsAxios.put<CmsEntry>(
91
+ `/sdk/${businessId}/${modelSlug}/entries/${entryId}`,
92
+ params
93
+ )
94
+
95
+ return response.data
96
+ }
97
+
98
+ /**
99
+ * Delete a CMS entry.
100
+ * Requires public_api.delete permission on the model.
101
+ */
102
+ export async function deleteCmsEntry(
103
+ modelSlug: string,
104
+ entryId: string
105
+ ): Promise<void> {
106
+ const config = getRuntimeConfig()
107
+ const { businessId } = config
108
+
109
+ await cmsAxios.delete(
110
+ `/sdk/${businessId}/${modelSlug}/entries/${entryId}`
111
+ )
112
+ }