Skip to content
145 changes: 102 additions & 43 deletions scripts/brand-concierge/brand-concierge-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function getProductNamespace() {
return key ? `exl-bc-${key}` : 'exl-bc';
}

/**
* Non-localizable Brand Concierge config. Localized strings (`ui`/`text`/`arrays`) and
* `metadata.language` come per-locale from ./localization/<lang>.json and are merged in by
* loadBrandConciergeConfig(); this base holds only values that can't live in a JSON sheet
* (runtime-resolved CSS theme, session/behavior flags, namespace).
*/
const brandConciergeConfig = {
// destructured out in brand-concierge.js before forwarding to bootstrap().
stickySession: true,
Expand All @@ -42,49 +48,6 @@ const brandConciergeConfig = {
namespace: getProductNamespace(),
},

text: {
'welcome.heading': 'Not sure where to start?<br>Ask me anything about Adobe products.',
'welcome.subheading': 'Type your question or pick a suggestion below.',
'input.placeholder': 'Ask a question…',
'input.messageInput.aria': 'Message input',
'input.send.aria': 'Send message',
'input.mic.aria': 'Voice input',
'card.aria.select': 'Select example message',
'carousel.prev.aria': 'Previous cards',
'carousel.next.aria': 'Next cards',
'scroll.bottom.aria': 'Scroll to bottom',
'error.network': "I'm sorry, I'm having trouble connecting right now. Please try again in a moment.",
'error.general': "I'm sorry, something went wrong. Please try again in a moment.",
'loading.message': "Generating from Adobe's trusted resources",
'feedback.dialog.title.positive': 'Your feedback is appreciated',
'feedback.dialog.title.negative': 'Your feedback is appreciated',
'feedback.dialog.question.positive': 'What went well? Select all that apply.',
'feedback.dialog.question.negative': 'What went wrong? Select all that apply.',
'feedback.dialog.notes': 'Notes',
'feedback.dialog.submit': 'Submit',
'feedback.dialog.cancel': 'Cancel',
'feedback.dialog.notes.placeholder': 'Additional notes (optional)',
'feedback.toast.success': 'Thank you for the feedback.',
'feedback.thumbsUp.aria': 'Thumbs up',
'feedback.thumbsDown.aria': 'Thumbs down',
},

arrays: {
'welcome.examples': [
{ text: 'Where can I go to learn about AI on Experience League?' },
{ text: 'Getting started with Experience Manager' },
{ text: 'Set up an Adobe Analytics report suite' },
{ text: 'Explain Adobe Target A/B testing' },
],
'feedback.positive.options': [
'Helpful and relevant',
'Clear and easy to understand',
'Friendly and conversational tone',
'Other',
],
'feedback.negative.options': ['Not helpful or relevant', 'Confusing or unclear', 'Too formal or robotic', 'Other'],
},

// CSS variable overrides forwarded to BC. Only set values that diverge from
// BC defaults or need tuning for the compact dialog context.
// spacing and sizing for pill/suggestion buttons are intentionally set only
Expand Down Expand Up @@ -130,4 +93,100 @@ const brandConciergeConfig = {
},
};

const LOCALES_BASE_PATH = `${window.hlx.codeBasePath}/scripts/brand-concierge/localization`;

/** lang -> Promise resolving that locale's { language, ui, text, arrays } sheet (deduped). */
const localeSheetCache = {};

function fetchLocaleSheet(lang) {
if (!localeSheetCache[lang]) {
localeSheetCache[lang] = fetch(`${LOCALES_BASE_PATH}/${lang}.json`)
.then((res) => {
if (!res.ok) throw new Error(`Brand Concierge locale '${lang}' -> ${res.status}`);
return res.json();
})
.catch((err) => {
// Don't cache failures — a transient network blip shouldn't permanently break this locale.
delete localeSheetCache[lang];
throw err;
});
}
return localeSheetCache[lang];
}

/** Recursive merge: `over` wins; nested plain objects merge per-field, arrays/scalars replace. */
function deepMerge(base, over) {
if (!over) return base;
const out = { ...base };
Object.entries(over).forEach(([key, value]) => {
const baseVal = base?.[key];
const bothPlainObjects =
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
baseVal &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal);
out[key] = bothPlainObjects ? deepMerge(baseVal, value) : value;
});
return out;
}

/**
* Loads the BC config for a path language, layering the locale's localization sheet over the
* English base. English is always fetched as the fallback base, so a partial locale sheet
* degrades per-field rather than dropping keys. Falls back to English for unknown/failed locales.
* Returns `null` if the English base sheet itself can't be loaded, so the caller can skip mounting
* gracefully rather than render a widget with no copy (single-point-of-failure guard).
* @param {string} [lang] - Path language from getPathDetails().lang (e.g. 'en', 'es').
* @returns {Promise<(typeof brandConciergeConfig & { ui: object }) | null>}
*/
export async function loadBrandConciergeConfig(lang) {
const key = (lang || 'en').toLowerCase();
let en;
try {
en = await fetchLocaleSheet('en');
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[BC] English localization sheet failed to load; skipping mount', err?.message || err);
return null;
}
let locale = en;
if (key !== 'en') {
try {
locale = await fetchLocaleSheet(key);
} catch {
locale = en;
}
Comment thread
eliwangj marked this conversation as resolved.
}
Comment on lines +144 to +161

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadBrandConciergeConfig always attempts to fetch ${LOCALES_BASE_PATH}/${key}.json for any non-en path locale (line 155-160), but only en.json and es.json actually exist in this PR. Experience League already serves many other locales (de, fr, it, ja, ko, pt-br, zh-hans, zh-hant, etc., per the languagesMap in scripts.js), so on every one of those locales, every single page load will issue a fetch that is guaranteed to 404 (and since fetch failures aren't cached in localeSheetCache, this repeats indefinitely — there's no way for it to ever be short-circuited).

On top of that, the two fetches are sequential, not parallel:

en = await fetchLocaleSheet('en');       // line 148
...
locale = await fetchLocaleSheet(key);    // line 157

For every non-en/es locale this doubles the network latency on the critical path of Brand Concierge's init (activeConfig = await loadBrandConciergeConfig(activeLang) blocks widget mount), since the doomed-to-404 locale fetch is awaited only after the English fetch resolves.

Two independent, low-effort fixes:

  1. Kick off fetchLocaleSheet('en') and fetchLocaleSheet(key) concurrently (e.g. Promise.all/Promise.allSettled) instead of sequentially.
  2. Gate the locale fetch on a known-supported-locales list (mirroring the BC_DATASTREAMS keys pattern) so locales without a translation file skip the network call entirely instead of hitting the CDN with a guaranteed 404 on every pageview.

return {
...brandConciergeConfig,
ui: deepMerge(en.ui, locale.ui),
text: { ...en.text, ...locale.text },
arrays: { ...en.arrays, ...locale.arrays },
metadata: {
...brandConciergeConfig.metadata,
language: locale.language || brandConciergeConfig.metadata.language,
},
};
}

/**
* Per-locale Brand Concierge Edge datastream overrides. This is routing config, not translation,
* so it is kept out of the localization sheets and out of the config forwarded to the BC web
* client. Same IMS org as the default datastream.
*/
const BC_DATASTREAMS = {
es: '152f88b1-ef07-4afe-8a23-6c0e21c6f017',
};

/**
* @param {string} [lang] - Path language, e.g. 'en', 'es'.
* @param {string} fallback - Default datastream id used when the locale has no override.
* @returns {string}
*/
export function getBrandConciergeDatastreamId(lang, fallback) {
return BC_DATASTREAMS[(lang || 'en').toLowerCase()] ?? fallback;
}

export default brandConciergeConfig;
80 changes: 61 additions & 19 deletions scripts/brand-concierge/brand-concierge.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// eslint-disable-next-line import/no-cycle
import { getConfig } from '../scripts.js';
import { getConfig, getPathDetails } from '../scripts.js';
import { loadScript, decorateIcon } from '../lib-franklin.js';
import { openDrawer } from '../dialog/dialog.js';
import brandConciergeConfig from './brand-concierge-config.js';
import { loadBrandConciergeConfig, getBrandConciergeDatastreamId } from './brand-concierge-config.js';

// Separate alloy instance avoids conflicting with the Launch-owned window.alloy.
const ALLOY_INSTANCE_NAME = 'alloyBC';
Expand Down Expand Up @@ -51,6 +51,15 @@ const warn = (...args) => console.warn('[BC]', ...args);
// eslint-disable-next-line no-console
const error = (...args) => console.error('[BC]', ...args);

/**
* Locale-resolved config (English base + any locale overlay), loaded async from per-locale
* JSON in initBrandConcierge(). Null until loaded; consumers run only after the load resolves,
* and the observer callbacks guard against a null value defensively.
*/
let activeConfig = null;
/** Path language backing `activeConfig`; 'en' means no locale overlay is active. */
let activeLang = 'en';

let cssLinkEl = null;
let drawerHandle = null;
let inputLabelIconObserver = null;
Expand Down Expand Up @@ -87,6 +96,21 @@ function clearBrandConciergeTranscriptStorage(options = {}) {
toRemove.forEach((k) => localStorage.removeItem(k));
}

/**
* BC hardcodes English copy in its hover tooltips (e.g. the send button's "Send"), which its
* config `text` map does not cover. For non-default locales, replace the send tooltip's text
* with the localized send label. No-op for English so BC's own copy is preserved.
*/
function localizeSubmitTooltip(mount) {
if (activeLang === 'en') return;
const label = activeConfig?.text?.['input.send.aria'];
if (!label) return;
mount.querySelectorAll('.submit-button[aria-describedby]').forEach((btn) => {
const tip = document.getElementById(btn.getAttribute('aria-describedby'));
if (tip && tip.textContent !== label) tip.textContent = label;
});
}

/**
* BC renders inline SVG sparkles in several places; swap them for icons/bc-ask-sparkles.svg.
*/
Expand Down Expand Up @@ -121,6 +145,8 @@ function patchBcSparkleIcons(mount) {
img.className = 'bc-sparkle-img';
svg.replaceWith(img);
});

localizeSubmitTooltip(mount);
};

run();
Expand All @@ -130,6 +156,9 @@ function patchBcSparkleIcons(mount) {
}

function buildPanelDisclaimer() {
const copy = activeConfig?.ui?.disclaimer;
if (!copy) return null;

const disclaimer = document.createElement('p');
disclaimer.id = PANEL_DISCLAIMER_ID;
disclaimer.className = 'bc-panel-disclaimer';
Expand All @@ -138,22 +167,20 @@ function buildPanelDisclaimer() {
privacyLink.href = PRIVACY_POLICY_URL;
privacyLink.target = '_blank';
privacyLink.rel = 'noopener noreferrer';
privacyLink.textContent = 'Privacy Policy';
privacyLink.textContent = copy.privacyLabel;

const termsLink = document.createElement('a');
termsLink.href = GENERATIVE_AI_TERMS_URL;
termsLink.target = '_blank';
termsLink.rel = 'noopener noreferrer';
termsLink.textContent = 'Generative AI Terms';
termsLink.textContent = copy.termsLabel;

disclaimer.append(
document.createTextNode("Use of this beta AI chatbot is subject to Adobe's "),
document.createTextNode(copy.prefix),
privacyLink,
document.createTextNode(
". Don't share sensitive data. AI responses are not your Content, may be inaccurate, and any offers provided are non-binding. ",
),
document.createTextNode(copy.middle),
termsLink,
document.createTextNode('.'),
document.createTextNode(copy.suffix),
);

return disclaimer;
Expand All @@ -166,7 +193,8 @@ function installPanelDisclaimer(mount) {
if (!mount) return;
const inputSection = mount.querySelector('.input-section');
if (!inputSection || inputSection.querySelector(`#${PANEL_DISCLAIMER_ID}`)) return;
inputSection.append(buildPanelDisclaimer());
const disclaimer = buildPanelDisclaimer();
if (disclaimer) inputSection.append(disclaimer);
}

function watchPanelDisclaimer(mount) {
Expand Down Expand Up @@ -414,7 +442,9 @@ function handleBrandConciergeClientEvent(event) {
}

function getBootstrapOptions() {
const { stickySession = false, ...stylingConfigurations } = brandConciergeConfig;
// `ui` holds ExL chrome strings (rendered by our own code) — strip it so only BC's
// own styling/text/arrays are forwarded to the third-party web client.
const { stickySession = false, ui: _ui, ...stylingConfigurations } = activeConfig;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new datastreamId field added to resolveBrandConciergeConfig's return value (config.js, the overlay.datastreamId spread) isn't stripped here alongside stickySession/ui. On /es/ pages, activeConfig.datastreamId ('3098f7cc-...') will fall into ...stylingConfigurations and get forwarded to the third-party BC web client via getBootstrapOptions()window.adobe.concierge.bootstrap() — the same call this comment says should only carry "BC's own styling/text/arrays".

datastreamId is only meaningful to the Alloy configureWebSdk() call (line ~867), not to the BC web client's bootstrap options, so this looks like an unintentional leak of an ExL/Edge-only field into the third-party payload. Consider destructuring it out here too, e.g.:

Suggested change
const { stickySession = false, ui: _ui, ...stylingConfigurations } = activeConfig;
const { stickySession = false, ui: _ui, datastreamId: _datastreamId, ...stylingConfigurations } = activeConfig;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the JSON refactor (824b932): datastreamId no longer lives on activeConfig — it moved to a separate BC_DATASTREAMS lookup (getBrandConciergeDatastreamId) resolved only for configureWebSdk, so it can't reach getBootstrapOptions/the BC client payload. Safe to resolve.

return {
instanceName: ALLOY_INSTANCE_NAME,
stylingConfigurations,
Expand Down Expand Up @@ -699,17 +729,19 @@ function installKeyboardScrollHandler(dialog, mount) {
function createMountPoint() {
if (document.getElementById(DIALOG_ID)) return document.getElementById(DIALOG_ID);

const { ui } = activeConfig;

const trigger = document.createElement('button');
trigger.id = TRIGGER_ID;
trigger.setAttribute('aria-label', 'Open AI assistant');
trigger.setAttribute('aria-label', ui.triggerAriaLabel);
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', DIALOG_ID);

const triggerIcon = document.createElement('span');
triggerIcon.className = 'icon icon-bc-ask-sparkles';
const triggerAsk = document.createElement('span');
triggerAsk.className = 'bc-trigger-ask';
triggerAsk.textContent = 'Ask a question';
triggerAsk.textContent = ui.triggerAsk;
trigger.append(triggerIcon, triggerAsk);
const betaBadge = document.createElement('span');
betaBadge.className = 'bc-trigger-beta';
Expand All @@ -729,13 +761,13 @@ function createMountPoint() {
clearBtn.id = HEADER_CLEAR_ID;
clearBtn.type = 'button';
clearBtn.className = 'exl-dialog-header-clear';
clearBtn.textContent = 'Clear';
clearBtn.setAttribute('aria-label', 'Clear conversation');
clearBtn.textContent = ui.clearLabel;
clearBtn.setAttribute('aria-label', ui.clearAriaLabel);

drawerHandle = openDrawer({
id: DIALOG_ID,
ariaLabel: 'AI assistant',
title: 'Ask',
ariaLabel: ui.drawerAriaLabel,
title: ui.drawerTitle,
titleBadge: 'BETA',
titleIcon: 'bc-ask-sparkles',
content: mount,
Expand Down Expand Up @@ -829,13 +861,23 @@ export function destroyBrandConcierge() {
export async function initBrandConcierge() {
const { bcAlloySdkUrl, bcDatastreamId, bcOrgId, bcWebClientUrl, bcEdgeDomain } = getConfig();

activeLang = getPathDetails().lang;
activeConfig = await loadBrandConciergeConfig(activeLang);
// If even the English base sheet can't load, skip mounting rather than render an empty widget.
if (!activeConfig) return;

// Route to the locale's Brand Concierge datastream (e.g. the Spanish concierge on /es/),
// falling back to the default datastream for locales without an override. Kept separate from
// activeConfig so this routing id never leaks into the styling payload sent to the BC client.
const datastreamId = getBrandConciergeDatastreamId(activeLang, bcDatastreamId);

createMountPoint();
injectAlloyStub();

try {
log('[BC] loading Web SDK (alloyBC instance)', { bcEdgeDomain, bcDatastreamId });
log('[BC] loading Web SDK (alloyBC instance)', { bcEdgeDomain, datastreamId, locale: activeLang });
await loadScript(bcAlloySdkUrl);
await configureWebSdk(bcDatastreamId, bcOrgId, bcEdgeDomain);
await configureWebSdk(datastreamId, bcOrgId, bcEdgeDomain);
log('[BC] Web SDK configured');

log('[BC] loading Web Client', bcWebClientUrl);
Expand Down
Loading