diff --git a/packages/botonic-core/src/models/legacy-types.ts b/packages/botonic-core/src/models/legacy-types.ts index 01c7b7f82d..4eb8137106 100644 --- a/packages/botonic-core/src/models/legacy-types.ts +++ b/packages/botonic-core/src/models/legacy-types.ts @@ -241,6 +241,7 @@ export interface SessionUser { locale: string country: string system_locale: string + system_locale_updated?: boolean } export interface HubtypeCaseContactReason { diff --git a/packages/botonic-plugin-flow-builder/src/action/index.tsx b/packages/botonic-plugin-flow-builder/src/action/index.tsx index e80ab2416e..9b8a9efce9 100644 --- a/packages/botonic-plugin-flow-builder/src/action/index.tsx +++ b/packages/botonic-plugin-flow-builder/src/action/index.tsx @@ -73,13 +73,36 @@ export class FlowBuilderAction extends React.Component { return filteredContents } - render(): JSX.Element | JSX.Element[] { - const { contents, webchatSettingsParams } = this.props - const botContext = this.context as BotContext + protected getWebchatSettingsParams(botContext: BotContext): { + shouldSendWebchatSettings: boolean + webchatSettingsParams?: WebchatSettingsProps + } { + let { webchatSettingsParams } = this.props + if (botContext.session.user.system_locale_updated) { + webchatSettingsParams = { + ...webchatSettingsParams, + user: { + ...webchatSettingsParams?.user, + system_locale: botContext.session.user.system_locale, + }, + } + } const shouldSendWebchatSettings = (isWebchat(botContext.session) || isDev(botContext.session)) && !!webchatSettingsParams + return { + shouldSendWebchatSettings, + webchatSettingsParams, + } + } + + render(): JSX.Element | JSX.Element[] { + const { contents } = this.props + const botContext = this.context as BotContext + const { shouldSendWebchatSettings, webchatSettingsParams } = + this.getWebchatSettingsParams(botContext) + return ( <> {shouldSendWebchatSettings && ( @@ -93,11 +116,10 @@ export class FlowBuilderAction extends React.Component { export class FlowBuilderMultichannelAction extends FlowBuilderAction { render(): JSX.Element | JSX.Element[] { - const { contents, webchatSettingsParams } = this.props + const { contents } = this.props const botContext = this.context as BotContext - const shouldSendWebchatSettings = - (isWebchat(botContext.session) || isDev(botContext.session)) && - !!webchatSettingsParams + const { shouldSendWebchatSettings, webchatSettingsParams } = + this.getWebchatSettingsParams(botContext) return ( diff --git a/packages/botonic-plugin-flow-builder/src/api.ts b/packages/botonic-plugin-flow-builder/src/api.ts index d21b98a882..c8be4cf141 100644 --- a/packages/botonic-plugin-flow-builder/src/api.ts +++ b/packages/botonic-plugin-flow-builder/src/api.ts @@ -25,6 +25,7 @@ import { type HtSmartIntentNode, } from './content-fields/hubtype-fields' import { type FlowBuilderApiOptions, ProcessEnvNodeEnvs } from './types' +import { FlowLocale } from './utils/flow-locale' export class FlowBuilderApi { url: string @@ -310,47 +311,13 @@ export class FlowBuilderApi { } getResolvedLocale(): string { - const systemLocale = this.request.getSystemLocale() - - const locale = this.resolveAsLocale(systemLocale) - if (locale) { - return locale - } - - const language = this.resolveAsLanguage(systemLocale) - if (language) { - this.request.setSystemLocale(language) - return language - } - - const defaultLocale = this.resolveAsDefaultLocale() - this.request.setSystemLocale(defaultLocale) - return defaultLocale - } - - private resolveAsLocale(locale: string): string | undefined { - if (this.flow.locales.find(flowLocale => flowLocale === locale)) { - return locale - } - return undefined - } - - private resolveAsLanguage(locale?: string): string | undefined { - const language = locale?.split('-')[0] - if ( - language && - this.flow.locales.find(flowLocale => flowLocale === language) - ) { - console.log(`locale: ${locale} has been resolved as ${language}`) - return language - } - return undefined - } - - private resolveAsDefaultLocale(): string { - console.log( - `Resolve locale with default locale: ${this.flow.default_locale_code}` - ) - return this.flow.default_locale_code || 'en' + const flowLocales = this.flow.locales + const defaultLocaleCode = this.flow.default_locale_code + + return new FlowLocale( + this.request, + flowLocales, + defaultLocaleCode + ).resolve() } } diff --git a/packages/botonic-plugin-flow-builder/src/index.ts b/packages/botonic-plugin-flow-builder/src/index.ts index cf59200267..8135c48912 100644 --- a/packages/botonic-plugin-flow-builder/src/index.ts +++ b/packages/botonic-plugin-flow-builder/src/index.ts @@ -167,6 +167,7 @@ export default class BotonicPluginFlowBuilder implements Plugin { post(request: PluginPreRequest): void { request.input.nluResolution = undefined + delete request.session.user.system_locale_updated } async getContentsByContentID( diff --git a/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts b/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts new file mode 100644 index 0000000000..907ed467e3 --- /dev/null +++ b/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts @@ -0,0 +1,87 @@ +import type { BotContext } from '@botonic/core' + +export class FlowLocale { + constructor( + private readonly botContext: BotContext, + private readonly flowLocales: string[], + private readonly defaultLocaleCode: string + ) {} + + resolve(): string { + const priorityLocale = this.getPriorityLocale() + + if (priorityLocale) { + const exactMatch = this.matchExactLocale(priorityLocale) + if (exactMatch) { + return this.applyLocale(exactMatch) + } + + const languageMatch = this.matchLanguage(priorityLocale) + if (languageMatch) { + return this.applyLocale(languageMatch) + } + } + + return this.applyLocale(this.getDefaultLocale()) + } + + /** + * Rules: + * - If user and system languages differ, user locale takes priority. + * - If both share the same language, the more specific locale wins. + * - If both have the same specificity, user locale wins. + */ + private getPriorityLocale(): string | undefined { + const userLocale = this.botContext.getUserLocale() + const systemLocale = this.botContext.getSystemLocale() + + if (!userLocale || !systemLocale) { + return undefined + } + + const userLanguage = this.getLanguage(userLocale) + const systemLanguage = this.getLanguage(systemLocale) + + if (userLanguage !== systemLanguage) { + return userLocale + } + const userIsSpecific = this.isSpecificLocale(userLocale) + const systemIsSpecific = this.isSpecificLocale(systemLocale) + + if (userIsSpecific && !systemIsSpecific) { + return userLocale + } + if (!userIsSpecific && systemIsSpecific) { + return systemLocale + } + + return userLocale + } + + private matchExactLocale(locale: string): string | undefined { + return this.flowLocales.includes(locale) ? locale : undefined + } + + private matchLanguage(locale: string): string | undefined { + const language = this.getLanguage(locale) + return this.flowLocales.includes(language) ? language : undefined + } + + private getDefaultLocale(): string { + return this.defaultLocaleCode || 'en' + } + + private applyLocale(locale: string): string { + this.botContext.setSystemLocale(locale) + this.botContext.session.user.system_locale_updated = true + return locale + } + + private getLanguage(locale: string): string { + return locale.split('-')[0] + } + + private isSpecificLocale(locale: string): boolean { + return locale.includes('-') + } +} diff --git a/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts b/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts new file mode 100644 index 0000000000..7841334d69 --- /dev/null +++ b/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts @@ -0,0 +1,367 @@ +import type { BotContext } from '@botonic/core' +import { describe, expect, test } from '@jest/globals' + +import { FlowLocale } from '../src/utils/flow-locale' + +function createMockBotContext( + userLocale: string, + systemLocale: string +): BotContext { + let currentSystemLocale = systemLocale + + return { + session: { + user: { + locale: userLocale, + system_locale: systemLocale, + }, + }, + getUserLocale: () => userLocale, + getSystemLocale: () => currentSystemLocale, + setSystemLocale: (locale: string) => { + currentSystemLocale = locale + }, + } as unknown as BotContext +} + +describe('FlowLocale.resolve()', () => { + describe('when user locale matches exactly', () => { + test('should return the user locale when it matches a flow locale', () => { + const botContext = createMockBotContext('es', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('es') + }) + + test('should set system locale when user locale is resolved', () => { + const botContext = createMockBotContext('fr', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + }) + + describe('when user locale has language-region format', () => { + test('should resolve to language code when exact locale not found but language is available', () => { + const botContext = createMockBotContext('es-ES', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('es') + }) + + test('should set system locale to resolved language', () => { + const botContext = createMockBotContext('fr-CA', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + + test('should prefer exact match over language extraction', () => { + const botContext = createMockBotContext('pt-BR', 'en') + const flowLocales = ['en', 'pt-BR', 'pt'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('pt-BR') + }) + }) + + describe('when system locale matches', () => { + test('should return default locale when user locale does not match and is different from system locale', () => { + const botContext = createMockBotContext('de', 'es') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('when falling back to default locale', () => { + test('should return default locale when no match found', () => { + const botContext = createMockBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should set system locale to default when falling back', () => { + const botContext = createMockBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'fr' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + + test('should return "en" when default locale code is empty', () => { + const botContext = createMockBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = '' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('edge cases', () => { + test('should handle empty flow locales array', () => { + const botContext = createMockBotContext('es', 'en') + const flowLocales: string[] = [] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should handle undefined user locale', () => { + const botContext = createMockBotContext( + undefined as unknown as string, + 'es' + ) + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should handle undefined system locale', () => { + const botContext = createMockBotContext( + 'de', + undefined as unknown as string + ) + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'fr' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('fr') + }) + + test('should handle locale with multiple dashes', () => { + const botContext = createMockBotContext('zh-Hans-CN', 'en') + const flowLocales = ['en', 'zh'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('zh') + }) + + test('should be case sensitive when matching locales', () => { + const botContext = createMockBotContext('ES', 'EN') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('when locales share language but differ in specificity', () => { + test('should prefer system locale with region over generic user locale', () => { + const botContext = createMockBotContext('es', 'es-MX') + const flowLocales = ['es', 'es-MX'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-MX') + }) + + test('should fall back to generic locale when specific system locale not in flow', () => { + const botContext = createMockBotContext('es', 'es-MX') + const flowLocales = ['es'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es') + }) + + test('should prefer user locale with region over generic system locale', () => { + const botContext = createMockBotContext('es-ES', 'es') + const flowLocales = ['es-ES', 'es'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-ES') + }) + + test('should use user locale when both have regions for the same language', () => { + const botContext = createMockBotContext('es-MX', 'es-CO') + const flowLocales = ['es-MX', 'es-CO'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-MX') + }) + + test('should not apply specificity logic when languages differ', () => { + const botContext = createMockBotContext('fr', 'es-MX') + const flowLocales = ['fr', 'es-MX'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('fr') + }) + + test('should set system locale to the more specific resolved locale', () => { + const botContext = createMockBotContext('es', 'es-MX') + const flowLocales = ['es-MX'] + const defaultLocaleCode = 'en' + + new FlowLocale(botContext, flowLocales, defaultLocaleCode).resolve() + + expect(botContext.getSystemLocale()).toBe('es-MX') + }) + }) + + describe('priority order', () => { + test('should prioritize user locale over system locale', () => { + const botContext = createMockBotContext('fr', 'es') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('fr') + }) + }) +})