@dev.smartpricing/platform-layer 0.0.3 → 0.0.5

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 CHANGED
@@ -38,24 +38,26 @@ pnpm add nuxt-ui-layer@"github:smartpricing/smartness-nuxt-ui#v1.6.3"
38
38
  ```ts
39
39
  // nuxt.config.ts
40
40
  export default defineNuxtConfig({
41
- extends: ['@dev.smartpricing/platform-layer'],
42
- })
41
+ extends: ["@dev.smartpricing/platform-layer"],
42
+ });
43
43
  ```
44
44
 
45
45
  ### 2. Configure Runtime Environment
46
46
 
47
47
  Set these environment variables (or define them in `nuxt.config.ts` under `runtimeConfig.public`):
48
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 | `''` |
49
+ | Variable | Description | Default |
50
+ | ------------------------------ | ------------------------------- | ---------- |
51
+ | `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
52
+ | `NUXT_PUBLIC_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
53
+ | `NUXT_PUBLIC_ENVIRONMENT` | Environment | `local` |
54
+ | `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
54
55
 
55
56
  ```bash
56
57
  # .env
57
58
  NUXT_PUBLIC_BACKEND_BASE_URL=https://api.smartpricing.com
58
- NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME=smt_csrf
59
+ NUXT_PUBLIC_CSRF_COOKIE_NAME=smt_csrf
60
+ ENVIRONMENT=local
59
61
  NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
60
62
  ```
61
63
 
@@ -63,13 +65,13 @@ NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
63
65
 
64
66
  The layer auto-registers these [Nuxt modules](https://nuxt.com/docs/guide/concepts/modules):
65
67
 
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 | — |
68
+ | Module | Purpose | Docs |
69
+ | -------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------- |
70
+ | [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
71
+ | [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
72
+ | [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
73
+ | [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
74
+ | [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
73
75
 
74
76
  All composables, queries, and mutations are **auto-imported** — no manual imports needed.
75
77
 
@@ -85,26 +87,34 @@ All composables, queries, and mutations are **auto-imported** — no manual impo
85
87
 
86
88
  ```ts
87
89
  interface ApiClientOptions {
88
- baseURL: string
89
- csrf?: ApiClientCsrfConfig
90
- auth?: ApiClientAuthConfig
91
- errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
90
+ baseURL: string;
91
+ csrf?: ApiClientCsrfConfig;
92
+ auth?: ApiClientAuthConfig;
93
+ errorSerializer?: (context: {
94
+ status: number;
95
+ statusText: string;
96
+ data: unknown;
97
+ }) => unknown;
98
+ onRequest?: FetchHook<FetchContext>;
99
+ onResponseError?: FetchHook<FetchContext & { response: Response }>;
92
100
  }
93
101
  ```
94
102
 
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). |
103
+ | Option | Type | Description |
104
+ | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
105
+ | `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
106
+ | `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
107
+ | `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
108
+ | `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
109
+ | `onRequest` | `FetchHook<FetchContext>` | Custom [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) appended after built-in hooks (cookie forwarding, CSRF). |
110
+ | `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling. |
101
111
 
102
112
  #### CSRF Config
103
113
 
104
114
  ```ts
105
115
  interface ApiClientCsrfConfig {
106
- cookieName: string
107
- headerName?: string // default: 'X-CSRF-Token'
116
+ cookieName: string;
117
+ headerName?: string; // default: 'X-CSRF-Token'
108
118
  }
109
119
  ```
110
120
 
@@ -114,17 +124,17 @@ The client reads the CSRF token from the specified cookie via Nuxt's [`useCookie
114
124
 
115
125
  ```ts
116
126
  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
127
+ refreshBaseURL: string;
128
+ refreshEndpoint?: string; // default: '/api/auth/refresh'
129
+ platformURL: string;
130
+ shouldRefresh?: (status: number, data: unknown) => boolean;
131
+ onRefreshFailed?: () => void;
132
+ mfa?: ApiClientMfaConfig;
123
133
  }
124
134
 
125
135
  interface ApiClientMfaConfig {
126
- shouldChallenge: (status: number, data: unknown) => boolean
127
- challengePath?: string // default: '/auth/mfa-challenge'
136
+ shouldChallenge: (status: number, data: unknown) => boolean;
137
+ challengePath?: string; // default: '/auth/mfa-challenge'
128
138
  }
129
139
  ```
130
140
 
@@ -144,20 +154,20 @@ On the server, the client reads the incoming request cookies via Nuxt's [`useReq
144
154
 
145
155
  ```ts
146
156
  const client = createApiClient({
147
- baseURL: 'https://api.example.com',
148
- csrf: { cookieName: 'my_csrf' },
157
+ baseURL: "https://api.example.com",
158
+ csrf: { cookieName: "my_csrf" },
149
159
  auth: {
150
- refreshBaseURL: 'https://api.example.com',
151
- platformURL: 'https://app.example.com',
152
- onRefreshFailed: () => navigateTo('/login'),
160
+ refreshBaseURL: "https://api.example.com",
161
+ platformURL: "https://app.example.com",
162
+ onRefreshFailed: () => navigateTo("/login"),
153
163
  },
154
164
  errorSerializer: ({ status, data }) => ({
155
165
  code: status,
156
- message: (data as any)?.error?.message ?? 'Unknown error',
166
+ message: (data as any)?.error?.message ?? "Unknown error",
157
167
  }),
158
- })
168
+ });
159
169
 
160
- const users = await client<User[]>('/users', { query: { active: true } })
170
+ const users = await client<User[]>("/users", { query: { active: true } });
161
171
  ```
162
172
 
163
173
  ---
@@ -202,12 +212,12 @@ const filtered = await client<User[]>('/api/users', {
202
212
 
203
213
  ```vue
204
214
  <script setup lang="ts">
205
- const client = useApiClient()
215
+ const client = useApiClient();
206
216
 
207
217
  const { data, status, error } = useQuery({
208
- key: ['custom-data'],
209
- query: () => client<MyData>('/api/custom-endpoint'),
210
- })
218
+ key: ["custom-data"],
219
+ query: () => client<MyData>("/api/custom-endpoint"),
220
+ });
211
221
  </script>
212
222
  ```
213
223
 
@@ -215,21 +225,20 @@ const { data, status, error } = useQuery({
215
225
 
216
226
  ```vue
217
227
  <script setup lang="ts">
218
- const client = useApiClient()
228
+ const client = useApiClient();
219
229
 
220
230
  const { mutateAsync, status } = useMutation({
221
231
  mutation: (body: CreateItemRequest) =>
222
- client<CreateItemResponse>('/api/items', {
223
- method: 'POST',
232
+ client<CreateItemResponse>("/api/items", {
233
+ method: "POST",
224
234
  body,
225
235
  }),
226
- })
236
+ });
227
237
 
228
238
  async function handleSubmit(data: CreateItemRequest) {
229
239
  try {
230
- const result = await mutateAsync(data)
231
- }
232
- catch (error) {
240
+ const result = await mutateAsync(data);
241
+ } catch (error) {
233
242
  // FetchError with parsed body in error.data
234
243
  }
235
244
  }
@@ -246,17 +255,17 @@ async function handleSubmit(data: CreateItemRequest) {
246
255
 
247
256
  #### Return Values
248
257
 
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 |
258
+ | Property | Type | Description |
259
+ | -------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
260
+ | `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
261
+ | `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
262
+ | `logoutError` | `Ref<Error \| null>` | Error if logout failed |
254
263
 
255
264
  #### Usage
256
265
 
257
266
  ```vue
258
267
  <script setup lang="ts">
259
- const { signOut, logoutStatus } = useAuth()
268
+ const { signOut, logoutStatus } = useAuth();
260
269
  </script>
261
270
 
262
271
  <template>
@@ -276,42 +285,42 @@ Each query file also exports a **key factory** (e.g., `sessionKeys`, `billingOve
276
285
 
277
286
  ### Session & User
278
287
 
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()` |
288
+ | Composable | Endpoint | Response Type | Key Factory |
289
+ | ------------------------ | --------------------------- | ------------------------- | ------------------------ |
290
+ | `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
291
+ | `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
283
292
  | `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
284
293
 
285
294
  ### Organization
286
295
 
287
- | Composable | Endpoint | Response Type | Key Factory |
288
- |------------|----------|---------------|-------------|
296
+ | Composable | Endpoint | Response Type | Key Factory |
297
+ | ----------------------------- | ----------------------- | ----------------------------- | ------------------------------ |
289
298
  | `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
290
299
 
291
300
  **Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
292
301
 
293
302
  ### Billing
294
303
 
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)` |
304
+ | Composable | Endpoint | Response Type | Key Factory |
305
+ | --------------------------------------------- | --------------------------------------------------------- | ------------------------------ | ---------------------------------------------- |
306
+ | `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
307
+ | `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
308
+ | `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
309
+ | `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
301
310
 
302
311
  #### Billing Documents Filter Params
303
312
 
304
313
  ```ts
305
314
  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
+ legalEntityId?: string;
316
+ customerId?: string;
317
+ limit?: number;
318
+ after?: string;
319
+ before?: string;
320
+ subscriptionIds?: string[];
321
+ type?: string;
322
+ status?: string;
323
+ search?: string;
315
324
  }
316
325
  ```
317
326
 
@@ -319,20 +328,20 @@ interface BillingDocumentsQueryParams {
319
328
 
320
329
  ```vue
321
330
  <script setup lang="ts">
322
- const route = useRoute()
331
+ const route = useRoute();
323
332
 
324
333
  // Static query — fetches immediately
325
- const { data: session, status } = useSessionQuery()
334
+ const { data: session, status } = useSessionQuery();
326
335
 
327
336
  // Dynamic query — re-fetches when orgId changes
328
- const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
337
+ const { data: org } = useOrganizationQuery(() => route.params.orgId as string);
329
338
 
330
339
  // Conditional query with filter params
331
- const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
340
+ const filters = ref<BillingDocumentsQueryParams>({ limit: 20 });
332
341
  const { data: docs } = useBillingDocumentsQuery(
333
342
  () => route.params.orgId as string,
334
343
  filters,
335
- )
344
+ );
336
345
  </script>
337
346
 
338
347
  <template>
@@ -348,16 +357,16 @@ Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm
348
357
 
349
358
  ```vue
350
359
  <script setup lang="ts">
351
- import { useQueryCache } from '@pinia/colada'
360
+ import { useQueryCache } from "@pinia/colada";
352
361
 
353
- const queryCache = useQueryCache()
362
+ const queryCache = useQueryCache();
354
363
 
355
- const { mutateAsync: updateProfile } = useUpdateProfileMutation()
364
+ const { mutateAsync: updateProfile } = useUpdateProfileMutation();
356
365
 
357
366
  async function handleSave(data: UpdateProfileRequest) {
358
- await updateProfile(data)
367
+ await updateProfile(data);
359
368
  // Invalidate session to refetch updated user data
360
- queryCache.invalidateQueries({ key: sessionKeys.me() })
369
+ queryCache.invalidateQueries({ key: sessionKeys.me() });
361
370
  }
362
371
  </script>
363
372
  ```
@@ -368,62 +377,62 @@ async function handleSave(data: UpdateProfileRequest) {
368
377
 
369
378
  All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
370
379
 
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 |
380
+ | Property | Type | Description |
381
+ | ------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
382
+ | `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
383
+ | `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
384
+ | `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
385
+ | `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
386
+ | `data` | `Ref<TResult \| undefined>` | Last successful response |
387
+ | `error` | `Ref<Error \| null>` | Last error |
388
+ | `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
389
+ | `reset` | `() => void` | Reset to initial state |
381
390
 
382
391
  ### Authentication Mutations
383
392
 
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` |
393
+ | Composable | Method | Endpoint | Request | Response |
394
+ | ------------------------------ | ------ | ---------------------------- | ------------------------ | -------- |
395
+ | `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
396
+ | `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
397
+ | `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
398
+ | `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
399
+ | `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
391
400
 
392
401
  ### MFA Mutations
393
402
 
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` |
403
+ | Composable | Method | Endpoint | Request | Response |
404
+ | ----------------------------------------- | ------ | ----------------------------------------- | ----------------------------------- | ------------------------------------ |
405
+ | `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
406
+ | `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
407
+ | `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
408
+ | `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
400
409
  | `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
401
410
 
402
411
  ### Profile Mutations
403
412
 
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` |
413
+ | Composable | Method | Endpoint | Request | Response |
414
+ | ----------------------------------- | ------- | ---------------------------------- | ----------------------- | ----------------------- |
415
+ | `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
416
+ | `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
417
+ | `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
418
+ | `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
410
419
 
411
420
  ### Billing Mutations
412
421
 
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` |
422
+ | Composable | Method | Endpoint | Request | Response |
423
+ | -------------------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- |
424
+ | `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
425
+ | `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
426
+ | `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
427
+ | `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
419
428
 
420
429
  ### Organization Mutations
421
430
 
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` |
431
+ | Composable | Method | Endpoint | Request | Response |
432
+ | ---------------------------------------------- | ------- | -------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------- |
433
+ | `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
434
+ | `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
435
+ | `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
427
436
 
428
437
  ### Mutation Usage Examples
429
438
 
@@ -431,14 +440,13 @@ All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations
431
440
 
432
441
  ```vue
433
442
  <script setup lang="ts">
434
- const { mutateAsync: login, status, error } = useLoginMutation()
443
+ const { mutateAsync: login, status, error } = useLoginMutation();
435
444
 
436
445
  async function handleLogin(email: string, password: string) {
437
446
  try {
438
- await login({ email, password })
439
- await navigateTo('/dashboard')
440
- }
441
- catch (err) {
447
+ await login({ email, password });
448
+ await navigateTo("/dashboard");
449
+ } catch (err) {
442
450
  // error.value is also set reactively
443
451
  }
444
452
  }
@@ -448,7 +456,7 @@ async function handleLogin(email: string, password: string) {
448
456
  <form @submit.prevent="handleLogin(email, password)">
449
457
  <p v-if="error">{{ error.message }}</p>
450
458
  <button :disabled="status === 'pending'" type="submit">
451
- {{ status === 'pending' ? 'Signing in...' : 'Sign In' }}
459
+ {{ status === "pending" ? "Signing in..." : "Sign In" }}
452
460
  </button>
453
461
  </form>
454
462
  </template>
@@ -458,14 +466,14 @@ async function handleLogin(email: string, password: string) {
458
466
 
459
467
  ```vue
460
468
  <script setup lang="ts">
461
- const { mutateAsync: setupMfa } = useMfaSetupMutation()
462
- const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
469
+ const { mutateAsync: setupMfa } = useMfaSetupMutation();
470
+ const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation();
463
471
 
464
472
  // Step 1: Get QR code / secret
465
- const setupData = await setupMfa()
473
+ const setupData = await setupMfa();
466
474
 
467
475
  // Step 2: User enters TOTP code, finalize
468
- await finalizeMfa({ otp: userCode })
476
+ await finalizeMfa({ otp: userCode });
469
477
  </script>
470
478
  ```
471
479
 
@@ -473,21 +481,22 @@ await finalizeMfa({ otp: userCode })
473
481
 
474
482
  ```vue
475
483
  <script setup lang="ts">
476
- import { useQueryCache } from '@pinia/colada'
484
+ import { useQueryCache } from "@pinia/colada";
477
485
 
478
- const queryCache = useQueryCache()
479
- const orgId = computed(() => route.params.orgId as string)
486
+ const queryCache = useQueryCache();
487
+ const orgId = computed(() => route.params.orgId as string);
480
488
 
481
- const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
489
+ const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation();
482
490
 
483
491
  async function handleRemove(paymentMethodId: string) {
484
492
  try {
485
- await removeMethod({ orgId: orgId.value, paymentMethodId })
493
+ await removeMethod({ orgId: orgId.value, paymentMethodId });
486
494
 
487
495
  // Refetch billing data after removing payment method
488
- queryCache.invalidateQueries({ key: billingOverviewKeys.byOrg(orgId.value) })
489
- }
490
- catch (err) {
496
+ queryCache.invalidateQueries({
497
+ key: billingOverviewKeys.byOrg(orgId.value),
498
+ });
499
+ } catch (err) {
491
500
  // Handle error
492
501
  }
493
502
  }
@@ -498,17 +507,16 @@ async function handleRemove(paymentMethodId: string) {
498
507
 
499
508
  ```vue
500
509
  <script setup lang="ts">
501
- const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
510
+ const { mutate, mutateAsync, status, error } = useUpdateProfileMutation();
502
511
 
503
512
  // Fire-and-forget — errors are swallowed, check error ref reactively
504
- mutate({ name: 'New Name' })
513
+ mutate({ name: "New Name" });
505
514
 
506
515
  // Async — errors rethrown, use try/catch
507
516
  try {
508
- const result = await mutateAsync({ name: 'New Name' })
509
- }
510
- catch (err) {
511
- console.error('Update failed:', err)
517
+ const result = await mutateAsync({ name: "New Name" });
518
+ } catch (err) {
519
+ console.error("Update failed:", err);
512
520
  }
513
521
  </script>
514
522
  ```
@@ -526,10 +534,10 @@ If your app already provides a module the layer includes, [disable it](https://n
526
534
  ```ts
527
535
  // nuxt.config.ts
528
536
  export default defineNuxtConfig({
529
- extends: ['@dev.smartpricing/platform-layer'],
530
- i18n: false, // disable layer's i18n
531
- pinia: false, // disable layer's pinia
532
- })
537
+ extends: ["@dev.smartpricing/platform-layer"],
538
+ i18n: false, // disable layer's i18n
539
+ pinia: false, // disable layer's pinia
540
+ });
533
541
  ```
534
542
 
535
543
  ### Override i18n Locales
package/_shared/mfa.ts CHANGED
@@ -58,6 +58,30 @@ export const MfaRegenerateRecoveryCodesResponseSchema = z.object({
58
58
  recovery_codes: z.array(z.string()),
59
59
  })
60
60
 
61
+ // ── Send email code ────────────────────────────────────────────────
62
+
63
+ export const MfaSendEmailCodeRequestSchema = z.object({
64
+ enrollmentId: z.string(),
65
+ })
66
+
67
+ export const MfaSendEmailCodeResponseSchema = z.object({
68
+ sent: z.literal(true),
69
+ email_masked: z.string(),
70
+ expires_in_seconds: z.number(),
71
+ retry_after_seconds: z.number(),
72
+ })
73
+
74
+ // ── Verify email ───────────────────────────────────────────────────
75
+
76
+ export const MfaVerifyEmailRequestSchema = z.object({
77
+ enrollmentId: z.string(),
78
+ emailCode: z.string(),
79
+ })
80
+
81
+ export const MfaVerifyEmailResponseSchema = z.object({
82
+ verified: z.literal(true),
83
+ })
84
+
61
85
  // ── Types ───────────────────────────────────────────────────────────
62
86
 
63
87
  export type MfaStepUpRequest = z.infer<typeof MfaStepUpRequestSchema>
@@ -73,3 +97,9 @@ export type MfaDisableResponse = z.infer<typeof MfaDisableResponseSchema>
73
97
 
74
98
  export type MfaRegenerateRecoveryCodesRequest = z.infer<typeof MfaRegenerateRecoveryCodesRequestSchema>
75
99
  export type MfaRegenerateRecoveryCodesResponse = z.infer<typeof MfaRegenerateRecoveryCodesResponseSchema>
100
+
101
+ export type MfaSendEmailCodeRequest = z.infer<typeof MfaSendEmailCodeRequestSchema>
102
+ export type MfaSendEmailCodeResponse = z.infer<typeof MfaSendEmailCodeResponseSchema>
103
+
104
+ export type MfaVerifyEmailRequest = z.infer<typeof MfaVerifyEmailRequestSchema>
105
+ export type MfaVerifyEmailResponse = z.infer<typeof MfaVerifyEmailResponseSchema>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { SuiteProduct } from 'nuxt-ui-layer/types'
3
+
4
+ const props = defineProps<{
5
+ product: SuiteProduct
6
+ collapsed?: boolean
7
+ }>()
8
+
9
+ const { enabledProducts, navigateToProduct } = useProductSwitcher()
10
+
11
+ const selectedProduct = shallowRef<SuiteProduct>(props.product)
12
+
13
+ watch(selectedProduct, (value) => {
14
+ if (value !== props.product)
15
+ navigateToProduct(value)
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <SNavigationProducts
21
+ v-model="selectedProduct"
22
+ :products="enabledProducts"
23
+ :collapsed="collapsed"
24
+ />
25
+ </template>
@@ -1,10 +1,15 @@
1
1
  export function useApiClient() {
2
- const { BACKEND_BASE_URL, ENV_CSRF_COOKIE_NAME, PLATFORM_URL } = useRuntimeConfig().public
2
+ const { BACKEND_BASE_URL, CSRF_COOKIE_NAME, PLATFORM_URL, ENVIRONMENT } = useRuntimeConfig().public
3
+
4
+
5
+ const baseCsrfName = (CSRF_COOKIE_NAME as string | undefined) || 'smt_csrf'
6
+ const environment = (ENVIRONMENT as string | undefined) || 'local'
7
+ const csrfCookieName = environment === 'prod' ? baseCsrfName : `${baseCsrfName}_${environment}`
3
8
 
4
9
  return createApiClient({
5
10
  baseURL: BACKEND_BASE_URL as string,
6
11
  csrf: {
7
- cookieName: (ENV_CSRF_COOKIE_NAME as string) || 'smt_csrf',
12
+ cookieName: csrfCookieName
8
13
  },
9
14
  auth: {
10
15
  refreshBaseURL: BACKEND_BASE_URL as string,
@@ -16,7 +21,7 @@ export function useApiClient() {
16
21
  },
17
22
  },
18
23
  onRefreshFailed: () => {
19
- if (import.meta.client) {
24
+ if (import.meta.client && !window.location.pathname.startsWith('/auth/login')) {
20
25
  window.location.assign(`${PLATFORM_URL}/auth/login`)
21
26
  }
22
27
  },
@@ -0,0 +1,70 @@
1
+ import type { SuiteProduct } from 'nuxt-ui-layer/types'
2
+ import { computed } from 'vue'
3
+
4
+ export function useProductSwitcher() {
5
+ const { data: permissions, status } = usePermissionsQuery()
6
+ const config = useRuntimeConfig().public
7
+
8
+ const productUrls: Record<string, string> = {
9
+ config: config.PLATFORM_APP_URL,
10
+ pricing: config.PRICING_APP_URL,
11
+ connect: config.CONNECT_APP_URL,
12
+ chat: config.CHAT_APP_URL,
13
+ pms: config.PMS_APP_URL,
14
+ }
15
+
16
+ /** All enabled product keys (from /permission + always-on "config"). */
17
+ const enabledProducts = computed<SuiteProduct[]>(() => {
18
+ // "config" is a new SuiteProduct value added in smartness-nuxt-ui — cast
19
+ // needed until the updated nuxt-ui-layer package is published.
20
+ const enabled: SuiteProduct[] = ['config' as SuiteProduct]
21
+
22
+ if (!permissions.value)
23
+ return enabled
24
+
25
+ const products = permissions.value.products
26
+ const mapping: Record<string, SuiteProduct> = {
27
+ pricing: 'pricing',
28
+ connect: 'connect',
29
+ chat: 'chat',
30
+ smartpms: 'pms',
31
+ }
32
+
33
+ for (const [key, product] of Object.entries(products)) {
34
+ if (product.enabled) {
35
+ const mapped = mapping[key]
36
+ if (mapped)
37
+ enabled.push(mapped)
38
+ }
39
+ }
40
+
41
+ return enabled
42
+ })
43
+
44
+ const isLoading = computed(() => status.value === 'pending')
45
+
46
+ function navigateToProduct(product?: SuiteProduct) {
47
+ if (!product) return
48
+
49
+ const isEnabled = enabledProducts.value.includes(product)
50
+ if (!isEnabled) {
51
+ window.open(`${window.location.origin}/upgrade`, '_blank', 'noopener,noreferrer')
52
+ return
53
+ }
54
+
55
+ const url = productUrls[product]
56
+ if (url)
57
+ location.assign(url)
58
+ }
59
+
60
+ function getProductUrl(product: SuiteProduct): string | undefined {
61
+ return productUrls[product] || undefined
62
+ }
63
+
64
+ return {
65
+ enabledProducts,
66
+ isLoading,
67
+ navigateToProduct,
68
+ getProductUrl,
69
+ }
70
+ }
@@ -0,0 +1,10 @@
1
+ import type { MfaSendEmailCodeRequest, MfaSendEmailCodeResponse } from '@package/platform-shared'
2
+
3
+ export function useMfaSendEmailCodeMutation() {
4
+ const client = useApiClient()
5
+
6
+ return useMutation({
7
+ mutation: (body: MfaSendEmailCodeRequest) =>
8
+ client<MfaSendEmailCodeResponse>('/api/auth/mfa/send-email-code', { method: 'POST', body }),
9
+ })
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { MfaVerifyEmailRequest, MfaVerifyEmailResponse } from '@package/platform-shared'
2
+
3
+ export function useMfaVerifyEmailMutation() {
4
+ const client = useApiClient()
5
+
6
+ return useMutation({
7
+ mutation: (body: MfaVerifyEmailRequest) =>
8
+ client<MfaVerifyEmailResponse>('/api/auth/mfa/verify-email', { method: 'POST', body }),
9
+ })
10
+ }
package/nuxt.config.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { dirname, join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { fileURLToPath } from 'node:url'
3
4
 
4
5
  const currentDir = dirname(fileURLToPath(import.meta.url))
6
+ const sharedDir = join(currentDir, '_shared')
5
7
 
6
8
  export default defineNuxtConfig({
7
9
  extends: ['nuxt-ui-layer'],
8
10
 
9
11
  alias: {
10
- '@package/platform-shared': join(currentDir, '_shared'),
12
+ // _shared exists only in published tarball (created by prepack script)
13
+ ...(existsSync(sharedDir) && { '@package/platform-shared': sharedDir }),
11
14
  },
12
15
 
13
16
  modules: [
@@ -43,8 +46,15 @@ export default defineNuxtConfig({
43
46
  runtimeConfig: {
44
47
  public: {
45
48
  BACKEND_BASE_URL: '',
46
- ENV_CSRF_COOKIE_NAME: '',
49
+ CSRF_COOKIE_NAME: '',
47
50
  PLATFORM_URL: '',
51
+ ENVIRONMENT: '',
52
+ // Standard product URL convention: {PRODUCT}_APP_URL
53
+ PLATFORM_APP_URL: '',
54
+ PRICING_APP_URL: '',
55
+ CONNECT_APP_URL: '',
56
+ CHAT_APP_URL: '',
57
+ PMS_APP_URL: '',
48
58
  },
49
59
  },
50
60
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/platform-layer",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./nuxt.config.ts",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "nuxt": "^4.4.2",
22
- "nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.6.3",
22
+ "nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.6.19-platform.0",
23
23
  "vue": "latest"
24
24
  },
25
25
  "peerDependencies": {
@@ -29,6 +29,6 @@
29
29
  "dev": "nuxi dev .playground",
30
30
  "dev:prepare": "nuxt prepare .playground",
31
31
  "build": "nuxt build .playground",
32
- "postinstall": "nuxt prepare"
32
+ "postinstall": "nuxt prepare .playground"
33
33
  }
34
34
  }