Appearance
Utils Documentation ​
Overview ​
This section documents the utility modules in the Brand Portal. Utilities are located in app/utils/ and provide shared, framework-agnostic helpers used across composables, plugins, and components.
File Information ​
- Path:
app/utils/ - Purpose: Centralized utility functions for error handling, request lifecycle management, route id and post-login redirect validation, custom field values and Amplitude analytics
- Auto-import: The
nuxt.configincludesutils/**inimports.dirs, so these utilities are auto-imported where used.
Utils Structure ​
app/utils/
├── amplitudeSessionReplay.ts # Amplitude session replay init, masking, shutdown
├── analyticsIds.ts # Id property normalization for Amplitude events
├── cancelTokenManager.ts # Request cancellation and throttling
├── customFieldValues.ts # Custom field value parsing and formatting
├── error.ts # API error message extraction
├── redirect.ts # Post-login ?redirect= validation
└── resourceId.ts # Route id validation for detail pagesModules ​
error.ts ​
Path: app/utils/error.ts
Purpose: Centralized API error message extraction for consistent user-facing error display. Ensures that API responses (e.g. {"status":false,"code":402,"message":"Folder not found."}) are surfaced as the actual backend message in snackbars and UI instead of generic text.
Exports:
getApiErrorMessage(error: unknown): string
Behavior:
- Handles
$fetch/ofetch: readserror.data?.message - Handles Nuxt response: reads
error.response?._data?.message - Handles Axios-style: reads
error.response?.data?.message - Falls back to
error.messageor string coercion - Returns
"An error occurred"when no message is available
Usage:
Used by the Vue Query plugin for global query and mutation error handling (snackbar display), and by composables that show API errors to the user.
typescript
import { getApiErrorMessage } from '~/utils/error'
try {
await someApiCall()
} catch (e) {
useSnackbar().showError(getApiErrorMessage(e))
}Related:
- Plugins - Vue Query - Uses this for global error handling
- Composables - useSnackbar - Displays extracted messages
cancelTokenManager.ts ​
Path: app/utils/cancelTokenManager.ts
Purpose: Manages AbortController instances for request cancellation and throttling. Allows the application to cancel in-flight requests (e.g. on route change or search debounce) and to clear both cancel and throttle controllers in a single operation.
Exports:
addCancelController(controller: AbortController): voidremoveCancelController(controller: AbortController): voidaddThrottleController(controller: AbortController): voidremoveThrottleController(controller: AbortController): voidcancelAllRequests(): void— Aborts all registered controllers and clears both sets
Usage:
Typically used by the API client or composables that perform fetch requests with an AbortSignal, so that navigation or user actions can cancel pending requests.
Related:
- Plugins - API Client - Request lifecycle and cancellation
- Composables API - API calls that may use cancellation
resourceId.ts ​
Path: app/utils/resourceId.ts (added in ee26b58)
Purpose: Rejects malformed :id route params before the folder and collage detail pages call the API.
Exports:
isValidResourceId(raw: unknown): boolean
Behavior:
- If
rawis an array (Vue Router repeated param), onlyraw[0]is checked. - Returns
trueonly for a string matching/^[1-9]\d*$/: a positive integer without leading zeros, sign, spaces or decimals.
| Input | Result |
|---|---|
"42" | true |
["42", "7"] | true (first element) |
"0", "042", "-1", "4.2", "abc", "" | false |
42 (number), undefined | false |
Usage:
typescript
import { isValidResourceId } from "~/utils/resourceId";
onMounted(async () => {
if (!isValidResourceId(route.params.id)) {
snackbar.showError("Folder not found.");
await navigateTo(`/${getBrandName()}/folders`, { replace: true });
return;
}
// ...load folder
});Related:
redirect.ts ​
Path: app/utils/redirect.ts
Purpose: Validates a ?redirect= query value before the app sends a just-authenticated visitor there, so a deep link survives login without opening an open-redirect or a login loop.
Exports:
resolveBrandRedirect(redirect: string | string[] | null | undefined, brandUrlSlug: string, router: Router): string | null
Callers: app/pages/[brand_name]/login.vue (manual login), app/middleware/public-portal-login-redirect.ts (public-portal auto-login) and app/middleware/redirect-if-logged-in.ts. Each falls back to `/${brand}` when the function returns null.
Rules (checked in this order; the first failure returns null):
| # | Rule | Rejects |
|---|---|---|
| 1 | Value present; for an array only redirect[0] is used; brandUrlSlug non-empty | undefined, "" |
| 2 | After trim(), starts with / and not // or /\ | https://evil.com, //evil.com, /\evil.com |
| 3 | No whitespace and no control characters (code <= 0x1f or 0x7f) | /acme/files 1, /acme%00 is allowed (encoded), a raw \u0000 is not |
| 4 | Pathname (text before ? or #) equals /<brand> or starts with /<brand>/ | /other-brand/files/1 |
| 5 | First segment after the brand is not an auth route: login, forgot-password, reset-password, generate-password | /acme/login?redirect=... |
| 6 | router.resolve(pathname).name is set and is not slug, brand_name-slug or brand_name-token; a throw from resolve counts as a failure | /acme/does-not-exist, /acme/abc123 |
On success the original trimmed value is returned, including its query string and hash.
Why rule 6 uses the route name: every path matches something in this router (the [...slug] catch-alls and the bare [brand_name]/[token] route), so matched.length proves nothing. brand_name-token is also excluded because it serves hex token-login links and check-url clears the session on those; redirecting into it after login would log the visitor straight back out. This is a routing check only: /acme/collage/999999 passes even if that record does not exist.
typescript
// login.vue
await navigateTo(
resolveBrandRedirect(route.query.redirect, brandName, router) || `/${brandName}`
);customFieldValues.ts ​
Path: app/utils/customFieldValues.ts
Purpose: Normalizes the many shapes a custom field value arrives in (array, JSON-encoded array string, plain string, number, option objects) into a list of display strings.
Exports:
toCustomFieldValues(raw: unknown): string[]formatCustomFieldValues(raw: unknown): string-toCustomFieldValues(raw).join(", ")
Rules:
- Array: each entry is converted, empty results are dropped.
- String: trimmed; empty gives
[]. If it starts with[andJSON.parseyields an array, the decoded entries are converted; otherwise (bad JSON or not an array) the whole trimmed string is one value. - Number:
[String(raw)]. Anything else (null, objects, booleans):[]. - Entry conversion: strings are trimmed, numbers stringified, objects with
valueortextusevalue ?? text(trimmed); anything else becomes""and is dropped.
| Input | toCustomFieldValues |
|---|---|
'["Red","Blue"]' | ["Red", "Blue"] |
[{ text: "Red", value: "red" }, " Blue "] | ["red", "Blue"] |
"[not json" | ["[not json"] |
" ", null, {} | [] |
42 | ["42"] |
Callers: useAssetDetails (asset detail custom fields), useListLevelThree (search list custom field filter options), useHelpers.
analyticsIds.ts ​
Path: app/utils/analyticsIds.ts
Purpose: Normalizes id-type event properties before they are sent to Amplitude, so the same property always has the same type across events.
Exports: ID_KEYS, MULTI_KEYS, COUNT_KEYS, SINGLE_VALUED, isSingleValued(eventType, key), normalizeIdProperties(properties, eventType = null).
Key sets:
| Constant | Values |
|---|---|
ID_KEYS | asset_id, category_id, collection_id, version_id, share_id, instance_id, banner_id, tile_id, collage_id, tag_id, workspace_id, user_id, dealer_user_id |
MULTI_KEYS | asset_id, category_id, share_id |
COUNT_KEYS | asset_id -> asset_count, category_id -> category_count, share_id -> share_count |
SINGLE_VALUED | asset viewed: asset_id, category_id; asset converted: asset_id; folder viewed: category_id; collage viewed: collection_id |
normalizeIdProperties rules:
null,undefinedor a non-object input returns{}.- Keys outside
ID_KEYS, and id keys whose value isnull/undefined, pass through unchanged. - Single-id keys (in
ID_KEYS, not inMULTI_KEYS) becomeString(value). - Multi keys: the value is wrapped in an array if needed and
null/undefinedentries are removed; if nothing is left the key is dropped. One id is sent as a string, several as a string array. - A count property (
asset_countetc.) is added with the number of ids unless the caller already set it, or there is exactly one id and the event/key pair is inSINGLE_VALUED.
typescript
normalizeIdProperties({ asset_id: [101, 102], workspace_id: 7 })
// { asset_id: ["101", "102"], asset_count: 2, workspace_id: "7" }
normalizeIdProperties({ asset_id: 101 }, "asset viewed")
// { asset_id: "101" } (no asset_count: single-valued event)Callers: useAmplitude (on picked properties) and plugins/amplitude.client.ts (on every event's event_properties).
amplitudeSessionReplay.ts ​
Path: app/utils/amplitudeSessionReplay.ts
Purpose: Wraps @amplitude/session-replay-browser with the portal's sample rate parsing, privacy masking and an idempotent init/shutdown.
Exports:
parseSessionReplaySampleRate(raw): number- empty/null/undefined/NaNgives the default0.1; otherwise the number clamped to0..1.getSessionReplayPrivacyConfig(): SessionReplayPrivacyConfiginitSessionReplay({ apiKey, sampleRate, deviceId, sessionId }): voidsetSessionReplaySessionId(sessionId): void- ignoresnull; swallows the error when replay is not initialized.shutdownSessionReplay(): Promise<void>- flushes (only if initialized), callsshutdown, always resets the initialized flag; best-effort, never throws.
Init guards (any one returns without initializing): already initialized, running on the server, empty apiKey, sampleRate === 0, missing deviceId, or sessionId null/undefined. The module-level flag is set only after the SDK's init promise resolves.
Options passed to the SDK: serverZone: 'US', storeType: 'idb', useWebWorker: true, performanceConfig: { enabled: true, timeout: 2000 }, optOut: false, debugMode: false, applyBackgroundColorToBlockedElements: false.
Privacy config:
| Setting | Value |
|---|---|
defaultMaskLevel | medium |
maskSelector | password and email inputs; cc-number, cc-csc, cc-exp autocomplete inputs; inputs whose name contains password, credit, card, cvv, ssn; textarea[data-sensitive="true"]; .amp-mask, [data-amp-mask] |
blockSelector | .amp-block, [data-amp-block], [data-no-replay] |
unmaskSelector | .amp-unmask, [data-amp-unmask] |
To keep an element out of recordings entirely add data-no-replay; to mask text add data-amp-mask.
Wiring: plugins/amplitude.client.ts reads the sample rate from runtimeConfig.public.amplitudeSessionReplaySampleRate and, because Amplitude's device and session ids are not available immediately, retries initSessionReplay up to REPLAY_READY_MAX_ATTEMPTS (10) times, REPLAY_READY_DELAY_MS (100 ms) apart.