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