diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 1a18e9635..96fe57148 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -24,3 +24,4 @@ export * from './translations'; // Utils export {default as getDefaultI18nBundles} from './utils/getDefaultI18nBundles'; +export {default as normalizeTranslations} from './utils/normalizeTranslations'; diff --git a/packages/i18n/src/utils/normalizeTranslations.ts b/packages/i18n/src/utils/normalizeTranslations.ts new file mode 100644 index 000000000..843ea323b --- /dev/null +++ b/packages/i18n/src/utils/normalizeTranslations.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {I18nTranslations} from '../models/i18n'; + +/** + * Accepts translations in either flat or namespaced format and normalizes them + * to the flat format required by the SDK. + * + * Flat format (already correct): + * ```ts + * { "signin.heading": "Sign In" } + * ``` + * + * Namespaced format (auto-converted): + * ```ts + * { signin: { heading: "Sign In" } } + * ``` + * + * Both formats can be mixed within the same object — a top-level string value + * is kept as-is, while a top-level object value is flattened one level deep + * using `"namespace.key"` concatenation. + * + * @param translations - Translations in flat or namespaced format. + * @returns Normalized flat translations compatible with `I18nTranslations`. + */ +const normalizeTranslations = ( + translations: Record> | null | undefined, +): I18nTranslations => { + if (!translations || typeof translations !== 'object') { + return {} as unknown as I18nTranslations; + } + + const result: Record = {}; + + Object.entries(translations).forEach(([topKey, value]: [string, string | Record]) => { + if (typeof value === 'string') { + // Already flat — keep as-is (e.g., "signin.heading": "Sign In") + result[topKey] = value; + } else if (value !== null && typeof value === 'object') { + // Namespaced — flatten one level (e.g., signin: { heading: "Sign In" } → "signin.heading": "Sign In") + Object.entries(value).forEach(([subKey, subValue]: [string, string]) => { + if (typeof subValue === 'string') { + result[`${topKey}.${subKey}`] = subValue; + } + }); + } + }); + + return result as unknown as I18nTranslations; +}; + +export default normalizeTranslations; diff --git a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx index e4ddb85fd..c69b27895 100644 --- a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {CreateOrganizationPayload, createPackageComponentLogger} from '@asgardeo/browser'; +import {CreateOrganizationPayload, createPackageComponentLogger, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {ChangeEvent, CSSProperties, FC, FormEvent, ReactElement, ReactNode, useState} from 'react'; import useStyles from './BaseCreateOrganization.styles'; @@ -62,6 +62,13 @@ export interface BaseCreateOrganizationProps { renderAdditionalFields?: () => ReactNode; style?: CSSProperties; title?: string; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -95,13 +102,14 @@ export const BaseCreateOrganization: FC = ({ onSubmit, onSuccess, open = false, + preferences, renderAdditionalFields, style, title = 'Create Organization', }: BaseCreateOrganizationProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: ReturnType = useStyles(theme, colorScheme); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [formData, setFormData] = useState({ description: '', handle: '', diff --git a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx index 9068597a6..cc4be63e8 100644 --- a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx +++ b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {AllOrganizationsApiResponse, Organization} from '@asgardeo/browser'; +import {AllOrganizationsApiResponse, Organization, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {CSSProperties, FC, MouseEvent, ReactElement, ReactNode, useMemo} from 'react'; import useStyles from './BaseOrganizationList.styles'; @@ -120,6 +120,13 @@ export interface BaseOrganizationListProps { * Title for the popup dialog (only used in popup mode) */ title?: string; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -269,10 +276,11 @@ export const BaseOrganizationList: FC = ({ style, title = 'Organizations', showStatus, + preferences, }: BaseOrganizationListProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: ReturnType = useStyles(theme, colorScheme); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const organizationsWithSwitchAccess: OrganizationWithSwitchAccess[] = useMemo(() => { if (!allOrganizations?.organizations) { diff --git a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx index ff67e3d3b..34245a313 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {OrganizationDetails, formatDate} from '@asgardeo/browser'; +import {OrganizationDetails, formatDate, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useState, useCallback} from 'react'; import useStyles from './BaseOrganizationProfile.styles'; @@ -108,6 +108,13 @@ export interface BaseOrganizationProfileProps { * Custom title for the profile. */ title?: string; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** diff --git a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx index ae7348070..37bb3e067 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {OrganizationDetails, createPackageComponentLogger} from '@asgardeo/browser'; +import {OrganizationDetails, createPackageComponentLogger, Preferences} from '@asgardeo/browser'; import {FC, ReactElement, useEffect, useState} from 'react'; import BaseOrganizationProfile, {BaseOrganizationProfileProps} from './BaseOrganizationProfile'; import getOrganization from '../../../api/getOrganization'; @@ -144,10 +144,11 @@ const OrganizationProfile: FC = ({ popupTitle, loadingFallback, errorFallback, + preferences, ...rest }: OrganizationProfileProps): ReactElement => { const {baseUrl, instanceId} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [organization, setOrganization] = useState(null); const fetchOrganization = async (): Promise => { @@ -206,6 +207,7 @@ const OrganizationProfile: FC = ({ open={open} onOpenChange={onOpenChange} title={popupTitle || t('organization.profile.heading')} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx index 167deb7c3..74adec40c 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx @@ -17,6 +17,7 @@ */ // Removed BEM and vendor prefix utilities +import {Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import { useFloating, @@ -159,6 +160,13 @@ export interface BaseOrganizationSwitcherProps { * Custom styles for the component. */ style?: CSSProperties; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -185,12 +193,13 @@ export const BaseOrganizationSwitcher: FC = ({ showTriggerLabel = true, avatarSize = 24, fallback = null, + preferences, }: BaseOrganizationSwitcherProps): ReactElement => { const {theme, colorScheme, direction} = useTheme(); const styles: Record = useStyles(theme, colorScheme); const [isOpen, setIsOpen] = useState(false); const [hoveredItemIndex, setHoveredItemIndex] = useState(null); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const isRTL: boolean = direction === 'rtl'; const {refs, floatingStyles, context} = useFloating({ diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx index 0f164df5c..497d082ef 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -16,6 +16,7 @@ * under the License. */ +import {Preferences} from '@asgardeo/browser'; import {FC, ReactElement, useState} from 'react'; import { @@ -87,6 +88,7 @@ export const OrganizationSwitcher: FC = ({ fallback = null, onOrganizationSwitch: propOnOrganizationSwitch, organizations: propOrganizations, + preferences, ...props }: OrganizationSwitcherProps): ReactElement => { const {isSignedIn} = useAsgardeo(); @@ -100,7 +102,7 @@ export const OrganizationSwitcher: FC = ({ const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false); const [isOrganizationListOpen, setIsOrganizationListOpen] = useState(false); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); if (!isSignedIn && fallback) { return fallback; @@ -155,6 +157,7 @@ export const OrganizationSwitcher: FC = ({ error={error} menuItems={menuItems} onManageProfile={handleManageOrganization} + preferences={preferences} {...props} /> = ({ open = false, error = null, isLoading = false, + preferences, showFields = [], hideFields = [], displayNameAttributes = [], @@ -132,7 +140,7 @@ const BaseUserProfile: FC = ({ const {theme, colorScheme} = useTheme(); const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); /** * Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays. diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index 115d49700..7e1727637 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {AsgardeoError, User} from '@asgardeo/browser'; +import {AsgardeoError, User, Preferences} from '@asgardeo/browser'; import {FC, ReactElement, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; @@ -64,10 +64,10 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({preferences, ...rest}: UserProfileProps): ReactElement => { const {baseUrl, instanceId} = useAsgardeo(); const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const [error, setError] = useState(null); @@ -95,6 +95,7 @@ const UserProfile: FC = ({...rest}: UserProfileProps): ReactEl schemas={schemas} onUpdate={handleProfileUpdate} error={error} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx index 8e05b1055..bac2362ff 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse} from '@asgardeo/browser'; +import {FlowMetadataResponse, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import useStyles from './BaseAcceptInvite.styles'; @@ -227,6 +227,13 @@ export interface BaseAcceptInviteProps { * Theme variant for the component. */ variant?: CardProps['variant']; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -254,13 +261,14 @@ const BaseAcceptInvite: FC = ({ onGoToSignIn, className = '', children, + preferences, size = 'medium', variant = 'outlined', showTitle = true, showSubtitle = true, }: BaseAcceptInviteProps): ReactElement => { const {meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx index 2492ddd1b..903bfce3e 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {EmbeddedFlowType, FlowMetadataResponse} from '@asgardeo/browser'; +import {EmbeddedFlowType, FlowMetadataResponse, Preferences} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import useStyles from './BaseInviteUser.styles'; @@ -215,6 +215,13 @@ export interface BaseInviteUserProps { * Theme variant for the component. */ variant?: CardProps['variant']; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -241,13 +248,14 @@ const BaseInviteUser: FC = ({ className = '', children, isInitialized = true, + preferences, size = 'medium', variant = 'outlined', showTitle = true, showSubtitle = true, }: BaseInviteUserProps): ReactElement => { const {meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); const [isLoading, setIsLoading] = useState(false); diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index b65c97287..80941c08e 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -21,6 +21,7 @@ import { EmbeddedSignInFlowHandleResponse, EmbeddedSignInFlowHandleRequestPayload, Platform, + Preferences, } from '@asgardeo/browser'; import {FC, ReactElement} from 'react'; import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; @@ -36,6 +37,10 @@ export type SignInProps = Pick ReactElement; + /** + * Component-level preferences to override global i18n and theme settings. + */ + preferences?: Preferences; }; /** @@ -63,7 +68,7 @@ export type SignInProps = Pick = ({className, size = 'medium', children, ...rest}: SignInProps) => { +const SignIn: FC = ({className, size = 'medium', children, preferences, ...rest}: SignInProps) => { const {signIn, afterSignInUrl, isInitialized, isLoading, platform} = useAsgardeo(); /** @@ -105,6 +110,7 @@ const SignIn: FC = ({className, size = 'medium', children, ...rest} variant={rest.variant} onSuccess={rest.onSuccess} onError={rest.onError} + preferences={preferences} > {children} @@ -123,6 +129,7 @@ const SignIn: FC = ({className, size = 'medium', children, ...rest} showLogo={true} showSubtitle={true} showTitle={true} + preferences={preferences} {...rest} /> ); diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx index b5b40456e..0759858f0 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx @@ -30,6 +30,7 @@ import { EmbeddedFlowExecuteRequestConfig, handleWebAuthnAuthentication, createPackageComponentLogger, + Preferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, FormEvent, RefObject, useEffect, useState, useCallback, useRef, ReactElement} from 'react'; diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx index b47078c28..a78f252d7 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx @@ -21,11 +21,13 @@ import { EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse, + Preferences, resolveVars, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, useState, useCallback, ReactElement, ReactNode} from 'react'; import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; +import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; import useFlow from '../../../../../contexts/Flow/useFlow'; import useTheme from '../../../../../contexts/Theme/useTheme'; @@ -185,6 +187,13 @@ export interface BaseSignInProps { */ onSuccess?: (authData: Record) => void; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + /** * Whether to show the logo. */ @@ -631,11 +640,11 @@ const BaseSignInContent: FC = ({ * * ``` */ -const BaseSignIn: FC = ({showLogo = true, ...rest}: BaseSignInProps): ReactElement => { +const BaseSignIn: FC = ({preferences, showLogo = true, ...rest}: BaseSignInProps): ReactElement => { const {theme} = useTheme(); const styles: any = useStyles(theme, theme.vars.colors.text.primary); - return ( + const content: ReactElement = (
{showLogo && (
@@ -647,6 +656,14 @@ const BaseSignIn: FC = ({showLogo = true, ...rest}: BaseSignInP
); + + if (!preferences) return content; + + return ( + + {content} + + ); }; export default BaseSignIn; diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx index 9ce123f96..948a97d45 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx @@ -25,6 +25,7 @@ import { EmbeddedSignInFlowStatusV2, EmbeddedSignInFlowTypeV2, FlowMetadataResponse, + Preferences, } from '@asgardeo/browser'; import {FC, ReactElement, useState, useEffect, useRef, ReactNode} from 'react'; // eslint-disable-next-line import/no-named-as-default @@ -112,6 +113,13 @@ export type SignInProps = { * Theme variant for the component. */ variant?: BaseSignInProps['variant']; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; }; /** @@ -189,6 +197,7 @@ interface PasskeyState { */ const SignIn: FC = ({ className, + preferences, size = 'medium', onSuccess, onError, @@ -196,7 +205,7 @@ const SignIn: FC = ({ children, }: SignInProps): ReactElement => { const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta} = useAsgardeo(); - const {t} = useTranslation(); + const {t} = useTranslation(preferences?.i18n); // State management for the flow const [components, setComponents] = useState([]); @@ -687,6 +696,7 @@ const SignIn: FC = ({ className={className} size={size} variant={variant} + preferences={preferences} /> ); }; diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx index d9e08fc06..548444af4 100644 --- a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx @@ -24,10 +24,12 @@ import { withVendorCSSClassPrefix, EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, createPackageComponentLogger, + Preferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, ReactNode, useEffect, useState, useCallback, useRef} from 'react'; import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; +import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; import useFlow from '../../../../../contexts/Flow/useFlow'; import useTheme from '../../../../../contexts/Theme/useTheme'; @@ -237,6 +239,13 @@ export interface BaseSignUpProps { * Theme variant for the component. */ variant?: CardProps['variant']; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; } /** @@ -1016,11 +1025,11 @@ const BaseSignUpContent: FC = ({ * This component handles both the presentation layer and sign-up flow logic. * It accepts API functions as props to maintain framework independence. */ -const BaseSignUp: FC = ({showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { +const BaseSignUp: FC = ({preferences, showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { const {theme, colorScheme} = useTheme(); const styles: any = useStyles(theme, colorScheme); - return ( + const content: ReactElement = (
{showLogo && (
@@ -1032,6 +1041,14 @@ const BaseSignUp: FC = ({showLogo = true, ...rest}: BaseSignUpP
); + + if (!preferences) return content; + + return ( + + {content} + + ); }; export default BaseSignUp; diff --git a/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts b/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts new file mode 100644 index 000000000..5e54248cd --- /dev/null +++ b/packages/react/src/contexts/I18n/ComponentPreferencesContext.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {Preferences} from '@asgardeo/browser'; +import {createContext} from 'react'; + +/** + * Context for component-level preferences overrides. + * Presentational components can provide this context to override the global i18n + * and theme settings for their entire subtree, including all nested components. + */ +const ComponentPreferencesContext = createContext(undefined); + +export default ComponentPreferencesContext; diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 37d3222ef..0f3f5a62a 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -17,7 +17,7 @@ */ import {deepMerge, I18nPreferences, createPackageComponentLogger} from '@asgardeo/browser'; -import {I18nBundle, getDefaultI18nBundles} from '@asgardeo/i18n'; +import {I18nBundle, I18nTranslations, getDefaultI18nBundles, normalizeTranslations} from '@asgardeo/i18n'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; @@ -110,13 +110,16 @@ const I18nProvider: FC> = ({ setInjectedBundles((prev: Record) => { const merged: Record = {...prev}; Object.entries(newBundles).forEach(([key, bundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], - translations: deepMerge(merged[key].translations, bundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = bundle; + merged[key] = {...bundle, translations: normalizedTranslations}; } }); return merged; @@ -140,27 +143,33 @@ const I18nProvider: FC> = ({ // 2. Injected bundles (e.g., from flow metadata) — override defaults Object.entries(injectedBundles).forEach(([key, bundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], - translations: deepMerge(merged[key].translations, bundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = bundle; + merged[key] = {...bundle, translations: normalizedTranslations}; } }); // 3. User-provided bundles (from props) — highest priority, override everything if (preferences?.bundles) { Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + userBundle.translations as unknown as Record>, + ); if (merged[key]) { merged[key] = { ...merged[key], metadata: userBundle.metadata ? {...merged[key].metadata, ...userBundle.metadata} : merged[key].metadata, - translations: deepMerge(merged[key].translations, userBundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = userBundle; + merged[key] = {...userBundle, translations: normalizedTranslations}; } }); } diff --git a/packages/react/src/hooks/useTranslation.ts b/packages/react/src/hooks/useTranslation.ts index 3a0e122ea..826898af6 100644 --- a/packages/react/src/hooks/useTranslation.ts +++ b/packages/react/src/hooks/useTranslation.ts @@ -16,9 +16,10 @@ * under the License. */ -import {deepMerge, I18nPreferences} from '@asgardeo/browser'; -import {I18nBundle} from '@asgardeo/i18n'; +import {deepMerge, I18nPreferences, Preferences} from '@asgardeo/browser'; +import {I18nBundle, I18nTranslations, normalizeTranslations} from '@asgardeo/i18n'; import {useContext, useMemo} from 'react'; +import ComponentPreferencesContext from '../contexts/I18n/ComponentPreferencesContext'; import I18nContext from '../contexts/I18n/I18nContext'; export interface UseTranslation { @@ -60,6 +61,8 @@ export interface UseTranslationWithPreferences extends UseTranslation { */ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationWithPreferences => { const context: any = useContext(I18nContext); + const componentPrefs: Preferences | undefined = useContext(ComponentPreferencesContext); + const contextPreferences: I18nPreferences | undefined = componentPrefs?.i18n; if (!context) { throw new Error( @@ -67,11 +70,14 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW ); } + // Direct parameter takes precedence over context-provided preferences + const effectivePreferences: I18nPreferences | undefined = componentPreferences ?? contextPreferences; + const {t: globalT, currentLanguage, setLanguage, bundles: globalBundles, fallbackLanguage} = context; // Merge global bundles with component-level bundles if provided const mergedBundles: Record = useMemo(() => { - if (!componentPreferences?.bundles) { + if (!effectivePreferences?.bundles) { return globalBundles; } @@ -83,7 +89,10 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW }); // Merge component-level bundles using deepMerge for better merging - Object.entries(componentPreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + componentBundle.translations as unknown as Record>, + ); if (merged[key]) { // Deep merge component bundle with existing global bundle merged[key] = { @@ -91,20 +100,20 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW metadata: componentBundle.metadata ? {...merged[key].metadata, ...componentBundle.metadata} : merged[key].metadata, - translations: deepMerge(merged[key].translations, componentBundle.translations), + translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { // No global bundle for this language, use component bundle as-is - merged[key] = componentBundle; + merged[key] = {...componentBundle, translations: normalizedTranslations}; } }); return merged; - }, [globalBundles, componentPreferences?.bundles]); + }, [globalBundles, effectivePreferences?.bundles]); // Create enhanced translation function that uses merged bundles const enhancedT: (key: string, params?: Record) => string = useMemo(() => { - if (!componentPreferences?.bundles) { + if (!effectivePreferences?.bundles) { // No component preferences, use global translation function return globalT; } @@ -114,14 +123,14 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW // Try to get translation from current language bundle const currentBundle: I18nBundle | undefined = mergedBundles[currentLanguage]; - if (currentBundle?.translations[key]) { + if (currentBundle?.translations?.[key]) { translation = currentBundle.translations[key]; } // Fallback to fallback language if translation not found if (!translation && currentLanguage !== fallbackLanguage) { const fallbackBundle: I18nBundle | undefined = mergedBundles[fallbackLanguage]; - if (fallbackBundle?.translations[key]) { + if (fallbackBundle?.translations?.[key]) { translation = fallbackBundle.translations[key]; } } @@ -142,7 +151,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW return translation; }; - }, [mergedBundles, currentLanguage, fallbackLanguage, globalT, componentPreferences?.bundles]); + }, [mergedBundles, currentLanguage, fallbackLanguage, globalT, effectivePreferences?.bundles]); return { availableLanguages: Object.keys(mergedBundles),