@dev.smartpricing/platform-layer 0.0.2 → 0.0.3

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 ADDED
@@ -0,0 +1,568 @@
1
+ # @dev.smartpricing/platform-layer
2
+
3
+ Nuxt layer providing authentication, API client, and data-fetching composables for the Smartness platform. Built on [`ofetch`](https://github.com/unjs/ofetch) and [`@pinia/colada`](https://pinia-colada.esm.dev). Designed for any Nuxt application that needs to interact with the Smartness backend.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation](#installation)
8
+ - [Setup](#setup)
9
+ - [What's Included](#whats-included)
10
+ - [API Client](#api-client)
11
+ - [`createApiClient()`](#createapiclientoptions)
12
+ - [`useApiClient()`](#useapiclient)
13
+ - [Authentication](#authentication)
14
+ - [`useAuth()`](#useauth)
15
+ - [Queries](#queries)
16
+ - [Mutations](#mutations)
17
+ - [Overriding Layer Behavior](#overriding-layer-behavior)
18
+ - [Development](#development)
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pnpm add @dev.smartpricing/platform-layer
24
+ ```
25
+
26
+ ### Peer Dependencies
27
+
28
+ The layer requires the Smartness UI layer:
29
+
30
+ ```bash
31
+ pnpm add nuxt-ui-layer@"github:smartpricing/smartness-nuxt-ui#v1.6.3"
32
+ ```
33
+
34
+ ## Setup
35
+
36
+ ### 1. Extend the Layer
37
+
38
+ ```ts
39
+ // nuxt.config.ts
40
+ export default defineNuxtConfig({
41
+ extends: ['@dev.smartpricing/platform-layer'],
42
+ })
43
+ ```
44
+
45
+ ### 2. Configure Runtime Environment
46
+
47
+ Set these environment variables (or define them in `nuxt.config.ts` under `runtimeConfig.public`):
48
+
49
+ | Variable | Description | Default |
50
+ |----------|-------------|---------|
51
+ | `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
52
+ | `NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
53
+ | `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
54
+
55
+ ```bash
56
+ # .env
57
+ NUXT_PUBLIC_BACKEND_BASE_URL=https://api.smartpricing.com
58
+ NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME=smt_csrf
59
+ NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
60
+ ```
61
+
62
+ ## What's Included
63
+
64
+ The layer auto-registers these [Nuxt modules](https://nuxt.com/docs/guide/concepts/modules):
65
+
66
+ | Module | Purpose | Docs |
67
+ |--------|---------|------|
68
+ | [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
69
+ | [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
70
+ | [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
71
+ | [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
72
+ | [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
73
+
74
+ All composables, queries, and mutations are **auto-imported** — no manual imports needed.
75
+
76
+ ---
77
+
78
+ ## API Client
79
+
80
+ ### `createApiClient(options)`
81
+
82
+ > Factory function that creates a configured [`$fetch`](https://github.com/unjs/ofetch) instance using [`ofetch.create()`](https://github.com/unjs/ofetch#%EF%B8%8F-create-fetch-with-default-options). This is a low-level utility — most apps should use [`useApiClient()`](#useapiclient) instead.
83
+
84
+ #### Options
85
+
86
+ ```ts
87
+ interface ApiClientOptions {
88
+ baseURL: string
89
+ csrf?: ApiClientCsrfConfig
90
+ auth?: ApiClientAuthConfig
91
+ errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
92
+ }
93
+ ```
94
+
95
+ | Option | Type | Description |
96
+ |--------|------|-------------|
97
+ | `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
98
+ | `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
99
+ | `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
100
+ | `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
101
+
102
+ #### CSRF Config
103
+
104
+ ```ts
105
+ interface ApiClientCsrfConfig {
106
+ cookieName: string
107
+ headerName?: string // default: 'X-CSRF-Token'
108
+ }
109
+ ```
110
+
111
+ The client reads the CSRF token from the specified cookie via Nuxt's [`useCookie()`](https://nuxt.com/docs/api/composables/use-cookie) and injects it as a request header on every request using an [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors).
112
+
113
+ #### Auth Config
114
+
115
+ ```ts
116
+ interface ApiClientAuthConfig {
117
+ refreshBaseURL: string
118
+ refreshEndpoint?: string // default: '/api/auth/refresh'
119
+ platformURL: string
120
+ shouldRefresh?: (status: number, data: unknown) => boolean
121
+ onRefreshFailed?: () => void
122
+ mfa?: ApiClientMfaConfig
123
+ }
124
+
125
+ interface ApiClientMfaConfig {
126
+ shouldChallenge: (status: number, data: unknown) => boolean
127
+ challengePath?: string // default: '/auth/mfa-challenge'
128
+ }
129
+ ```
130
+
131
+ When `auth` is provided, the client wraps the raw `$fetch` instance with error-handling logic:
132
+
133
+ 1. **MFA challenge** — If `mfa.shouldChallenge()` returns `true`, redirects the user to `{platformURL}{challengePath}?redirect={currentUrl}`.
134
+ 2. **Token refresh** — If `shouldRefresh()` returns `true` (default: `401` with `session.expired` or `session.missing` error code), calls `POST {refreshEndpoint}` with [`credentials: 'include'`](https://github.com/unjs/ofetch#%EF%B8%8F-adding-headers). On success, retries the original request once.
135
+ 3. **Refresh dedup** — Concurrent 401s share a single refresh call (module-level promise deduplication).
136
+ 4. **Refresh failure** — Calls `onRefreshFailed()` if the refresh itself fails.
137
+ 5. **Error serialization** — If `errorSerializer` is set, transforms [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response) before rethrowing.
138
+
139
+ #### SSR Cookie Forwarding
140
+
141
+ On the server, the client reads the incoming request cookies via Nuxt's [`useRequestHeaders()`](https://nuxt.com/docs/api/composables/use-request-headers) and forwards them to the backend, ensuring cookie-based auth works during SSR.
142
+
143
+ #### Example: Custom Client
144
+
145
+ ```ts
146
+ const client = createApiClient({
147
+ baseURL: 'https://api.example.com',
148
+ csrf: { cookieName: 'my_csrf' },
149
+ auth: {
150
+ refreshBaseURL: 'https://api.example.com',
151
+ platformURL: 'https://app.example.com',
152
+ onRefreshFailed: () => navigateTo('/login'),
153
+ },
154
+ errorSerializer: ({ status, data }) => ({
155
+ code: status,
156
+ message: (data as any)?.error?.message ?? 'Unknown error',
157
+ }),
158
+ })
159
+
160
+ const users = await client<User[]>('/users', { query: { active: true } })
161
+ ```
162
+
163
+ ---
164
+
165
+ ### `useApiClient()`
166
+
167
+ > [Vue composable](https://vuejs.org/guide/reusability/composables.html) that returns a pre-configured `$fetch` instance using the layer's [runtime config](https://nuxt.com/docs/guide/going-further/runtime-config). This is the primary way to make API calls in consuming apps.
168
+
169
+ Internally calls [`createApiClient()`](#createapiclientoptions) with the runtime config values (`BACKEND_BASE_URL`, `ENV_CSRF_COOKIE_NAME`, `PLATFORM_URL`).
170
+
171
+ #### Features
172
+
173
+ - **CSRF token injection** — Reads from the `smt_csrf` cookie, sends as `X-CSRF-Token` header.
174
+ - **Auto token refresh** — On 401 with `session.expired`/`session.missing`, refreshes and retries once.
175
+ - **MFA challenge** — On `otp_required` error code, redirects to `/auth/mfa-challenge`.
176
+ - **SSR cookie forwarding** — Forwards request cookies during server-side rendering.
177
+ - **Auth redirect** — Redirects to `{PLATFORM_URL}/auth/login` when refresh fails.
178
+
179
+ #### Usage
180
+
181
+ ```vue
182
+ <script setup lang="ts">
183
+ // Auto-imported — no import needed
184
+ const client = useApiClient()
185
+
186
+ // GET with typed response
187
+ const users = await client<User[]>('/api/users')
188
+
189
+ // POST with body (auto-serialized as JSON)
190
+ const created = await client<User>('/api/users', {
191
+ method: 'POST',
192
+ body: { name: 'Alice', email: 'alice@example.com' },
193
+ })
194
+
195
+ // GET with query parameters
196
+ const filtered = await client<User[]>('/api/users', {
197
+ query: { role: 'admin', active: true },
198
+ })
199
+ ```
200
+
201
+ #### Using with Pinia Colada Queries
202
+
203
+ ```vue
204
+ <script setup lang="ts">
205
+ const client = useApiClient()
206
+
207
+ const { data, status, error } = useQuery({
208
+ key: ['custom-data'],
209
+ query: () => client<MyData>('/api/custom-endpoint'),
210
+ })
211
+ </script>
212
+ ```
213
+
214
+ #### Using with Pinia Colada Mutations
215
+
216
+ ```vue
217
+ <script setup lang="ts">
218
+ const client = useApiClient()
219
+
220
+ const { mutateAsync, status } = useMutation({
221
+ mutation: (body: CreateItemRequest) =>
222
+ client<CreateItemResponse>('/api/items', {
223
+ method: 'POST',
224
+ body,
225
+ }),
226
+ })
227
+
228
+ async function handleSubmit(data: CreateItemRequest) {
229
+ try {
230
+ const result = await mutateAsync(data)
231
+ }
232
+ catch (error) {
233
+ // FetchError with parsed body in error.data
234
+ }
235
+ }
236
+ </script>
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Authentication
242
+
243
+ ### `useAuth()`
244
+
245
+ > [Vue composable](https://vuejs.org/guide/reusability/composables.html) that wraps the [`useLogoutMutation()`](#authentication-mutations) and provides a sign-out flow with redirect.
246
+
247
+ #### Return Values
248
+
249
+ | Property | Type | Description |
250
+ |----------|------|-------------|
251
+ | `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
252
+ | `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
253
+ | `logoutError` | `Ref<Error \| null>` | Error if logout failed |
254
+
255
+ #### Usage
256
+
257
+ ```vue
258
+ <script setup lang="ts">
259
+ const { signOut, logoutStatus } = useAuth()
260
+ </script>
261
+
262
+ <template>
263
+ <button :disabled="logoutStatus === 'pending'" @click="signOut">
264
+ Sign Out
265
+ </button>
266
+ </template>
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Queries
272
+
273
+ All queries use [`useQuery()`](https://pinia-colada.esm.dev/guide/queries.html) from Pinia Colada. Each returns reactive `data`, `status`, `error`, `asyncStatus`, `refetch`, and `refresh`.
274
+
275
+ Each query file also exports a **key factory** (e.g., `sessionKeys`, `billingOverviewKeys`) for use with [`queryCache.invalidateQueries()`](https://pinia-colada.esm.dev/guide/mutations.html#query-invalidation).
276
+
277
+ ### Session & User
278
+
279
+ | Composable | Endpoint | Response Type | Key Factory |
280
+ |------------|----------|---------------|-------------|
281
+ | `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
282
+ | `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
283
+ | `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
284
+
285
+ ### Organization
286
+
287
+ | Composable | Endpoint | Response Type | Key Factory |
288
+ |------------|----------|---------------|-------------|
289
+ | `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
290
+
291
+ **Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
292
+
293
+ ### Billing
294
+
295
+ | Composable | Endpoint | Response Type | Key Factory |
296
+ |------------|----------|---------------|-------------|
297
+ | `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
298
+ | `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
299
+ | `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
300
+ | `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
301
+
302
+ #### Billing Documents Filter Params
303
+
304
+ ```ts
305
+ interface BillingDocumentsQueryParams {
306
+ legalEntityId?: string
307
+ customerId?: string
308
+ limit?: number
309
+ after?: string
310
+ before?: string
311
+ subscriptionIds?: string[]
312
+ type?: string
313
+ status?: string
314
+ search?: string
315
+ }
316
+ ```
317
+
318
+ ### Query Usage Examples
319
+
320
+ ```vue
321
+ <script setup lang="ts">
322
+ const route = useRoute()
323
+
324
+ // Static query — fetches immediately
325
+ const { data: session, status } = useSessionQuery()
326
+
327
+ // Dynamic query — re-fetches when orgId changes
328
+ const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
329
+
330
+ // Conditional query with filter params
331
+ const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
332
+ const { data: docs } = useBillingDocumentsQuery(
333
+ () => route.params.orgId as string,
334
+ filters,
335
+ )
336
+ </script>
337
+
338
+ <template>
339
+ <div v-if="status === 'pending'">Loading...</div>
340
+ <div v-else-if="status === 'error'">Error loading session</div>
341
+ <div v-else>Welcome, {{ session.name }}</div>
342
+ </template>
343
+ ```
344
+
345
+ ### Invalidating Queries After Mutations
346
+
347
+ Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm.dev/guide/mutations.html#query-invalidation):
348
+
349
+ ```vue
350
+ <script setup lang="ts">
351
+ import { useQueryCache } from '@pinia/colada'
352
+
353
+ const queryCache = useQueryCache()
354
+
355
+ const { mutateAsync: updateProfile } = useUpdateProfileMutation()
356
+
357
+ async function handleSave(data: UpdateProfileRequest) {
358
+ await updateProfile(data)
359
+ // Invalidate session to refetch updated user data
360
+ queryCache.invalidateQueries({ key: sessionKeys.me() })
361
+ }
362
+ </script>
363
+ ```
364
+
365
+ ---
366
+
367
+ ## Mutations
368
+
369
+ All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
370
+
371
+ | Property | Type | Description |
372
+ |----------|------|-------------|
373
+ | `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
374
+ | `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
375
+ | `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
376
+ | `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
377
+ | `data` | `Ref<TResult \| undefined>` | Last successful response |
378
+ | `error` | `Ref<Error \| null>` | Last error |
379
+ | `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
380
+ | `reset` | `() => void` | Reset to initial state |
381
+
382
+ ### Authentication Mutations
383
+
384
+ | Composable | Method | Endpoint | Request | Response |
385
+ |------------|--------|----------|---------|----------|
386
+ | `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
387
+ | `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
388
+ | `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
389
+ | `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
390
+ | `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
391
+
392
+ ### MFA Mutations
393
+
394
+ | Composable | Method | Endpoint | Request | Response |
395
+ |------------|--------|----------|---------|----------|
396
+ | `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
397
+ | `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
398
+ | `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
399
+ | `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
400
+ | `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
401
+
402
+ ### Profile Mutations
403
+
404
+ | Composable | Method | Endpoint | Request | Response |
405
+ |------------|--------|----------|---------|----------|
406
+ | `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
407
+ | `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
408
+ | `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
409
+ | `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
410
+
411
+ ### Billing Mutations
412
+
413
+ | Composable | Method | Endpoint | Request | Response |
414
+ |------------|--------|----------|---------|----------|
415
+ | `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
416
+ | `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
417
+ | `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
418
+ | `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
419
+
420
+ ### Organization Mutations
421
+
422
+ | Composable | Method | Endpoint | Request | Response |
423
+ |------------|--------|----------|---------|----------|
424
+ | `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
425
+ | `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
426
+ | `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
427
+
428
+ ### Mutation Usage Examples
429
+
430
+ #### Login Flow
431
+
432
+ ```vue
433
+ <script setup lang="ts">
434
+ const { mutateAsync: login, status, error } = useLoginMutation()
435
+
436
+ async function handleLogin(email: string, password: string) {
437
+ try {
438
+ await login({ email, password })
439
+ await navigateTo('/dashboard')
440
+ }
441
+ catch (err) {
442
+ // error.value is also set reactively
443
+ }
444
+ }
445
+ </script>
446
+
447
+ <template>
448
+ <form @submit.prevent="handleLogin(email, password)">
449
+ <p v-if="error">{{ error.message }}</p>
450
+ <button :disabled="status === 'pending'" type="submit">
451
+ {{ status === 'pending' ? 'Signing in...' : 'Sign In' }}
452
+ </button>
453
+ </form>
454
+ </template>
455
+ ```
456
+
457
+ #### MFA Setup Flow
458
+
459
+ ```vue
460
+ <script setup lang="ts">
461
+ const { mutateAsync: setupMfa } = useMfaSetupMutation()
462
+ const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
463
+
464
+ // Step 1: Get QR code / secret
465
+ const setupData = await setupMfa()
466
+
467
+ // Step 2: User enters TOTP code, finalize
468
+ await finalizeMfa({ otp: userCode })
469
+ </script>
470
+ ```
471
+
472
+ #### Billing Operations with Query Invalidation
473
+
474
+ ```vue
475
+ <script setup lang="ts">
476
+ import { useQueryCache } from '@pinia/colada'
477
+
478
+ const queryCache = useQueryCache()
479
+ const orgId = computed(() => route.params.orgId as string)
480
+
481
+ const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
482
+
483
+ async function handleRemove(paymentMethodId: string) {
484
+ try {
485
+ await removeMethod({ orgId: orgId.value, paymentMethodId })
486
+
487
+ // Refetch billing data after removing payment method
488
+ queryCache.invalidateQueries({ key: billingOverviewKeys.byOrg(orgId.value) })
489
+ }
490
+ catch (err) {
491
+ // Handle error
492
+ }
493
+ }
494
+ </script>
495
+ ```
496
+
497
+ #### Fire-and-Forget vs Async
498
+
499
+ ```vue
500
+ <script setup lang="ts">
501
+ const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
502
+
503
+ // Fire-and-forget — errors are swallowed, check error ref reactively
504
+ mutate({ name: 'New Name' })
505
+
506
+ // Async — errors rethrown, use try/catch
507
+ try {
508
+ const result = await mutateAsync({ name: 'New Name' })
509
+ }
510
+ catch (err) {
511
+ console.error('Update failed:', err)
512
+ }
513
+ </script>
514
+ ```
515
+
516
+ > All request/response types are imported from `@package/platform-shared`. See the shared package for type definitions.
517
+
518
+ ---
519
+
520
+ ## Overriding Layer Behavior
521
+
522
+ ### Disable Bundled Modules
523
+
524
+ If your app already provides a module the layer includes, [disable it](https://nuxt.com/docs/getting-started/layers#disable-layer-modules):
525
+
526
+ ```ts
527
+ // nuxt.config.ts
528
+ export default defineNuxtConfig({
529
+ extends: ['@dev.smartpricing/platform-layer'],
530
+ i18n: false, // disable layer's i18n
531
+ pinia: false, // disable layer's pinia
532
+ })
533
+ ```
534
+
535
+ ### Override i18n Locales
536
+
537
+ The layer ships with `en`, `it`, `de`, `es`. Your app's [i18n config](https://i18n.nuxtjs.org/docs/options) takes precedence — add or override locales in your own `nuxt.config.ts`.
538
+
539
+ ### Override Layer Components/Composables
540
+
541
+ Your project files always take [priority over layer files](https://nuxt.com/docs/getting-started/layers#layer-priority). Create a file with the same name in your app to override.
542
+
543
+ ---
544
+
545
+ ## Development
546
+
547
+ ```bash
548
+ # Install dependencies
549
+ pnpm install
550
+
551
+ # Start playground dev server
552
+ pnpm --filter @dev.smartpricing/platform-layer dev
553
+
554
+ # Prepare TypeScript
555
+ pnpm --filter @dev.smartpricing/platform-layer dev:prepare
556
+
557
+ # Build playground
558
+ pnpm --filter @dev.smartpricing/platform-layer build
559
+ ```
560
+
561
+ ## Related Resources
562
+
563
+ - [Nuxt Layers](https://nuxt.com/docs/getting-started/layers) — How Nuxt layers work
564
+ - [ofetch](https://github.com/unjs/ofetch) — Universal fetch library powering `$fetch`
565
+ - [Pinia Colada](https://pinia-colada.esm.dev) — Async data caching for Vue/Nuxt
566
+ - [Pinia](https://pinia.vuejs.org) — State management for Vue
567
+ - [Nuxt i18n](https://i18n.nuxtjs.org) — Internationalization module
568
+ - [VueUse](https://vueuse.org) — Collection of Vue composables
@@ -1,5 +1,9 @@
1
+ import type { FetchContext, FetchHook } from 'ofetch'
1
2
  import { FetchError } from 'ofetch'
2
3
 
4
+ type OnRequestHook = FetchHook<FetchContext>
5
+ type OnResponseErrorHook = FetchHook<FetchContext & { response: Response }>
6
+
3
7
  export interface ApiClientCsrfConfig {
4
8
  cookieName: string
5
9
  headerName?: string
@@ -24,6 +28,8 @@ export interface ApiClientOptions {
24
28
  csrf?: ApiClientCsrfConfig
25
29
  auth?: ApiClientAuthConfig
26
30
  errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
31
+ onRequest?: OnRequestHook
32
+ onResponseError?: OnResponseErrorHook
27
33
  }
28
34
 
29
35
  function defaultShouldRefresh(status: number, data: unknown): boolean {
@@ -36,26 +42,33 @@ function defaultShouldRefresh(status: number, data: unknown): boolean {
36
42
  let refreshPromise: Promise<boolean> | null = null
37
43
 
38
44
  export function createApiClient(options: ApiClientOptions) {
39
- const client = $fetch.create({
40
- baseURL: options.baseURL,
41
- credentials: 'include',
42
- onRequest({ options: reqOpts }) {
43
- // SSR: forward cookies from incoming request
44
- if (import.meta.server) {
45
- const { cookie } = useRequestHeaders(['cookie'])
46
- if (cookie) {
47
- reqOpts.headers.set('cookie', cookie)
48
- }
45
+ const builtInOnRequest: OnRequestHook = ({ options: reqOpts }) => {
46
+ // SSR: forward cookies from incoming request
47
+ if (import.meta.server) {
48
+ const { cookie } = useRequestHeaders(['cookie'])
49
+ if (cookie) {
50
+ reqOpts.headers.set('cookie', cookie)
49
51
  }
52
+ }
50
53
 
51
- // CSRF: inject token header
52
- if (options.csrf) {
53
- const token = useCookie(options.csrf.cookieName).value
54
- if (token) {
55
- reqOpts.headers.set(options.csrf.headerName ?? 'X-CSRF-Token', token)
56
- }
54
+ // CSRF: inject token header
55
+ if (options.csrf) {
56
+ const token = useCookie(options.csrf.cookieName).value
57
+ if (token) {
58
+ reqOpts.headers.set(options.csrf.headerName ?? 'X-CSRF-Token', token)
57
59
  }
58
- },
60
+ }
61
+ }
62
+
63
+ const onRequestHooks: OnRequestHook[] = [builtInOnRequest]
64
+ if (options.onRequest) {
65
+ onRequestHooks.push(...(Array.isArray(options.onRequest) ? options.onRequest : [options.onRequest]))
66
+ }
67
+
68
+ const client = $fetch.create({
69
+ baseURL: options.baseURL,
70
+ credentials: 'include',
71
+ onRequest: onRequestHooks,
59
72
  })
60
73
 
61
74
  // No auth handling needed — return raw $fetch instance
@@ -126,6 +139,14 @@ export function createApiClient(options: ApiClientOptions) {
126
139
  auth.onRefreshFailed?.()
127
140
  }
128
141
 
142
+ // Call consumer's onResponseError hooks
143
+ if (options.onResponseError) {
144
+ const hooks = Array.isArray(options.onResponseError) ? options.onResponseError : [options.onResponseError]
145
+ for (const hook of hooks) {
146
+ await hook({ request: url, response: error.response!, options: fetchOptions ?? {} } as FetchContext & { response: Response })
147
+ }
148
+ }
149
+
129
150
  serializeAndThrow(error)
130
151
  }
131
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/platform-layer",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./nuxt.config.ts",