diff --git a/scripts/brand-concierge/brand-concierge-config.js b/scripts/brand-concierge/brand-concierge-config.js index 1c837a746..2a7b2764b 100644 --- a/scripts/brand-concierge/brand-concierge-config.js +++ b/scripts/brand-concierge/brand-concierge-config.js @@ -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/.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, @@ -42,49 +48,6 @@ const brandConciergeConfig = { namespace: getProductNamespace(), }, - text: { - 'welcome.heading': 'Not sure where to start?
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 @@ -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; + } + } + 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; diff --git a/scripts/brand-concierge/brand-concierge.js b/scripts/brand-concierge/brand-concierge.js index 06c95b9e1..e6252fe51 100644 --- a/scripts/brand-concierge/brand-concierge.js +++ b/scripts/brand-concierge/brand-concierge.js @@ -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'; @@ -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; @@ -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. */ @@ -121,6 +145,8 @@ function patchBcSparkleIcons(mount) { img.className = 'bc-sparkle-img'; svg.replaceWith(img); }); + + localizeSubmitTooltip(mount); }; run(); @@ -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'; @@ -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; @@ -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) { @@ -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; return { instanceName: ALLOY_INSTANCE_NAME, stylingConfigurations, @@ -699,9 +729,11 @@ 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); @@ -709,7 +741,7 @@ function createMountPoint() { 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'; @@ -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, @@ -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); diff --git a/scripts/brand-concierge/localization/en.json b/scripts/brand-concierge/localization/en.json new file mode 100644 index 000000000..2b271c379 --- /dev/null +++ b/scripts/brand-concierge/localization/en.json @@ -0,0 +1,59 @@ +{ + "language": "en-US", + "ui": { + "triggerAriaLabel": "Open AI assistant", + "triggerAsk": "Ask a question", + "drawerAriaLabel": "AI assistant", + "drawerTitle": "Ask", + "clearLabel": "Clear", + "clearAriaLabel": "Clear conversation", + "disclaimer": { + "prefix": "Use of this beta AI chatbot is subject to Adobe's ", + "privacyLabel": "Privacy Policy", + "middle": ". Don't share sensitive data. AI responses are not your Content, may be inaccurate, and any offers provided are non-binding. ", + "termsLabel": "Generative AI Terms", + "suffix": "." + } + }, + "text": { + "welcome.heading": "Not sure where to start?
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"] + } +} diff --git a/scripts/brand-concierge/localization/es.json b/scripts/brand-concierge/localization/es.json new file mode 100644 index 000000000..d61486463 --- /dev/null +++ b/scripts/brand-concierge/localization/es.json @@ -0,0 +1,64 @@ +{ + "language": "es-ES", + "ui": { + "triggerAriaLabel": "Abrir el asistente de IA", + "triggerAsk": "Hacer una pregunta", + "drawerAriaLabel": "Asistente de IA", + "drawerTitle": "Preguntar", + "clearLabel": "Borrar", + "clearAriaLabel": "Borrar conversación", + "disclaimer": { + "prefix": "El uso de este chatbot de IA en versión beta está sujeto a la ", + "privacyLabel": "Política de privacidad", + "middle": " de Adobe. No comparta datos confidenciales. Las respuestas de la IA no son su Contenido, pueden ser inexactas y cualquier oferta proporcionada no es vinculante. ", + "termsLabel": "Términos de IA generativa", + "suffix": "." + } + }, + "text": { + "welcome.heading": "¿No sabe por dónde empezar?
Pregúnteme lo que quiera sobre los productos de Adobe.", + "welcome.subheading": "Escriba su pregunta o elija una sugerencia a continuación.", + "input.placeholder": "Haga una pregunta…", + "input.messageInput.aria": "Campo de mensaje", + "input.send.aria": "Enviar mensaje", + "input.mic.aria": "Entrada de voz", + "card.aria.select": "Seleccionar mensaje de ejemplo", + "carousel.prev.aria": "Tarjetas anteriores", + "carousel.next.aria": "Tarjetas siguientes", + "scroll.bottom.aria": "Desplazarse hasta abajo", + "error.network": "Lo sentimos, en este momento tenemos problemas de conexión. Vuelva a intentarlo en unos instantes.", + "error.general": "Lo sentimos, se ha producido un error. Vuelva a intentarlo en unos instantes.", + "loading.message": "Generando a partir de los recursos fiables de Adobe", + "feedback.dialog.title.positive": "Agradecemos sus comentarios", + "feedback.dialog.title.negative": "Agradecemos sus comentarios", + "feedback.dialog.question.positive": "¿Qué salió bien? Seleccione todas las opciones aplicables.", + "feedback.dialog.question.negative": "¿Qué salió mal? Seleccione todas las opciones aplicables.", + "feedback.dialog.notes": "Notas", + "feedback.dialog.submit": "Enviar", + "feedback.dialog.cancel": "Cancelar", + "feedback.dialog.notes.placeholder": "Notas adicionales (opcional)", + "feedback.toast.success": "Gracias por sus comentarios.", + "feedback.thumbsUp.aria": "Me gusta", + "feedback.thumbsDown.aria": "No me gusta" + }, + "arrays": { + "welcome.examples": [ + { "text": "¿Dónde puedo aprender sobre la IA en Experience League?" }, + { "text": "Primeros pasos con Experience Manager" }, + { "text": "Configurar un conjunto de informes de Adobe Analytics" }, + { "text": "Explicar las pruebas A/B de Adobe Target" } + ], + "feedback.positive.options": [ + "Útil y relevante", + "Claro y fácil de entender", + "Tono cercano y conversacional", + "Otro" + ], + "feedback.negative.options": [ + "Poco útil o irrelevante", + "Confuso o poco claro", + "Demasiado formal o robótico", + "Otro" + ] + } +}