Skip to main content

Unified Branding Service (Whitelabel) — hub-chat FE Task Breakdown

RFC: rfc-unified-branding-hub-chat-fe.md Source: rfc-unified-branding-hub-chat-fe.md (§4.C Agent Execution Plan, chunks 1–8) Repo: hub-chat (Nuxt 3/4, Vue 3, Pinia, Vitest) — cross-repo from this docs repo Producer contract: GET /branding is built by rfc-unified-branding-service-be.md (out of this repo) Slicing: vertical — one task per execution chunk; chunk 5 (localStorage cache) is folded into the store task (T2), since the cache lives in the store. FE-only; the endpoint and other consumers (crm-fe-v3, legacy hub) are out of scope (see Skipped stories). Execute in order — each task's acceptance criteria must pass before the next. All chunks build against a local mock of GET /branding until the BE endpoint ships.


Effort Summary

TaskFE daysBE daysQA daysTotal
T1 — Feature flag + config scaffolding (chunk 1)0.50.5
T2 — BrandingStore + types + localStorage cache (chunks 2, 5)22
T3 — applyBranding util + color/URL validation (chunk 3)1.51.5
T4 — <BrandLogo/> + useHead title/favicon binding (chunk 4)11
T5 — Boot wiring in app.vue behind flag (chunk 6)10.51.5
T6 — De-hardcode user-facing brand literals (chunk 7)20.52.5
T7 — Two-tenant proof test (chunk 8)0.50.51
Grand total8.51.510

Confidence: medium–high. The build is high-confidence — every layer copies a verified in-repo pattern (AppConfigStore, $customFetch, useHead, the InputPeriod.spec.ts harness). Medium overall because four open questions can move T1/T5/T6: design-QA contrast owner (OQ1), /branding base URL + credential behavior (OQ2), CSP allow-list (OQ3), and story/flag-provisioner confirmation (OQ4). None blocks starting against a mock; each has a sensible default in RFC §5. Coverage caveat carried from RFC §3: brand override only restyles routes in NEXT_THEME_AVAILIBILITY (28 prefixes); legacy routes stay blue.400 until the separate next-theme migration lands.


Task 1: [FE] Feature flag + config scaffolding (chunk 1)

A default-off switch and a resolvable branding base URL exist, so all later work can be built and merged safely dark.

Status: ✅ Actionable (flag provisioner — backend client_configs vs build config — is RFC §5 OQ4; defaults to a build-config value for stage 1)

What to build

A branding.whitelabel feature flag (default off) read through the existing AppConfigStore pattern, plus a brandingApiBaseUrl runtime-config key sourced from configs/*.json.

Implementation Plan

ActionFileWhat changes
modifynuxt.config.tsadd brandingApiBaseUrl: CONFIGENVIRONMENT.env.BRANDING_API_BASE_URL under runtimeConfig.public (beside apiBaseUrl, L427)
modifyconfigs/development.json, configs/staging-alpha.json, configs/production.jsonadd BRANDING_API_BASE_URL + a default flag value
createcommon/composables/useWhitelabelFlag.tsreads branding.whitelabel off AppConfigStore.appConfig (mirrors useToggleQontakOne.ts), returns a boolean computed
createcommon/composables/__tests__/useWhitelabelFlag.spec.tsflag absent → false; flag truetrue

Implementation steps

  1. Explore — open common/store/AppConfigStore.ts (the AppConfig bag of boolean toggles, L2-65) and common/composables/useToggleQontakOne.ts for the flag-read composable shape; open nuxt.config.ts:419-478 for the runtimeConfig.public + configs/*.json wiring.
  2. Write failing spec (red)useWhitelabelFlag returns false when the key is missing, true when set.
  3. Implement — add the config key + flag composable; keep default false.
  4. Verifypnpm test common/composables/__tests__/useWhitelabelFlag.spec.ts and pnpm type-check.

Acceptance criteria

  • useWhitelabelFlag() returns false by default (no regression when unset).
  • runtimeConfig.public.brandingApiBaseUrl resolves from configs/*.json.
  • Spec covers flag absent / present.

Effort estimate

DisciplineDays
Frontend0.5
QA
Total0.5

Assumptions: reuses AppConfigStore as the flag source (RFC Detail 4.A); no new dependency.

Run to verify

pnpm test common/composables/__tests__/useWhitelabelFlag.spec.ts && pnpm type-check

Depends on

  • None (first task).

Task 2: [FE] BrandingStore + typed payload + localStorage cache (chunks 2, 5)

The tenant's branding has a place to live in the app, fetched once with a state-machine, cached for instant paint on the next boot.

Status: ✅ Actionable (base URL + whether $customFetch may send an auth header to a public endpoint — RFC §5 OQ2)

What to build

common/store/BrandingStore.ts — a Pinia setup store (modelled on AppConfigStore) exposing branding, status, a fetchBranding() action via $customFetch, and localStorage cache read/write (branding:cache), plus the exported Branding interface matching design-doc §4.

Implementation Plan

ActionFileWhat changes
createcommon/store/BrandingStore.tsdefineStore("Branding", () => {...}); branding: Ref<Branding | null>, status: Ref<'idle'|'pending'|'resolved'|'rejected'>; fetchBranding()$customFetch(brandingPath) (retry off); readCache()/writeCache() guarding JSON.parse; boot-once idempotency guard (mirror AppConfigStore.ts:78)
createcommon/store/BrandingStore.spec.tsresolves mock payload; status transitions; malformed payload → rejected without throw; cache read/write round-trip; malformed cache = miss

Implementation steps

  1. Explore — read common/store/AppConfigStore.ts:71-104 (setup store, status machine, $customFetch action, idempotency guard) and common/store/AppConfigStore.spec.ts for the store-spec harness.
  2. Write failing specs (red) — mock useNuxtApp().$customFetch; assert resolve/reject transitions, no throw on error, and cache round-trip via a localStorage stub.
  3. Implement store — the Branding interface (RFC §2.A), fetchBranding() (no toast on error — RFC §2.0 Patterns deviation), and cache helpers.
  4. Go greenpnpm test common/store/BrandingStore.spec.ts.
  5. Quality gatepnpm lint && pnpm type-check.

Acceptance criteria

  • defineStore("Branding", …) exposes branding, status, fetchBranding().
  • status transitions idle→pending→resolved on a mock 200; error → rejected and does not throw to the caller.
  • readCache() returns the last written payload; a malformed branding:cache value is treated as a miss (no throw).
  • fetchBranding uses $customFetch with retry disabled.

Effort estimate

DisciplineDays
Frontend2
QA
Total2

Assumptions: reuses $customFetch (plugins/customFetch.ts:154); payload is non-PII, so cache needs no logout-clear (RFC §2.3).

Run to verify

pnpm test common/store/BrandingStore.spec.ts && pnpm lint && pnpm type-check

Depends on

  • Task 1 (base URL config; flag composable used by the boot task).

Task 3: [FE] applyBranding util + color/URL validation (chunk 3)

The branding payload safely becomes live DOM: --mp-colors-* on :root, a swapped favicon, and an optional tenant font — with hostile values rejected before they touch the DOM.

Status: ✅ Actionable

What to build

common/utils/applyBranding.ts (design-doc §5) — iterates colors and writes each validated value to document.documentElement.style, swaps the favicon <link>, injects the font <link> + --brand-font-family, and sets the store. Color-syntax + https-host validators gate every value (RFC §3 Security).

Implementation Plan

ActionFileWhat changes
createcommon/utils/applyBranding.tsapplyBranding(b: Branding): void; validate each colors[*] against a hex/rgb()/hsl() regex; validate assets.*/font.cssUrl are https on an allow-listed host; skip missing font/appleTouchIcon; fire whitelabel.branding_applied analytics
createcommon/utils/__tests__/applyBranding.spec.ts:root receives each valid token; invalid color rejected; favicon href updated; font link + --brand-font-family set; missing optional fields handled

Implementation steps

  1. Explore — confirm common/utils/** is auto-imported (nuxt.config.ts:18-20); read assets/styles/pixel.css:38 to confirm --mp-colors-* are consumed (so a :root override takes effect); read common/components/__tests__/InputPeriod.spec.ts:1 for the // @vitest-environment nuxt harness.
  2. Write failing specs (red) — after applyBranding(fixture), document.documentElement.style.getPropertyValue('--mp-colors-background-brand-bold') equals the fixture; an invalid color value (e.g. url(evil)) is not written; favicon href swapped; font absent → no font link.
  3. Implement — the validators + DOM side-effects; write only individual property values and a stylesheet <link href> (never raw CSS text — RFC §3).
  4. Go greenpnpm test common/utils/__tests__/applyBranding.spec.ts.
  5. Quality gatepnpm lint && pnpm type-check.

Acceptance criteria

  • Every valid colors entry is written to :root; the util iterates generically (no hard-coded token list).
  • A non-color value is rejected and never reaches setProperty.
  • assets.* / font.cssUrl that are not https on an allowed host are rejected.
  • Missing font / appleTouchIcon are handled without error; analytics whitelabel.branding_applied fires once.

Effort estimate

DisciplineDays
Frontend1.5
QA
Total1.5

Assumptions: single branding-CDN origin allow-list (RFC §5 OQ3); no v-html anywhere (RFC §3).

Run to verify

pnpm test common/utils/__tests__/applyBranding.spec.ts && pnpm lint && pnpm type-check

Depends on

  • Task 2 (Branding type + store setter).

Task 4: [FE] <BrandLogo/> + useHead title/favicon binding (chunk 4)

Logo, product name, and tab title come from the store, with a safe default that renders the current Qontak brand pre-fetch.

Status: ✅ Actionable

What to build

common/components/BrandLogo.vue reading useBrandingStore(), plus a useHead binding for title + favicon driven by the store (extending the existing per-page useHead usage).

Implementation Plan

ActionFileWhat changes
createcommon/components/BrandLogo.vueprops variant?: 'full'|'mark', alt?; renders branding.assets.logo + productName; <img onerror> + empty-store fallback to the default Qontak logo asset
createcommon/components/__tests__/BrandLogo.spec.ts// @vitest-environment nuxt; renders tenant name/logo; falls back when store empty
modifyapp.vue (or a small useBrandHead composable)useHead(() => ({ title: productName, link: [{ rel: 'icon', href: favicon }] })) bound to the store

Implementation steps

  1. Explore — read pages/customers/index.vue:9 for the useHead({...}) shape; read common/components/__tests__/InputPeriod.spec.ts:10-13 for the pixel-utils mock pattern.
  2. Write failing spec (red)<BrandLogo/> renders tenant productName + logo src; store-empty → default logo + "Qontak Chat".
  3. Implement — component + reactive useHead; keep logo dimensions fixed to avoid CLS (RFC §3).
  4. Go greenpnpm test common/components/__tests__/BrandLogo.spec.ts.
  5. Quality gatepnpm lint && pnpm type-check.

Acceptance criteria

  • <BrandLogo/> renders tenant logo + product name when the store is set.
  • With an empty store / flag off, it renders the default Qontak logo and "Qontak Chat" (no broken image).
  • Title + favicon reflect the store's productName/favicon when set.

Effort estimate

DisciplineDays
Frontend1
QA
Total1

Assumptions: default logo asset already exists in public/ (fallback); useHead is the repo's head API (no useHeadSafe in repo).

Run to verify

pnpm test common/components/__tests__/BrandLogo.spec.ts && pnpm lint && pnpm type-check

Depends on

  • Task 2 (store).

Task 5: [FE] Boot wiring in app.vue behind the flag (chunk 6)

On boot, a branded tenant paints instantly from cache then revalidates — and with the flag off, nothing changes vs today.

Status: ✅ Actionable

What to build

Flag-gated branding init in app.vue onMounted (beside getAppConfig() at L205): read cache → applyBrandingfetchBranding() → re-apply + re-cache; on error keep the current brand and never block boot.

Implementation Plan

ActionFileWhat changes
modifyapp.vuein onMounted, if (useWhitelabelFlag().value) initBranding(); initBranding = cache-first paint then network revalidate (RFC §2.2 sequence)

Implementation steps

  1. Explore — read app.vue:203-239 (onMounted, where getAppConfig() is called) and :84-112 (next-theme watch — do not modify).
  2. Write failing test (red) — mounted-app / mocked-fetch test: flag off → no fetch, :root unmutated (snapshot == baseline); flag on → cached payload applies before network, fresh re-applies; fetch rejection → no unhandled error, brand unchanged.
  3. Implement — wire initBranding behind the flag; reuse Task 2/3 pieces.
  4. Go green + buildpnpm test and pnpm build.
  5. Quality gatepnpm lint && pnpm type-check.

Acceptance criteria

  • Flag off → boot does not fetch /branding; :root is unmodified (snapshot identical to today).
  • Flag on → cached brand applied synchronously, then network payload re-applied + re-cached.
  • A /branding failure never blocks boot and never shows a toast.

Effort estimate

DisciplineDays
Frontend1
QA0.5
Total1.5

Assumptions: mirrors getAppConfig() boot-fetch placement; QA validates flag-off no-regression on a next-theme route (/inbox).

Run to verify

pnpm test && pnpm build && pnpm lint && pnpm type-check

Depends on

  • Tasks 1, 2, 3, 4 (flag, store+cache, util, component).

Task 6: [FE] De-hardcode user-facing brand literals (chunk 7)

The product name a user sees comes from the store, not from hard-coded "Qontak Chat" strings — so a branded tenant reads as itself.

Status: ✅ Actionable (scope of "user-facing" subset to confirm with PM — RFC §5 OQ4)

What to build

Route the user-facing product-name renders (the biggest literal clusters) through the store/i18n interpolation, and make nuxt.config.ts head + configs/*.json META default-only (still "Qontak Chat" when flag off).

Implementation Plan

ActionFileWhat changes
modifylayouts/components/TheNavbar/TheNavbar.vue (37 literals)user-facing brand name → store/{brandName} i18n
modifylayouts/components/OneNavbar/OneNavbar.vue (31)same
modifycommon/composables/useSidebar.ts (22)same, where the string is user-visible
modifynuxt.config.ts (L27, L50), configs/*.json METAkeep defaults; title/favicon overridden at runtime by the store (Task 4)
modifyi18n/locales/{en,id,pt}.jsoninterpolate {brandName} in user-facing strings that embed "Qontak"

Implementation steps

  1. Explorerg -n "Qontak" layouts/components/TheNavbar layouts/components/OneNavbar common/composables/useSidebar.ts to enumerate the 286-literal clusters; separate user-facing strings from internal identifiers (mixpanel keys, package ids, cookie names — do not touch).
  2. Replace — route user-facing renders through the store/i18n {brandName}; leave analytics/config identifiers as literals.
  3. Verify no regression — flag off: renders "Qontak Chat" exactly as today (snapshot); flag on: renders tenant name.
  4. Gatepnpm lint && pnpm build && pnpm test.

Acceptance criteria

  • User-facing product-name renders in navbar/sidebar come from the store/i18n, not string literals, when the flag is on.
  • Flag off → identical "Qontak Chat" output (snapshot unchanged).
  • Internal identifiers (mixpanel tokens, package ids, cookie/domain names) are not altered.
  • pnpm build succeeds; tab title reflects productName when flag on.

Effort estimate

DisciplineDays
Frontend2
QA0.5
Total2.5

Assumptions: only the user-facing subset of the 286 Qontak occurrences is in scope; clusters verified as TheNavbar.vue 37 / OneNavbar.vue 31 / useSidebar.ts 22.

Run to verify

pnpm test && pnpm lint && pnpm build

Depends on

  • Tasks 2, 4 (store + <BrandLogo/>/head binding supply the runtime name).

Task 7: [FE] Two-tenant proof test (chunk 8)

The design doc's core claim is proven in code: the same applyBranding call, two payloads, two brands — identical code path.

Status: ✅ Actionable

What to build

A test applying two fixtures (purple #7A2FF2 / green #0E9F6E) and asserting two distinct :root color sets via one code path, plus a themed-component snapshot differing only by brand color (design-doc §6).

Implementation Plan

ActionFileWhat changes
createcommon/utils/__tests__/applyBranding.twoTenant.spec.tsapply fixture A then reset then fixture B; assert :root color sets differ and match each fixture; optional themed MpButton snapshot per brand

Implementation steps

  1. Explore — reuse the Task 3 harness + fixtures.
  2. Write test — two fixtures, same applyBranding call, assert distinct --mp-colors-* values on :root.
  3. Runpnpm test common/utils/__tests__/applyBranding.twoTenant.spec.ts.
  4. Coveragepnpm coverage (config includes common/**).

Acceptance criteria

  • Same applyBranding code path, two fixtures → two distinct :root color sets, each equal to its fixture.
  • (If snapshot) a themed component renders brand-A vs brand-B differing only by color.

Effort estimate

DisciplineDays
Frontend0.5
QA0.5
Total1

Assumptions: no visual-regression harness in repo (verified — no Playwright/Chromatic script); component snapshot substitutes (RFC Detail 4.B).

Run to verify

pnpm test common/utils/__tests__/applyBranding.twoTenant.spec.ts && pnpm coverage

Depends on

  • Task 3 (util + fixtures).

Final gate (RFC §4.D), after Tasks 1–7: run the pre-merge sequence in order — pnpm lintpnpm type-checkpnpm testpnpm build — and confirm green. Post-deploy: watch Datadog RUM branding_fetch_failed < 2% and whitelabel.branding_applied events on the pilot tenant.


Ordering rationale

  • Dark-launch first: T1 (flag + config) lands before anything user-visible, so every later task merges behind a default-off flag with zero production impact.
  • Data-up dependency chain: store+cache (T2) → apply util (T3) → component/head (T4) → boot wiring (T5). T5 is where cache-first paint + revalidate is orchestrated, so it needs T2–T4 complete.
  • T2 is the highest-value task — it owns the fetch, state-machine, and cache that everything else consumes; front-load review here.
  • T6 (literal de-hardcoding) is the riskiest — it touches high-traffic navbar/sidebar components (286 literals); keep it behind the flag and snapshot-test flag-off no-regression. It can start once T2/T4 land but should merge after T5 so the runtime name source exists.
  • T7 (proof test) is cheap and independent once T3 exists; it validates the design doc's central claim and can run in parallel with T5/T6.
  • Push externally in parallel: freeze the GET /branding contract with the BE RFC team (RFC §5 OQ2), name the design-QA contrast owner (OQ1), and settle the CSP allow-list (OQ3) — none blocks building against a mock, but all three gate the pilot-tenant stage.

Skipped stories

StoryReason
GET /branding endpoint + resolver + Redis + edge allow-listOut of repo — built by rfc-unified-branding-service-be.md (qontak.com/hub_core). This FE work consumes it and builds against a mock until it ships.
crm-fe-v3 consumptionOut of scope — separate repo (not in this workspace) / separate RFC; keep the payload contract aligned.
Legacy hub consumptionOut of scope — bootstrap-vue + old pixel; not themeable by --mp-colors-* override (design-doc §4b).
hub-chat next-theme migrationOut of scope — separate track; this RFC depends on it for full component coverage but does not perform it.
Tenant-admin branding editor UIOut of scope (RFC §1) — this is a consumer, not an authoring surface.
Automatic contrast/legibility enforcementDeferred — v1 relies on per-tenant design-QA sign-off (RFC §5 OQ1); computed-contrast warning is a future enhancement.