@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.
- package/LICENSE +201 -0
- package/README.md +112 -0
- package/dist/index.d.mts +1891 -0
- package/dist/index.d.ts +1891 -0
- package/dist/index.js +3540 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3314 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
- package/src/client/authClient.ts +135 -0
- package/src/client/graphqlClient.ts +53 -0
- package/src/client/httpClient.ts +298 -0
- package/src/config/index.ts +102 -0
- package/src/graphql/collections.graphql.ts +56 -0
- package/src/graphql/index.ts +7 -0
- package/src/graphql/products.graphql.ts +710 -0
- package/src/graphql/wishlist.graphql.ts +33 -0
- package/src/hooks/index.ts +26 -0
- package/src/hooks/use-auth.ts +182 -0
- package/src/hooks/use-bespoke-availability.ts +20 -0
- package/src/hooks/use-cart.ts +207 -0
- package/src/hooks/use-cms.ts +165 -0
- package/src/hooks/use-collections.ts +40 -0
- package/src/hooks/use-consultation-stream.ts +141 -0
- package/src/hooks/use-consultations.ts +76 -0
- package/src/hooks/use-countries.ts +24 -0
- package/src/hooks/use-currencies.ts +27 -0
- package/src/hooks/use-google-translate.ts +164 -0
- package/src/hooks/use-is-mounted.ts +9 -0
- package/src/hooks/use-languages.ts +33 -0
- package/src/hooks/use-order-stream.ts +195 -0
- package/src/hooks/use-orders.ts +64 -0
- package/src/hooks/use-payment.ts +68 -0
- package/src/hooks/use-products.ts +113 -0
- package/src/hooks/use-service-booking.ts +118 -0
- package/src/hooks/use-states.ts +28 -0
- package/src/hooks/use-wishlist.ts +121 -0
- package/src/index.ts +65 -0
- package/src/services/app.service.ts +17 -0
- package/src/services/auth.service.ts +332 -0
- package/src/services/bespoke.service.ts +17 -0
- package/src/services/cart.service.ts +245 -0
- package/src/services/cms.service.ts +112 -0
- package/src/services/collection.service.ts +77 -0
- package/src/services/config.service.ts +42 -0
- package/src/services/consultation.service.ts +51 -0
- package/src/services/index.ts +21 -0
- package/src/services/logistics.service.ts +95 -0
- package/src/services/notifications.service.ts +50 -0
- package/src/services/order.service.ts +169 -0
- package/src/services/payment.service.ts +127 -0
- package/src/services/product.service.ts +678 -0
- package/src/services/service-booking.service.ts +86 -0
- package/src/services/wishlist.service.ts +84 -0
- package/src/types/auth/index.ts +126 -0
- package/src/types/bespoke/index.ts +39 -0
- package/src/types/business/index.ts +6 -0
- package/src/types/cms/index.ts +77 -0
- package/src/types/consultations/index.ts +79 -0
- package/src/types/index.ts +27 -0
- package/src/types/media/index.ts +41 -0
- package/src/types/payments/index.ts +120 -0
- package/src/types/services/index.ts +80 -0
- package/src/types/store/index.ts +282 -0
- package/src/types/store/shipping.ts +38 -0
- package/src/types/ui/index.ts +32 -0
- package/src/utils/cache.ts +33 -0
- package/src/utils/checkout.ts +39 -0
- package/src/utils/customer.ts +20 -0
- package/src/utils/error-handler.ts +14 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/logger.ts +79 -0
- 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
|
+
}
|