-
Notifications
You must be signed in to change notification settings - Fork 42
feat: (AIME-194) Spanish localization for Brand Concierge widget UI #2791
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
da75209
4c71d21
393bf9e
01a35a9
824b932
bea7190
a950e53
c2c0bf3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
@@ -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 on lines
+144
to
+161
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On top of that, the two fetches are sequential, not parallel: en = await fetchLocaleSheet('en'); // line 148
...
locale = await fetchLocaleSheet(key); // line 157For every non- Two independent, low-effort fixes:
|
||
| 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; | ||
| 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'; | ||||||
|
|
@@ -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; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the JSON refactor (824b932): |
||||||
| return { | ||||||
| instanceName: ALLOY_INSTANCE_NAME, | ||||||
| stylingConfigurations, | ||||||
|
|
@@ -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'; | ||||||
|
|
@@ -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); | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.