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
| Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| T1 — Feature flag + config scaffolding (chunk 1) | 0.5 | — | — | 0.5 |
T2 — BrandingStore + types + localStorage cache (chunks 2, 5) | 2 | — | — | 2 |
T3 — applyBranding util + color/URL validation (chunk 3) | 1.5 | — | — | 1.5 |
T4 — <BrandLogo/> + useHead title/favicon binding (chunk 4) | 1 | — | — | 1 |
T5 — Boot wiring in app.vue behind flag (chunk 6) | 1 | — | 0.5 | 1.5 |
| T6 — De-hardcode user-facing brand literals (chunk 7) | 2 | — | 0.5 | 2.5 |
| T7 — Two-tenant proof test (chunk 8) | 0.5 | — | 0.5 | 1 |
| Grand total | 8.5 | — | 1.5 | 10 |
Confidence: medium–high. The build is high-confidence — every layer copies a verified in-repo pattern (
AppConfigStore,$customFetch,useHead, theInputPeriod.spec.tsharness). Medium overall because four open questions can move T1/T5/T6: design-QA contrast owner (OQ1),/brandingbase 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 inNEXT_THEME_AVAILIBILITY(28 prefixes); legacy routes stayblue.400until 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
| Action | File | What changes |
|---|---|---|
| modify | nuxt.config.ts | add brandingApiBaseUrl: CONFIGENVIRONMENT.env.BRANDING_API_BASE_URL under runtimeConfig.public (beside apiBaseUrl, L427) |
| modify | configs/development.json, configs/staging-alpha.json, configs/production.json | add BRANDING_API_BASE_URL + a default flag value |
| create | common/composables/useWhitelabelFlag.ts | reads branding.whitelabel off AppConfigStore.appConfig (mirrors useToggleQontakOne.ts), returns a boolean computed |
| create | common/composables/__tests__/useWhitelabelFlag.spec.ts | flag absent → false; flag true → true |
Implementation steps
- Explore — open
common/store/AppConfigStore.ts(theAppConfigbag of boolean toggles, L2-65) andcommon/composables/useToggleQontakOne.tsfor the flag-read composable shape; opennuxt.config.ts:419-478for theruntimeConfig.public+configs/*.jsonwiring. - Write failing spec (red) —
useWhitelabelFlagreturnsfalsewhen the key is missing,truewhen set. - Implement — add the config key + flag composable; keep default
false. - Verify —
pnpm test common/composables/__tests__/useWhitelabelFlag.spec.tsandpnpm type-check.
Acceptance criteria
-
useWhitelabelFlag()returnsfalseby default (no regression when unset). -
runtimeConfig.public.brandingApiBaseUrlresolves fromconfigs/*.json. - Spec covers flag absent / present.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: reuses
AppConfigStoreas 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
| Action | File | What changes |
|---|---|---|
| create | common/store/BrandingStore.ts | defineStore("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) |
| create | common/store/BrandingStore.spec.ts | resolves mock payload; status transitions; malformed payload → rejected without throw; cache read/write round-trip; malformed cache = miss |
Implementation steps
- Explore — read
common/store/AppConfigStore.ts:71-104(setup store,statusmachine,$customFetchaction, idempotency guard) andcommon/store/AppConfigStore.spec.tsfor the store-spec harness. - Write failing specs (red) — mock
useNuxtApp().$customFetch; assert resolve/reject transitions, no throw on error, and cache round-trip via alocalStoragestub. - Implement store — the
Brandinginterface (RFC §2.A),fetchBranding()(no toast on error — RFC §2.0 Patterns deviation), and cache helpers. - Go green —
pnpm test common/store/BrandingStore.spec.ts. - Quality gate —
pnpm lint && pnpm type-check.
Acceptance criteria
-
defineStore("Branding", …)exposesbranding,status,fetchBranding(). -
statustransitions idle→pending→resolved on a mock 200; error →rejectedand does not throw to the caller. -
readCache()returns the last written payload; a malformedbranding:cachevalue is treated as a miss (no throw). -
fetchBrandinguses$customFetchwith retry disabled.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | — |
| Total | 2 |
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
| Action | File | What changes |
|---|---|---|
| create | common/utils/applyBranding.ts | applyBranding(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 |
| create | common/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
- Explore — confirm
common/utils/**is auto-imported (nuxt.config.ts:18-20); readassets/styles/pixel.css:38to confirm--mp-colors-*are consumed (so a:rootoverride takes effect); readcommon/components/__tests__/InputPeriod.spec.ts:1for the// @vitest-environment nuxtharness. - 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; faviconhrefswapped;fontabsent → no font link. - Implement — the validators + DOM side-effects; write only individual property values and a stylesheet
<link href>(never raw CSS text — RFC §3). - Go green —
pnpm test common/utils/__tests__/applyBranding.spec.ts. - Quality gate —
pnpm lint && pnpm type-check.
Acceptance criteria
- Every valid
colorsentry 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.cssUrlthat are nothttpson an allowed host are rejected. - Missing
font/appleTouchIconare handled without error; analyticswhitelabel.branding_appliedfires once.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| QA | — |
| Total | 1.5 |
Assumptions: single branding-CDN origin allow-list (RFC §5 OQ3); no
v-htmlanywhere (RFC §3).
Run to verify
pnpm test common/utils/__tests__/applyBranding.spec.ts && pnpm lint && pnpm type-check
Depends on
- Task 2 (
Brandingtype + 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
| Action | File | What changes |
|---|---|---|
| create | common/components/BrandLogo.vue | props variant?: 'full'|'mark', alt?; renders branding.assets.logo + productName; <img onerror> + empty-store fallback to the default Qontak logo asset |
| create | common/components/__tests__/BrandLogo.spec.ts | // @vitest-environment nuxt; renders tenant name/logo; falls back when store empty |
| modify | app.vue (or a small useBrandHead composable) | useHead(() => ({ title: productName, link: [{ rel: 'icon', href: favicon }] })) bound to the store |
Implementation steps
- Explore — read
pages/customers/index.vue:9for theuseHead({...})shape; readcommon/components/__tests__/InputPeriod.spec.ts:10-13for the pixel-utils mock pattern. - Write failing spec (red) —
<BrandLogo/>renders tenantproductName+ logosrc; store-empty → default logo + "Qontak Chat". - Implement — component + reactive
useHead; keep logo dimensions fixed to avoid CLS (RFC §3). - Go green —
pnpm test common/components/__tests__/BrandLogo.spec.ts. - Quality gate —
pnpm 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/faviconwhen set.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | — |
| Total | 1 |
Assumptions: default logo asset already exists in
public/(fallback);useHeadis the repo's head API (nouseHeadSafein 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 → applyBranding → fetchBranding() → re-apply + re-cache; on error keep the current brand and never block boot.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | app.vue | in onMounted, if (useWhitelabelFlag().value) initBranding(); initBranding = cache-first paint then network revalidate (RFC §2.2 sequence) |
Implementation steps
- Explore — read
app.vue:203-239(onMounted, wheregetAppConfig()is called) and:84-112(next-theme watch — do not modify). - Write failing test (red) — mounted-app / mocked-fetch test: flag off → no fetch,
:rootunmutated (snapshot == baseline); flag on → cached payload applies before network, fresh re-applies; fetch rejection → no unhandled error, brand unchanged. - Implement — wire
initBrandingbehind the flag; reuse Task 2/3 pieces. - Go green + build —
pnpm testandpnpm build. - Quality gate —
pnpm lint && pnpm type-check.
Acceptance criteria
- Flag off → boot does not fetch
/branding;:rootis unmodified (snapshot identical to today). - Flag on → cached brand applied synchronously, then network payload re-applied + re-cached.
- A
/brandingfailure never blocks boot and never shows a toast.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.5 |
| Total | 1.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
| Action | File | What changes |
|---|---|---|
| modify | layouts/components/TheNavbar/TheNavbar.vue (37 literals) | user-facing brand name → store/{brandName} i18n |
| modify | layouts/components/OneNavbar/OneNavbar.vue (31) | same |
| modify | common/composables/useSidebar.ts (22) | same, where the string is user-visible |
| modify | nuxt.config.ts (L27, L50), configs/*.json META | keep defaults; title/favicon overridden at runtime by the store (Task 4) |
| modify | i18n/locales/{en,id,pt}.json | interpolate {brandName} in user-facing strings that embed "Qontak" |
Implementation steps
- Explore —
rg -n "Qontak" layouts/components/TheNavbar layouts/components/OneNavbar common/composables/useSidebar.tsto enumerate the 286-literal clusters; separate user-facing strings from internal identifiers (mixpanel keys, package ids, cookie names — do not touch). - Replace — route user-facing renders through the store/i18n
{brandName}; leave analytics/config identifiers as literals. - Verify no regression — flag off: renders "Qontak Chat" exactly as today (snapshot); flag on: renders tenant name.
- Gate —
pnpm 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 buildsucceeds; tab title reflectsproductNamewhen flag on.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: only the user-facing subset of the 286
Qontakoccurrences is in scope; clusters verified asTheNavbar.vue37 /OneNavbar.vue31 /useSidebar.ts22.
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
applyBrandingcall, 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
| Action | File | What changes |
|---|---|---|
| create | common/utils/__tests__/applyBranding.twoTenant.spec.ts | apply fixture A then reset then fixture B; assert :root color sets differ and match each fixture; optional themed MpButton snapshot per brand |
Implementation steps
- Explore — reuse the Task 3 harness + fixtures.
- Write test — two fixtures, same
applyBrandingcall, assert distinct--mp-colors-*values on:root. - Run —
pnpm test common/utils/__tests__/applyBranding.twoTenant.spec.ts. - Coverage —
pnpm coverage(config includescommon/**).
Acceptance criteria
- Same
applyBrandingcode path, two fixtures → two distinct:rootcolor sets, each equal to its fixture. - (If snapshot) a themed component renders brand-A vs brand-B differing only by color.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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 lint→pnpm type-check→pnpm test→pnpm build— and confirm green. Post-deploy: watch Datadog RUMbranding_fetch_failed< 2% andwhitelabel.branding_appliedevents 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 /brandingcontract 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
| Story | Reason |
|---|---|
GET /branding endpoint + resolver + Redis + edge allow-list | Out 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 consumption | Out of scope — separate repo (not in this workspace) / separate RFC; keep the payload contract aligned. |
Legacy hub consumption | Out of scope — bootstrap-vue + old pixel; not themeable by --mp-colors-* override (design-doc §4b). |
| hub-chat next-theme migration | Out of scope — separate track; this RFC depends on it for full component coverage but does not perform it. |
| Tenant-admin branding editor UI | Out of scope (RFC §1) — this is a consumer, not an authoring surface. |
| Automatic contrast/legibility enforcement | Deferred — v1 relies on per-tenant design-QA sign-off (RFC §5 OQ1); computed-contrast warning is a future enhancement. |