@kasuvia/sdk 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +42 -15
  2. package/dist/AddressAutocomplete-CjcqPElD.d.mts +46 -0
  3. package/dist/AddressAutocomplete-CvJHuRkf.d.ts +46 -0
  4. package/dist/components/index.d.mts +497 -0
  5. package/dist/components/index.d.ts +497 -0
  6. package/dist/components/index.js +4217 -0
  7. package/dist/components/index.js.map +1 -0
  8. package/dist/components/index.mjs +4194 -0
  9. package/dist/components/index.mjs.map +1 -0
  10. package/dist/config/server.d.mts +26 -0
  11. package/dist/config/server.d.ts +26 -0
  12. package/dist/config/server.js +38 -0
  13. package/dist/config/server.js.map +1 -0
  14. package/dist/config/server.mjs +13 -0
  15. package/dist/config/server.mjs.map +1 -0
  16. package/dist/hooks/index.d.mts +393 -160
  17. package/dist/hooks/index.d.ts +393 -160
  18. package/dist/hooks/index.js +2792 -2137
  19. package/dist/hooks/index.js.map +1 -1
  20. package/dist/hooks/index.mjs +2734 -2121
  21. package/dist/hooks/index.mjs.map +1 -1
  22. package/dist/index-CNW_LINy.d.mts +578 -0
  23. package/dist/index-CNW_LINy.d.ts +578 -0
  24. package/dist/{index-C1chUA3P.d.mts → index-F7N6UItS.d.mts} +1 -1
  25. package/dist/{index-C1chUA3P.d.ts → index-F7N6UItS.d.ts} +1 -1
  26. package/dist/index-NkI10J_E.d.mts +40 -0
  27. package/dist/index-NkI10J_E.d.ts +40 -0
  28. package/dist/index.d.mts +20437 -103
  29. package/dist/index.d.ts +20437 -103
  30. package/dist/index.js +2111 -1802
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +2006 -1786
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/media.service-Cx7dQjzX.d.mts +1393 -0
  35. package/dist/media.service-mrBFJ2UQ.d.ts +1393 -0
  36. package/dist/services/index.d.mts +908 -2
  37. package/dist/services/index.d.ts +908 -2
  38. package/dist/services/index.js +1492 -1686
  39. package/dist/services/index.js.map +1 -1
  40. package/dist/services/index.mjs +1431 -1670
  41. package/dist/services/index.mjs.map +1 -1
  42. package/package.json +46 -6
  43. package/src/components/bespoke/BespokeAvailabilityBadge.tsx +32 -0
  44. package/src/components/bespoke/BespokeProgressTracker.tsx +58 -0
  45. package/src/components/bespoke/index.ts +3 -0
  46. package/src/components/booking/SlotPicker.tsx +97 -0
  47. package/src/components/booking/index.ts +1 -0
  48. package/src/components/cart/CartItem.tsx +81 -0
  49. package/src/components/cart/CartSidebar.tsx +115 -0
  50. package/src/components/cart/CartTypeMismatchBanner.tsx +55 -0
  51. package/src/components/cart/index.ts +3 -0
  52. package/src/components/index.ts +8 -0
  53. package/src/components/orders/DeliverableReviewer.tsx +139 -0
  54. package/src/components/orders/index.ts +1 -0
  55. package/src/components/product/ProductCard.tsx +350 -0
  56. package/src/components/product/ProductGrid.tsx +84 -0
  57. package/src/components/product/index.ts +2 -0
  58. package/src/components/shared/AddressAutocomplete.tsx +212 -0
  59. package/src/components/shared/AnalyticsScripts.tsx +56 -0
  60. package/src/components/shared/CookieConsentBanner.tsx +98 -0
  61. package/src/components/shared/ImageEditorModal.tsx +1066 -0
  62. package/src/components/shared/ImageViewerModal.tsx +598 -0
  63. package/src/components/shared/LucideReactIcon.tsx +52 -0
  64. package/src/components/shared/MapPinDrop.tsx +101 -0
  65. package/src/components/shared/MultiSelectPicker.tsx +193 -0
  66. package/src/components/shared/OfflineBanner.tsx +46 -0
  67. package/src/components/shared/SearchableInput.tsx +153 -0
  68. package/src/components/shared/SearchablePopover.tsx +166 -0
  69. package/src/components/shared/SmartImage.tsx +311 -0
  70. package/src/components/shared/TruncatedValue.tsx +27 -0
  71. package/src/components/shared/UnsavedChangesGuard.tsx +138 -0
  72. package/src/components/shared/index.ts +15 -0
  73. package/src/components/skeletons/CardSkeleton.tsx +26 -0
  74. package/src/components/skeletons/CartItemSkeleton.tsx +26 -0
  75. package/src/components/skeletons/OrderItemSkeleton.tsx +26 -0
  76. package/src/components/skeletons/ProductCardSkeleton.tsx +26 -0
  77. package/src/components/skeletons/TableSkeleton.tsx +42 -0
  78. package/src/components/skeletons/index.ts +5 -0
  79. package/src/config/index.ts +22 -4
  80. package/src/config/server.ts +45 -0
  81. package/src/context/sdk-context.tsx +5 -2
  82. package/src/generated/auth-openapi.ts +2935 -0
  83. package/src/generated/cart-openapi.ts +652 -0
  84. package/src/generated/cms-openapi.ts +1668 -0
  85. package/src/generated/crm-openapi.ts +986 -0
  86. package/src/generated/graphql.ts +126 -0
  87. package/src/generated/index.ts +11 -0
  88. package/src/generated/media-openapi.ts +613 -0
  89. package/src/generated/notification-openapi.ts +862 -0
  90. package/src/generated/payment-openapi.ts +2713 -0
  91. package/src/generated/store-openapi.ts +9510 -0
  92. package/src/graphql/operations/collections.graphql +32 -0
  93. package/src/graphql/operations/products.graphql +197 -0
  94. package/src/graphql/operations/wishlist.graphql +25 -0
  95. package/src/hooks/index.ts +9 -2
  96. package/src/hooks/use-auth.ts +163 -69
  97. package/src/hooks/use-bespoke-availability.ts +39 -22
  98. package/src/hooks/use-business-config.ts +51 -0
  99. package/src/hooks/use-cart.ts +310 -238
  100. package/src/hooks/use-cms.ts +107 -127
  101. package/src/hooks/use-collections.ts +35 -55
  102. package/src/hooks/use-consultations.ts +9 -136
  103. package/src/hooks/use-countries.ts +19 -28
  104. package/src/hooks/use-currencies.ts +8 -20
  105. package/src/hooks/use-customer-address.ts +64 -0
  106. package/src/hooks/use-feedback.ts +34 -0
  107. package/src/hooks/use-languages.ts +26 -39
  108. package/src/hooks/use-media-upload.ts +23 -0
  109. package/src/hooks/use-notification-stream.ts +88 -0
  110. package/src/hooks/use-notifications.ts +163 -0
  111. package/src/hooks/use-order-stream.ts +29 -29
  112. package/src/hooks/use-orders.ts +91 -73
  113. package/src/hooks/use-payment.ts +116 -84
  114. package/src/hooks/use-products.ts +65 -269
  115. package/src/hooks/use-push-notifications.ts +122 -0
  116. package/src/hooks/use-reviews.ts +175 -0
  117. package/src/hooks/use-service-booking.ts +109 -132
  118. package/src/hooks/use-states.ts +16 -30
  119. package/src/hooks/use-wishlist.ts +44 -69
  120. package/src/index.ts +15 -11
  121. package/src/services/auth.service.ts +422 -132
  122. package/src/services/bespoke.service.ts +69 -10
  123. package/src/services/cart.service.ts +155 -186
  124. package/src/services/cms.service.ts +41 -50
  125. package/src/services/collection.service.ts +25 -63
  126. package/src/services/config.service.ts +10 -21
  127. package/src/services/consultation.service.ts +36 -93
  128. package/src/services/feedback.service.ts +76 -0
  129. package/src/services/index.ts +7 -1
  130. package/src/services/legal.service.ts +62 -0
  131. package/src/services/logistics.service.ts +235 -53
  132. package/src/services/media.service.ts +115 -0
  133. package/src/services/notifications.service.ts +204 -20
  134. package/src/services/order.service.ts +498 -127
  135. package/src/services/payment.service.ts +149 -100
  136. package/src/services/product.service.ts +157 -814
  137. package/src/services/review.service.ts +211 -0
  138. package/src/services/service-booking.service.ts +198 -99
  139. package/src/services/wishlist.service.ts +27 -74
  140. package/src/types/auth/index.ts +161 -18
  141. package/src/types/business/index.ts +9 -1
  142. package/src/types/cms/index.ts +91 -6
  143. package/src/types/cms/legal.ts +18 -0
  144. package/src/types/consultations/index.ts +60 -68
  145. package/src/types/index.ts +5 -3
  146. package/src/types/media/index.ts +35 -3
  147. package/src/types/payments/index.ts +80 -16
  148. package/src/types/reviews/index.ts +116 -0
  149. package/src/types/services/index.ts +113 -38
  150. package/src/types/store/checkout.ts +53 -0
  151. package/src/types/store/index.ts +273 -52
  152. package/src/types/store/invoice.ts +31 -0
  153. package/src/types/store/shipping.ts +84 -3
  154. package/src/types/ui/index.ts +1 -1
  155. package/src/utils/checkout.ts +19 -3
  156. package/src/utils/cookieConsent.ts +34 -0
  157. package/src/utils/format.ts +408 -0
  158. package/src/utils/geolocation.ts +134 -0
  159. package/src/utils/graphql-url.ts +47 -0
  160. package/src/utils/guest-session.ts +59 -0
  161. package/src/utils/http-client.ts +147 -0
  162. package/src/utils/index.ts +11 -4
  163. package/src/utils/status-colors.ts +79 -0
  164. package/src/utils/token-refresh.ts +115 -0
  165. package/src/utils/validators.ts +13 -0
  166. package/src/utils/youtube.ts +18 -0
  167. package/dist/index-C5YO9gTl.d.mts +0 -507
  168. package/dist/index-C5YO9gTl.d.ts +0 -507
  169. package/dist/index-CefG7_xq.d.mts +0 -793
  170. package/dist/index-DeHCf3zI.d.ts +0 -793
  171. package/src/graphql/collections.graphql.ts +0 -48
  172. package/src/graphql/index.ts +0 -7
  173. package/src/graphql/products.graphql.ts +0 -860
  174. package/src/graphql/wishlist.graphql.ts +0 -34
  175. package/src/hooks/use-consultation-stream.ts +0 -142
  176. package/src/types/bespoke/index.ts +0 -39
@@ -4,75 +4,88 @@
4
4
  */
5
5
  import type { SdkConfig } from '../config'
6
6
  import { STORAGE_KEYS } from '../constants'
7
+ import { apiRequest, ApiError, storefrontHeaders, authHeaders, JSON_HEADERS } from '../utils/http-client'
8
+ import { buildFullName, splitFullName } from '../utils/customer'
7
9
  import type {
8
10
  CustomerAuthResponse,
9
11
  CustomerLoginPayload,
10
12
  CustomerRegisterPayload,
13
+ CustomerPasswordlessRequestPayload,
14
+ CustomerPasswordlessVerifyPayload,
15
+ CustomerForgotPasswordPayload,
16
+ CustomerResetPasswordPayload,
17
+ CustomerDeactivatePayload,
18
+ DeactivateAccountResult,
19
+ DeactivateConfirmationRequired,
20
+ CustomerReactivatePayload,
21
+ CustomerReactivateVerifyPayload,
11
22
  LogoutPayload,
12
23
  RefreshTokenResponse,
13
24
  CustomerUser,
14
25
  AuthUserResponse,
15
26
  UpdateProfilePayload,
27
+ StorefrontBusinessInfo,
28
+ ChangePasswordPayload,
29
+ CustomerAddress,
30
+ CreateCustomerAddressPayload,
31
+ UpdateCustomerAddressPayload,
16
32
  } from '../types/auth'
17
33
 
34
+ import type { MediaAsset } from '../types/media'
35
+
18
36
  // ── Auth API Calls ────────────────────────────────────────────────────────────
19
37
 
20
38
  /**
21
39
  * POST /auth/login/customer/
22
- * The `tenant_id` is automatically filled from config.businessId.
40
+ * Business is identified by the X-Storefront-Key + X-Business-ID headers
41
+ * (config.storefrontApiKey / config.businessId) — never a body field (see
42
+ * plan: Customer Storefront Identity Enforcement).
23
43
  */
24
44
  export async function loginCustomer(
25
45
  config: SdkConfig,
26
- payload: Omit<CustomerLoginPayload, 'tenant_id'>
46
+ payload: CustomerLoginPayload,
47
+ guestSessionId?: string | null
27
48
  ): Promise<CustomerAuthResponse> {
28
- const response = await fetch(`${config.backendUrls.auth}/auth/login/customer/`, {
49
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
50
+ // REAL FEATURE FOUND (2026-09-21): kasuvia-auth's login/register/OTP-verify
51
+ // views already run a real server-side guest-cart merge the moment tokens
52
+ // are minted (customer.py's _run_post_login_merge, reading this exact
53
+ // header) — deliberately built to replace depending on a frontend
54
+ // remembering to call kasuvia-cart-management's /cart/merge afterward (see
55
+ // cart_merge_client.py's own doc comment). This was simply never wired:
56
+ // nothing in the storefront ever sent this header on login.
57
+ if (guestSessionId) {
58
+ headers['X-Guest-Session'] = guestSessionId
59
+ }
60
+ return apiRequest<CustomerAuthResponse>(`${config.backendUrls.auth}/auth/login/customer/`, {
29
61
  method: 'POST',
30
- headers: {
31
- 'Content-Type': 'application/json',
32
- 'X-Storefront-Key': config.storefrontApiKey,
33
- 'X-Business-ID': config.businessId,
34
- },
35
- body: JSON.stringify({
36
- ...payload,
37
- tenant_id: config.businessId,
38
- }),
62
+ headers,
63
+ body: JSON.stringify(payload),
64
+ fallbackErrorMessage: 'Login failed',
39
65
  })
40
-
41
- if (!response.ok) {
42
- throw new Error('Login failed')
43
- }
44
-
45
- const data = await response.json()
46
- return data
47
66
  }
48
67
 
49
68
  /**
50
69
  * POST /auth/register/customer/
51
- * The `tenant_id` is automatically filled from config.businessId.
70
+ * Business identified via headers, see loginCustomer.
52
71
  */
53
72
  export async function registerCustomer(
54
73
  config: SdkConfig,
55
- payload: Omit<CustomerRegisterPayload, 'tenant_id'>
74
+ payload: CustomerRegisterPayload,
75
+ guestSessionId?: string | null
56
76
  ): Promise<CustomerAuthResponse> {
57
- const response = await fetch(`${config.backendUrls.auth}/auth/register/customer/`, {
77
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
78
+ // See loginCustomer's identical comment — same real, already-built
79
+ // server-side merge, same header.
80
+ if (guestSessionId) {
81
+ headers['X-Guest-Session'] = guestSessionId
82
+ }
83
+ return apiRequest<CustomerAuthResponse>(`${config.backendUrls.auth}/auth/register/customer/`, {
58
84
  method: 'POST',
59
- headers: {
60
- 'Content-Type': 'application/json',
61
- 'X-Storefront-Key': config.storefrontApiKey,
62
- 'X-Business-ID': config.businessId,
63
- },
64
- body: JSON.stringify({
65
- ...payload,
66
- tenant_id: config.businessId,
67
- }),
85
+ headers,
86
+ body: JSON.stringify(payload),
87
+ fallbackErrorMessage: 'Registration failed',
68
88
  })
69
-
70
- if (!response.ok) {
71
- throw new Error('Registration failed')
72
- }
73
-
74
- const data = await response.json()
75
- return data
76
89
  }
77
90
 
78
91
  /** POST /auth/token/refresh/
@@ -81,38 +94,44 @@ export async function refreshToken(
81
94
  config: SdkConfig,
82
95
  refresh: string
83
96
  ): Promise<RefreshTokenResponse> {
84
- const response = await fetch(`${config.backendUrls.auth}/auth/token/refresh/`, {
97
+ return apiRequest<RefreshTokenResponse>(`${config.backendUrls.auth}/auth/token/refresh/`, {
85
98
  method: 'POST',
86
- headers: {
87
- 'Content-Type': 'application/json',
88
- },
99
+ headers: JSON_HEADERS,
89
100
  body: JSON.stringify({ refresh }),
101
+ fallbackErrorMessage: 'Token refresh failed',
90
102
  })
91
-
92
- if (!response.ok) {
93
- throw new Error('Token refresh failed')
94
- }
95
-
96
- const data = await response.json()
97
- return data
98
103
  }
99
104
 
100
- /** POST /auth/logout/ — revokes the server-side session. */
105
+ /**
106
+ * POST /auth/logout/ — revokes the server-side session.
107
+ *
108
+ * Never throws: a customer's own logout must always clear their local
109
+ * session client-side (caller does this regardless of this promise's
110
+ * outcome) even if the backend is unreachable — an unrevoked server-side
111
+ * session in that case is a availability tradeoff, not a security one (the
112
+ * refresh token still expires on its own TTL). What CLAUDE.md forbids is
113
+ * failing SILENTLY; the failure is now always logged with real context so
114
+ * it's visible to whoever is debugging a report of "sessions not clearing
115
+ * server-side", instead of vanishing into a bare `catch {}`.
116
+ */
101
117
  export async function logout(
102
118
  config: SdkConfig,
103
119
  refresh?: string
104
120
  ): Promise<void> {
105
121
  const payload: LogoutPayload = refresh ? { refresh } : {}
106
122
  try {
107
- await fetch(`${config.backendUrls.auth}/auth/logout/`, {
123
+ await apiRequest(`${config.backendUrls.auth}/auth/logout/`, {
108
124
  method: 'POST',
109
- headers: {
110
- 'Content-Type': 'application/json',
111
- },
125
+ headers: JSON_HEADERS,
112
126
  body: JSON.stringify(payload),
127
+ fallbackErrorMessage: 'Server-side logout failed',
113
128
  })
114
- } catch {
115
- // Swallow server errors
129
+ } catch (err) {
130
+ if (err instanceof ApiError) {
131
+ console.error(`[auth.service] Server-side logout failed with status ${err.status}; local session will still be cleared.`)
132
+ } else {
133
+ console.error('[auth.service] Server-side logout request failed; local session will still be cleared.', err)
134
+ }
116
135
  }
117
136
  }
118
137
 
@@ -124,25 +143,154 @@ export async function verifySocialToken(
124
143
  provider: 'google' | 'facebook',
125
144
  token: string
126
145
  ): Promise<CustomerAuthResponse> {
127
- const response = await fetch(`${config.backendUrls.auth}/auth/social/verify/${provider}/`, {
146
+ return apiRequest<CustomerAuthResponse>(`${config.backendUrls.auth}/auth/social/verify/${provider}/`, {
147
+ method: 'POST',
148
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
149
+ body: JSON.stringify({ id_token: token }),
150
+ fallbackErrorMessage: 'Social token verification failed',
151
+ })
152
+ }
153
+
154
+ /**
155
+ * POST /auth/login/customer/passwordless/request/
156
+ * Sends a one-time login code to the customer's email. Always resolves
157
+ * successfully (the backend never reveals whether the email is registered).
158
+ * Business identified via headers, see loginCustomer.
159
+ */
160
+ export async function requestCustomerOtp(
161
+ config: SdkConfig,
162
+ payload: CustomerPasswordlessRequestPayload
163
+ ): Promise<void> {
164
+ await apiRequest(`${config.backendUrls.auth}/auth/login/customer/passwordless/request/`, {
128
165
  method: 'POST',
129
- headers: {
130
- 'Content-Type': 'application/json',
131
- },
132
- body: JSON.stringify({
133
- id_token: token,
134
- storefront_key: config.storefrontApiKey,
135
- }),
166
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
167
+ body: JSON.stringify(payload),
168
+ fallbackErrorMessage: 'Failed to send login code',
136
169
  })
170
+ }
137
171
 
138
- if (!response.ok) {
139
- throw new Error('Social token verification failed')
172
+ /**
173
+ * POST /auth/login/customer/passwordless/verify/
174
+ * Verifies a one-time login code and returns tokens, auto-creating the
175
+ * customer account on first use (same as social login).
176
+ * Business identified via headers, see loginCustomer.
177
+ */
178
+ export async function verifyCustomerOtp(
179
+ config: SdkConfig,
180
+ payload: CustomerPasswordlessVerifyPayload,
181
+ guestSessionId?: string | null
182
+ ): Promise<CustomerAuthResponse> {
183
+ const headers: Record<string, string> = { ...JSON_HEADERS, ...storefrontHeaders(config) }
184
+ // See loginCustomer's identical comment — same real, already-built
185
+ // server-side merge, same header.
186
+ if (guestSessionId) {
187
+ headers['X-Guest-Session'] = guestSessionId
140
188
  }
189
+ return apiRequest<CustomerAuthResponse>(`${config.backendUrls.auth}/auth/login/customer/passwordless/verify/`, {
190
+ method: 'POST',
191
+ headers,
192
+ body: JSON.stringify(payload),
193
+ fallbackErrorMessage: 'Invalid or expired code',
194
+ })
195
+ }
141
196
 
142
- const data = await response.json()
143
- return data
197
+ /**
198
+ * POST /auth/forgot-password/customer/
199
+ * Request a customer password reset OTP (business-scoped via headers).
200
+ */
201
+ export async function forgotPasswordCustomer(
202
+ config: SdkConfig,
203
+ payload: CustomerForgotPasswordPayload
204
+ ): Promise<{ detail: string }> {
205
+ return apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/forgot-password/customer/`, {
206
+ method: 'POST',
207
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
208
+ body: JSON.stringify(payload),
209
+ fallbackErrorMessage: 'Failed to request password reset',
210
+ })
211
+ }
212
+
213
+ /**
214
+ * POST /auth/reset-password/customer/
215
+ * Verify OTP and reset customer password (business-scoped via headers).
216
+ */
217
+ export async function resetPasswordCustomer(
218
+ config: SdkConfig,
219
+ payload: CustomerResetPasswordPayload
220
+ ): Promise<{ detail: string }> {
221
+ return apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/reset-password/customer/`, {
222
+ method: 'POST',
223
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
224
+ body: JSON.stringify(payload),
225
+ fallbackErrorMessage: 'Invalid or expired reset code',
226
+ })
227
+ }
228
+
229
+ /**
230
+ * POST /auth/customer/deactivate/
231
+ * Self-service account deactivation for customers.
232
+ */
233
+ export async function deactivateCustomerAccount(
234
+ config: SdkConfig,
235
+ accessToken: string,
236
+ payload?: CustomerDeactivatePayload
237
+ ): Promise<DeactivateAccountResult> {
238
+ try {
239
+ const data = await apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/customer/deactivate/`, {
240
+ method: 'POST',
241
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
242
+ body: JSON.stringify(payload || {}),
243
+ fallbackErrorMessage: 'Failed to deactivate account',
244
+ })
245
+ return { status: 'success', detail: data.detail }
246
+ } catch (err) {
247
+ // REAL BUG FIXED (2026-09-20): a 409 here is NOT a failure — it's step 1
248
+ // of require_destructive_confirmation's real two-step contract (see
249
+ // DeactivateConfirmationRequired's doc comment). The FIRST call with no
250
+ // confirmation_code ALWAYS 409s by design, so this function as written
251
+ // before could never actually succeed — it threw a generic Error on every
252
+ // real call, discarding the impact list and confirmation_code the caller
253
+ // needs to complete the flow.
254
+ if (err instanceof ApiError && err.status === 409) {
255
+ return { status: 'confirmation_required', data: err.body as DeactivateConfirmationRequired }
256
+ }
257
+ throw err
258
+ }
259
+ }
260
+
261
+ /**
262
+ * POST /auth/customer/reactivate/
263
+ * Request reactivation code for a deactivated customer account.
264
+ */
265
+ export async function requestCustomerReactivation(
266
+ config: SdkConfig,
267
+ payload: CustomerReactivatePayload
268
+ ): Promise<{ detail: string }> {
269
+ return apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/customer/reactivate/`, {
270
+ method: 'POST',
271
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
272
+ body: JSON.stringify(payload),
273
+ fallbackErrorMessage: 'Failed to request reactivation',
274
+ })
275
+ }
276
+
277
+ /**
278
+ * POST /auth/customer/reactivate/verify/
279
+ * Verify reactivation OTP and restore customer account.
280
+ */
281
+ export async function verifyCustomerReactivation(
282
+ config: SdkConfig,
283
+ payload: CustomerReactivateVerifyPayload
284
+ ): Promise<{ detail: string }> {
285
+ return apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/customer/reactivate/verify/`, {
286
+ method: 'POST',
287
+ headers: { ...JSON_HEADERS, ...storefrontHeaders(config) },
288
+ body: JSON.stringify(payload),
289
+ fallbackErrorMessage: 'Invalid or expired reactivation code',
290
+ })
144
291
  }
145
292
 
293
+
146
294
  /** Decode a JWT payload without verifying signature (client-side use only). */
147
295
  function parseJwtPayload(token: string): Record<string, unknown> {
148
296
  try {
@@ -211,47 +359,38 @@ function buildCustomerFromClaims(claims: Record<string, unknown>): CustomerUser
211
359
  /** GET /auth/profile/ */
212
360
  export async function getMe(config?: SdkConfig, accessToken?: string): Promise<CustomerUser> {
213
361
  if (!config || !accessToken) {
214
- const response = await fetch('/api/auth/me', { credentials: 'include' })
215
- if (!response.ok) throw new Error('Failed to fetch profile')
216
- const data = await response.json()
217
- return data.user
362
+ // `/api/auth/me` proxies the raw AuthUserResponse shape (nested
363
+ // `.profile.firstname` etc) under `data.user` — it must be mapped the
364
+ // same way the config+accessToken branch below maps it, otherwise every
365
+ // field on the returned "CustomerUser" is silently undefined.
366
+ const data = await apiRequest<{ user: AuthUserResponse }>('/api/auth/me', {
367
+ credentials: 'include',
368
+ fallbackErrorMessage: 'Failed to fetch profile',
369
+ })
370
+ return mapAuthUserToCustomer(data.user)
218
371
  }
219
372
 
220
- const response = await fetch(`${config.backendUrls.auth}/auth/profile/`, {
221
- headers: {
222
- 'Authorization': `Bearer ${accessToken}`,
223
- },
373
+ const data = await apiRequest<AuthUserResponse>(`${config.backendUrls.auth}/auth/profile/`, {
374
+ headers: authHeaders(config, accessToken),
375
+ fallbackErrorMessage: 'Failed to fetch profile',
224
376
  })
225
-
226
- if (!response.ok) {
227
- throw new Error('Failed to fetch profile')
228
- }
229
-
230
- const data = await response.json()
231
377
  return mapAuthUserToCustomer(data)
232
378
  }
233
379
 
234
380
  /** GET /auth/profile/ - alias for getMe for backward compatibility */
235
381
  export async function getAuthUser(config?: SdkConfig, accessToken?: string): Promise<AuthUserResponse> {
236
382
  if (!config || !accessToken) {
237
- const response = await fetch('/api/auth/me', { credentials: 'include' })
238
- if (!response.ok) throw new Error('Failed to fetch auth user')
239
- const data = await response.json()
383
+ const data = await apiRequest<{ user: AuthUserResponse }>('/api/auth/me', {
384
+ credentials: 'include',
385
+ fallbackErrorMessage: 'Failed to fetch auth user',
386
+ })
240
387
  return data.user
241
388
  }
242
389
 
243
- const response = await fetch(`${config.backendUrls.auth}/auth/profile/`, {
244
- headers: {
245
- 'Authorization': `Bearer ${accessToken}`,
246
- },
390
+ return apiRequest<AuthUserResponse>(`${config.backendUrls.auth}/auth/profile/`, {
391
+ headers: authHeaders(config, accessToken),
392
+ fallbackErrorMessage: 'Failed to fetch auth user',
247
393
  })
248
-
249
- if (!response.ok) {
250
- throw new Error('Failed to fetch auth user')
251
- }
252
-
253
- const data = await response.json()
254
- return data
255
394
  }
256
395
 
257
396
  /** PUT /auth/profile/ — update firstname, lastname, phone */
@@ -263,35 +402,148 @@ export async function updateProfile(
263
402
  const isArg1Config = typeof configOrPayload === 'object' && configOrPayload !== null && 'businessId' in configOrPayload
264
403
  if (!isArg1Config) {
265
404
  const payload = configOrPayload as UpdateProfilePayload
266
- const response = await fetch('/api/auth/profile', {
405
+ return apiRequest<CustomerUser>('/api/auth/profile', {
267
406
  method: 'PUT',
268
- headers: { 'Content-Type': 'application/json' },
407
+ headers: JSON_HEADERS,
269
408
  credentials: 'include',
270
409
  body: JSON.stringify(payload),
410
+ fallbackErrorMessage: 'Failed to update profile',
271
411
  })
272
- if (!response.ok) throw new Error('Failed to update profile')
273
- return response.json()
274
412
  }
275
413
 
276
414
  const config = configOrPayload as SdkConfig
277
415
  const accessToken = accessTokenOrPayload as string
278
416
  const payload = payloadParam as UpdateProfilePayload
279
417
 
280
- const response = await fetch(`${config.backendUrls.auth}/auth/profile/`, {
418
+ // REAL BUG FIXED (2026-09-20): hardcoded generic message discarded real
419
+ // CustomerProfileUpdateSerializer validation errors (e.g. invalid phone
420
+ // format) — apiRequest's shared extractor handles this DRF dict shape.
421
+ const data = await apiRequest<AuthUserResponse>(`${config.backendUrls.auth}/auth/profile/`, {
281
422
  method: 'PUT',
282
- headers: {
283
- 'Content-Type': 'application/json',
284
- 'Authorization': `Bearer ${accessToken}`,
285
- },
423
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
286
424
  body: JSON.stringify(payload),
425
+ fallbackErrorMessage: 'Failed to update profile',
287
426
  })
427
+ return mapAuthUserToCustomer(data)
428
+ }
288
429
 
289
- if (!response.ok) {
290
- throw new Error('Failed to update profile')
291
- }
430
+ /**
431
+ * POST /auth/change-password/ — identity-mode-agnostic; the backend
432
+ * branches on the JWT's identity_mode (kasuvia-auth/authentication/api/v1/
433
+ * views/core.py:353, ChangePasswordView) to mutate the right underlying
434
+ * profile (customer/staff/owner). Used here for a logged-in storefront
435
+ * customer. `old_password` may be omitted only if the account genuinely has
436
+ * no password yet (a social/passwordless-only customer setting one for the
437
+ * first time) — the backend itself enforces this, not the frontend.
438
+ */
439
+ export async function changePassword(
440
+ config: SdkConfig,
441
+ accessToken: string,
442
+ payload: ChangePasswordPayload
443
+ ): Promise<{ detail: string }> {
444
+ return apiRequest<{ detail: string }>(`${config.backendUrls.auth}/auth/change-password/`, {
445
+ method: 'POST',
446
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
447
+ body: JSON.stringify(payload),
448
+ fallbackErrorMessage: 'Failed to change password',
449
+ })
450
+ }
292
451
 
293
- const data = await response.json()
294
- return mapAuthUserToCustomer(data)
452
+ /**
453
+ * GET /auth/storefront/business-info/
454
+ * Public, unauthenticated — only needs the storefront key (no access token,
455
+ * no X-Business-ID; PublicBusinessInfoView resolves the business from the
456
+ * key itself). Real gap fixed 2026-09-17: this endpoint existed on the
457
+ * backend with zero SDK coverage.
458
+ */
459
+ export async function getStorefrontBusinessInfo(config: SdkConfig): Promise<StorefrontBusinessInfo> {
460
+ // PublicBusinessInfoView resolves the business from X-Storefront-Key alone
461
+ // and never reads X-Business-ID — storefrontHeaders() sends both anyway
462
+ // for consistency with every other call site; the extra header is inert
463
+ // here (verified against the real view before making this change).
464
+ return apiRequest<StorefrontBusinessInfo>(`${config.backendUrls.auth}/auth/storefront/business-info/`, {
465
+ headers: storefrontHeaders(config),
466
+ fallbackErrorMessage: 'Failed to fetch business info',
467
+ })
468
+ }
469
+
470
+ export interface PresignedUploadUrlParams {
471
+ filename: string
472
+ content_type: string
473
+ business_id: string
474
+ context: string
475
+ entity_id: string
476
+ owner_id?: string
477
+ affiliation?: string
478
+ }
479
+
480
+ export interface PresignedUploadUrlResponse {
481
+ presigned_url: string
482
+ storage_path: string
483
+ bucket: string
484
+ expires_in: number
485
+ asset_id?: string
486
+ }
487
+
488
+ /**
489
+ * POST /auth/media/presigned-url/ — kasuvia-auth's thin proxy in front of
490
+ * kasuvia-media-system. Params are sent as a query string (not a JSON
491
+ * body) to match every other caller of this endpoint.
492
+ */
493
+ export async function getPresignedUploadUrl(
494
+ config: SdkConfig,
495
+ accessToken: string,
496
+ params: PresignedUploadUrlParams
497
+ ): Promise<PresignedUploadUrlResponse> {
498
+ const query = new URLSearchParams(
499
+ Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][]
500
+ )
501
+ // Bearer-only, deliberately not storefrontHeaders() — this endpoint's real
502
+ // header contract was never verified to also accept/require X-Storefront-
503
+ // Key/X-Business-ID, unlike getStorefrontBusinessInfo above.
504
+ return apiRequest<PresignedUploadUrlResponse>(
505
+ `${config.backendUrls.auth}/auth/media/presigned-url/?${query.toString()}`,
506
+ {
507
+ method: 'POST',
508
+ headers: { Authorization: `Bearer ${accessToken}` },
509
+ fallbackErrorMessage: 'Failed to get presigned upload URL',
510
+ }
511
+ )
512
+ }
513
+
514
+ export interface ConfirmMediaUploadParams {
515
+ storage_path: string
516
+ business_id: string
517
+ entity_id: string
518
+ original_filename: string
519
+ content_type: string
520
+ size: number
521
+ context: string
522
+ owner_id?: string
523
+ affiliation?: string
524
+ }
525
+
526
+ /**
527
+ * POST /auth/media/confirm-upload/ — confirms a completed direct-to-R2
528
+ * upload and returns the resulting MediaAsset. Params are sent as a query
529
+ * string (not a JSON body) to match every other caller of this endpoint.
530
+ */
531
+ export async function confirmMediaUpload(
532
+ config: SdkConfig,
533
+ accessToken: string,
534
+ params: ConfirmMediaUploadParams
535
+ ): Promise<MediaAsset> {
536
+ const query = new URLSearchParams(
537
+ Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][]
538
+ )
539
+ return apiRequest<MediaAsset>(
540
+ `${config.backendUrls.auth}/auth/media/confirm-upload/?${query.toString()}`,
541
+ {
542
+ method: 'POST',
543
+ headers: { Authorization: `Bearer ${accessToken}` },
544
+ fallbackErrorMessage: 'Failed to confirm upload',
545
+ }
546
+ )
295
547
  }
296
548
 
297
549
  /**
@@ -312,32 +564,17 @@ export async function silentRefresh(
312
564
  const user = buildCustomerFromClaims(claims)
313
565
  const provider = user.provider
314
566
  return { user, provider, newAccessToken: data.access, newRefreshToken: data.refresh }
315
- } catch {
567
+ } catch (err) {
568
+ // REAL BUG FIXED (2026-09-20): bare `catch {}` silently discarded the
569
+ // real reason (network failure vs. an actually-revoked/expired refresh
570
+ // token) — `null` here correctly means "not currently logged in" to the
571
+ // caller (a legitimate outcome, not swallowed), but CLAUDE.md still
572
+ // requires the failure itself be logged, not silently dropped.
573
+ console.error('[auth.service] Silent token refresh failed:', err)
316
574
  return null
317
575
  }
318
576
  }
319
577
 
320
- // ── Helper Functions ─────────────────────────────────────────────────────────
321
-
322
- function buildFullName(firstname?: string, lastname?: string): string {
323
- if (firstname && lastname) return `${firstname} ${lastname}`
324
- return firstname || lastname || ''
325
- }
326
-
327
- function splitFullName(fullName?: string): { firstname: string; lastname: string } {
328
- if (!fullName) return { firstname: '', lastname: '' }
329
-
330
- const parts = fullName.trim().split(/\s+/)
331
- if (parts.length === 1) {
332
- return { firstname: parts[0], lastname: '' }
333
- }
334
-
335
- return {
336
- firstname: parts[0],
337
- lastname: parts.slice(1).join(' ')
338
- }
339
- }
340
-
341
578
  /**
342
579
  * Store tokens in localStorage (client-side utility)
343
580
  * This is a convenience function for storefronts to store auth tokens
@@ -371,3 +608,56 @@ export function getStoredTokens(): { accessToken: string | null; refreshToken: s
371
608
  refreshToken: localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN),
372
609
  }
373
610
  }
611
+
612
+ // ── Customer saved-address book ─────────────────────────────────────────────
613
+ // REAL FEATURE WIRED (2026-09-22): CustomerProfile.address_book existed as a
614
+ // backend JSONField with no real write path anywhere (see
615
+ // CustomerAddressListView's own doc comment in kasuvia-auth). This is the
616
+ // SDK-side coverage for the real, now-validated endpoints.
617
+
618
+ /** GET /auth/profile/addresses/ */
619
+ export async function getCustomerAddresses(config: SdkConfig, accessToken: string): Promise<CustomerAddress[]> {
620
+ const data = await apiRequest<{ results: CustomerAddress[] }>(`${config.backendUrls.auth}/auth/profile/addresses/`, {
621
+ headers: authHeaders(config, accessToken),
622
+ fallbackErrorMessage: 'Failed to fetch saved addresses',
623
+ })
624
+ return data.results
625
+ }
626
+
627
+ /** POST /auth/profile/addresses/ */
628
+ export async function createCustomerAddress(
629
+ config: SdkConfig,
630
+ accessToken: string,
631
+ payload: CreateCustomerAddressPayload
632
+ ): Promise<CustomerAddress> {
633
+ return apiRequest<CustomerAddress>(`${config.backendUrls.auth}/auth/profile/addresses/`, {
634
+ method: 'POST',
635
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
636
+ body: JSON.stringify(payload),
637
+ fallbackErrorMessage: 'Failed to save address',
638
+ })
639
+ }
640
+
641
+ /** PATCH /auth/profile/addresses/{id}/ */
642
+ export async function updateCustomerAddress(
643
+ config: SdkConfig,
644
+ accessToken: string,
645
+ addressId: string,
646
+ payload: UpdateCustomerAddressPayload
647
+ ): Promise<CustomerAddress> {
648
+ return apiRequest<CustomerAddress>(`${config.backendUrls.auth}/auth/profile/addresses/${addressId}/`, {
649
+ method: 'PATCH',
650
+ headers: { ...JSON_HEADERS, ...authHeaders(config, accessToken) },
651
+ body: JSON.stringify(payload),
652
+ fallbackErrorMessage: 'Failed to update address',
653
+ })
654
+ }
655
+
656
+ /** DELETE /auth/profile/addresses/{id}/ */
657
+ export async function deleteCustomerAddress(config: SdkConfig, accessToken: string, addressId: string): Promise<void> {
658
+ await apiRequest(`${config.backendUrls.auth}/auth/profile/addresses/${addressId}/`, {
659
+ method: 'DELETE',
660
+ headers: authHeaders(config, accessToken),
661
+ fallbackErrorMessage: 'Failed to delete address',
662
+ })
663
+ }