Skip to content

Branding & White-Labeling ​

Overview ​

  1. Three branding composables — useBrand, useBrandingHead, and useBrandingTheme — handle brand resolution, document head injection, and Vuetify theme application respectively.
  2. useBrand reads damStore.brandingDetails and produces a render-ready Brand computed that components and layouts consume. It distinguishes Collage-domain requests from white-label host requests.
  3. SSR plugin (branding.server.ts) resolves branding before the initial HTML render via three sequential strategies: share page API, workspace ID API, and host-based check-branding.
  4. Client plugin (branding.client.ts) fires a fallback check-branding call for non-workspace, non-share routes where the SSR plugin could not resolve.
  5. usePortalBrandingTab composable drives the portal branding editor UI, handling logo/favicon upload, color pickers, domain configuration, and portal visibility.
  6. PortalBrandingTab.vue renders the two-column branding settings form inside the portal detail page; it is the primary UI for customizing a portal's visual identity.

Architecture ​

Branding resolves at two moments. On the server, branding.server.ts runs before rendering and tries three strategies in order, calling damStore.setBrandingDetails on the first successful response. This seeds brandingDetails in the Pinia store so useBrand produces the correct Brand object during SSR, preventing a favicon or title flash on initial page load. On the client, useBrandingTheme watches the resolved brand and writes hex color values directly into Vuetify's live light theme, and useBrandingHead drives useHead with title, OG meta, and favicon derived from the brand.

Portal-level branding is separate from the host/workspace branding. It lives in PortalBrandingTab.vue and usePortalBrandingTab.ts and controls the specific visual settings of a brand portal instance (logo, favicon, colors, domain, visibility).

File Structure ​

Branding Composables ​

  • app/composables/branding/useBrand.ts — Resolves damStore.brandingDetails into a Brand computed; handles three cases: unknown white-label host (blank shell), Collage domain (defaults), and white-label enabled (mapped fields)
  • app/composables/branding/useBrandingHead.ts — Drives useHead with tagPriority: 'high'; sets title, OG/Twitter meta, and favicon from the resolved brand; suppresses host-brand assets on /shared-assets routes unless the share is branded
  • app/composables/branding/useBrandingTheme.ts — Watches brand.is_branded and damStore.brandingDetails?.branding; writes validated hex primary_color / secondary_color into Vuetify's live light theme colors

Portal Branding Composable ​

  • app/composables/core/components/usePortalBrandingTab.ts — Full portal branding composable; manages form state (brand_name, description, brandUrl, domainUrl, primary_color, secondary_color, is_domain, is_pointed, is_public_portal), logo/favicon upload, color updates, portal visibility, and trending/recent upload toggles

Vue Component Files ​

  • app/components/dam/portals/PortalBrandingTab.vue — Two-column branding editor rendered on the portal detail page; left column: portal metadata and URL/domain config; right column: logo, favicon, and brand color pickers
  • app/pages/[workspace_id]/dam/portals/[id].vue — Portal detail page; hosts the Customize tab (which renders PortalBrandingTab), Users tab, and Content Blocks tab

Plugin Files ​

  • app/plugins/branding.server.ts — SSR-only; resolves branding via three strategies before initial HTML render
  • app/plugins/branding.client.ts — Client-only fallback; calls damStore.fetchCheckBranding() for non-workspace, non-share routes where SSR did not resolve

Store ​

  • app/stores/dam.ts — Holds brandingDetails: BrandingDetails | null; exposes setBrandingDetails, fetchWorkspaceBranding, and fetchCheckBranding actions

Type Files ​

  • app/types/store.ts — Brand, BrandingDetails, BrandingColors interfaces
  • app/types/portal.ts — PortalBranding, PortalInstance, BrandForm, BrandingUpdatePayload, SubscriptionFeatures (includes custom_brand_url and dam_branding feature flags)

useBrand ​

File: app/composables/branding/useBrand.ts

Resolves damStore.brandingDetails into a render-ready Brand computed. Returns a brandingReady flag that is true once brandingDetails is non-null.

Features ​

  • Host-origin detection via X-Forwarded-Host / X-Forwarded-Proto headers on SSR, window.location.origin on client
  • Three resolution modes: unknown white-label host, Collage domain, white-label enabled
  • Exports BLANK_FAVICON (transparent 1x1 SVG) used to prevent the Collage favicon from leaking onto a white-label host before branding resolves
  • Exports COLLAGE_DEFAULTS for fallback field values on blank branded shells

Returned Values ​

javascript
// Brand object shape
{
  is_branded: boolean,          // true on white-label hosts
  brand_name: string,           // workspace/portal name
  tagline: string | null,       // shown in OG description meta
  logo: string | null,          // full URL to logo asset
  favicon: string | null,       // full URL to favicon asset
  support_email: string | null, // support email for the brand
  brand_website_url: string,    // request origin on white-label; collage.inc on default
}

Usage ​

vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>

<template>
  <v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
  <span>{{ brand.brand_name || 'Collage' }}</span>
</template>

useBrandingHead ​

File: app/composables/branding/useBrandingHead.ts

Drives useHead with tagPriority: 'high' so branded title and favicon override any page-level defaults.

Features ​

  • Sets <title> to brand.brand_name (suppressed when not branded)
  • Sets og:site_name, og:title, og:description, og:image, twitter:card
  • Sets favicon and shortcut-icon link elements from brand.favicon || brand.logo
  • On /shared-assets routes: host-brand assets are suppressed unless brand.is_branded to avoid leaking the host workspace logo onto a partner's shared link
  • Falls back to BLANK_FAVICON when assets are suppressed

Usage ​

vue
<!-- Call once from the main layout setup — not in individual pages -->
<script setup lang="ts">
useBrandingHead()
</script>

useBrandingTheme ​

File: app/composables/branding/useBrandingTheme.ts

Applies white-label primary and secondary colors to Vuetify's light theme at runtime.

Features ​

  • Watches [brand.is_branded, damStore.brandingDetails?.branding] as a tuple; only runs on reference change (store replaces brandingDetails wholesale, never mutates in place)
  • Validates hex values with /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/ before writing to theme.themes.value.light.colors
  • Leaves static defaults intact when a color is missing or invalid

Usage ​

vue
<!-- Call once from the main layout setup -->
<script setup lang="ts">
useBrandingTheme()
</script>

usePortalBrandingTab ​

File: app/composables/core/components/usePortalBrandingTab.ts

Full page composable for the PortalBrandingTab component. Manages all reactive state and async operations for portal branding configuration.

Features ​

  • Form model: brand_name, description, brandUrl (slug), domainUrl, is_domain (0|1), is_pointed, is_trading, is_recent_upload, is_public_portal, primary_color, secondary_color
  • isDirty computed compares text fields only (not colors, not files)
  • colorsDirty separate dirty check for color changes
  • Logo validation: max 10MB, jpg/png only; favicon validation: max 2MB, png/ico/svg
  • onLogoPick / onFaviconPick read file to data URL then open a crop dialog
  • uploadAsset('logo' | 'favicon', file) builds multipart FormData and calls updateInstance
  • submit() validates, builds FormData (without logo/favicon), calls updateInstance
  • updateBranding(isBrandingDefault) posts update-workspace-branding with colors + is_branding: 0|1
  • onPortalVisibilityChange posts change-portal-visibility

Amplitude Events ​

javascript
PORTAL_LOGO_UPDATED
PORTAL_FAVICON_UPDATED
PORTAL_SETTINGS_UPDATED
PORTAL_THEME_UPDATED
PORTAL_BRANDING_UPDATED
PORTAL_VISIBILITY_CHANGED

PortalBrandingTab Component ​

File: app/components/dam/portals/PortalBrandingTab.vue

Two-column layout component rendered inside the portal detail page's Customize tab.

Features ​

  • Left column: portal name, meta description, URL mode radio (Collage subdomain vs own domain), slug/domain text field with preview link, Submit/Cancel, display toggles (Trending, Recently Added, Public Portal)
  • Right column: logo upload (pick → crop → immediate upload to S3), favicon upload (same flow), brand color pickers via v-color-picker, Restore To Default checkbox and Update button
  • Default colors: #070E22 primary, #ffffff secondary
  • White-label hosts: when isBranded prop is true, forces is_domain = 1 and hides the Collage subdomain radio

Props ​

javascript
{
  instance: {
    type: Object,        // PortalInstance — portal data
    required: true
  },
  contentLoading: {
    type: Boolean,
    default: false
  },
  customBrandAllowed: {
    type: Boolean,       // subscription gate for custom domain
    default: false
  },
  isBranded: {
    type: Boolean,       // true on white-label hosts
    default: false
  }
}

Events ​

javascript
{
  'updated': (instance: PortalInstance) => {}  // after successful save
}

Usage ​

PortalBrandingTab is rendered inside the Customize tab of the portal detail page. All props come from usePortalDetail() — the portal detail page composable — not from local refs. onInstanceUpdated merges the API echo back via spread (immutable update); if the response shape is unexpected it re-fetches the full instance.

vue
<!-- app/pages/[workspace_id]/dam/portals/[id].vue (Customize tab) -->
<script setup lang="ts">
import type { PortalInstance } from '~/types/portal'

const {
  contentLoading,
  tab,
  instance,
  tabLabels,
  customBrandAllowed,
  whiteLabelEnabled,
  fetchInstance,
} = usePortalDetail()

const onInstanceUpdated = (data: unknown): void => {
  if (data && typeof data === 'object') {
    instance.value = { ...(instance.value || {}), ...(data as PortalInstance) }
  } else {
    fetchInstance()
  }
}
</script>

<template>
  <v-window v-model="tab">
    <v-window-item v-for="(label, index) in tabLabels" :key="index" :value="index">
      <PortalBrandingTab
        v-if="label === 'Customize'"
        :instance="instance"
        :content-loading="contentLoading"
        :custom-brand-allowed="customBrandAllowed"
        :is-branded="whiteLabelEnabled"
        @updated="onInstanceUpdated"
      />
    </v-window-item>
  </v-window>
</template>

Component Integration ​

Branding hooks are wired once at the layout level. Individual pages and components consume useBrand() to read the resolved brand — they never call the Pinia store or the branding API directly.

Layout-level setup ​

useBrandingHead() and useBrandingTheme() are called once from the root app or layout. They have no arguments and return nothing — they install watchers internally.

vue
<!-- app/app.vue or the primary layout -->
<script setup lang="ts">
useBrandingHead()    // sets <title>, og:*, and favicon from the resolved brand
useBrandingTheme()  // writes primary_color / secondary_color into Vuetify's light theme
</script>

Consuming the resolved brand in a component ​

vue
<script setup lang="ts">
const { brand, brandingReady } = useBrand()
</script>

<template>
  <v-img v-if="brandingReady && brand.logo" :src="brand.logo" />
  <span>{{ brand.brand_name }}</span>
</template>

brandingReady is true once damStore.brandingDetails is non-null. On SSR it is true immediately if the server plugin resolved branding before rendering. On the client it becomes true after the store hydrates from the SSR payload.

Portal branding editor ​

See PortalBrandingTab — Usage above for the real portal-page integration pattern. The key point: props come from usePortalDetail(), not from local refs constructed in the page.

Workflows ​

SSR Branding Resolution Flow ​

1. Server receives request for any route
   Plugin: branding.server.ts (SSR only)
   ↓
2. Step 1 — share page check
   If route matches /shared-assets/:type
   → POST show-share-assets with type, brand_name, status
   → If response has favicon/logo/brand_name
     → damStore.setBrandingDetails(data) → return early
   ↓
3. Step 2 — workspace route check
   If route has workspace_id param
   → POST get-branding with workspace_id
   → If response data present
     → damStore.setBrandingDetails(data) → return early
   ↓
4. Step 3 — host-based check
   Derive origin from X-Forwarded-Proto/Host headers
   → GET check-branding with Origin + X-Forwarded-Host headers
   → If response data present
     → damStore.setBrandingDetails(data)
   ↓
5. Client hydrates
   Nuxt SSR payload includes brandingDetails from store
   → useBrand() computed resolves immediately on client
   → useBrandingHead() sets title/favicon from server-resolved brand
   → useBrandingTheme() applies colors to Vuetify theme

Client Fallback Branding Flow ​

1. Page loads on client (non-workspace, non-share route)
   Plugin: branding.client.ts
   → Skips if route has workspace_id param
   → Skips if route path matches /shared-assets
   → Skips if damStore.brandingDetails?.white_label_enabled is already true
   ↓
2. Fire-and-forget fetch
   → damStore.fetchCheckBranding()
   → GET check-branding (client sends real Host header via proxy)
   → On response: damStore.brandingDetails updated reactively
   ↓
3. useBrand() recomputes
   → Cascades to useBrandingHead() and useBrandingTheme()
   → Title, favicon, and colors update without page reload

Portal Branding Update Flow ​

1. Admin navigates to portal detail page
   Route: /:workspace_id/dam/portals/:id
   Middleware: can-access-dam-settings.ts
   → Checks DAM access and portal settings capability
   ↓
2. Customize tab renders PortalBrandingTab
   → usePortalBrandingTab() hydrates form via buildForm(instance)
   ↓
3. Admin updates portal name, URL, meta description
   → isDirty computed detects changes
   → submit() validates, builds FormData, calls updateInstance (PUT)
   → On success: emits 'updated' with API response
   ↓
4. Admin uploads logo
   → onLogoPick reads file → validates (jpg/png, max 10MB)
   → Opens crop dialog for adjustment
   → uploadAsset('logo', croppedFile) builds FormData with logo + instance_id
   → PUT update-instance (multipart)
   → Preview updated from API echo
   → Amplitude: PORTAL_LOGO_UPDATED
   ↓
5. Admin updates brand colors
   → Primary/secondary color pickers via v-color-picker
   → updateBranding(false) posts update-workspace-branding
   → Amplitude: PORTAL_THEME_UPDATED
   ↓
6. Admin configures custom domain
   → Selects "Own Domain" radio (plan-gated by customBrandAllowed)
   → Enters domain URL (e.g., assets.acme.com)
   → submit() includes is_domain: 1, domainUrl in payload
   → DNS verification flow triggered separately via Account Settings

API Integration ​

Branding Endpoints ​

MethodEndpointDescription
GETcheck-brandingHost-based brand lookup (Origin header required)
POSTget-brandingWorkspace brand lookup by workspace_id
POSTshow-share-assetsShare page brand lookup
POSTupdate-workspace-brandingUpdate portal primary/secondary colors
PUTupdate-instanceUpdate portal metadata and domain settings
json
// POST get-branding
{
  "workspace_id": 42
}

// Response
{
  "data": {
    "id": 7,
    "brand_name": "Acme Brand Assets",
    "logo": "https://cdn.collage.inc/logos/acme.png",
    "favicon": "https://cdn.collage.inc/favicons/acme.ico",
    "tagline": "Official brand resources for Acme partners.",
    "support_email": "[email protected]",
    "white_label_enabled": true,
    "branding": {
      "primary_color": "#0A1F44",
      "secondary_color": "#F4A300"
    }
  }
}
json
// POST update-workspace-branding
{
  "workspace_id": 42,
  "instance_id": 7,
  "primary_color": "#0A1F44",
  "secondary_color": "#F4A300",
  "is_branding": 1
}
  • Portals — Brand portal creation and management
  • Workspace — Account settings including logo and favicon upload
  • Permissions — Brand portal settings access control
  • Subscription — Plan gates for custom_brand_url and dam_branding
  • Real-time — Pusher channels for live branding updates