From e9bae32ee20ba5b124ee1a2ed96834bf10eabb95 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Mon, 15 Jun 2026 18:11:39 +0700 Subject: [PATCH] feat: add default_language to site config for banner i18n Site owners can now set a default language for the consent banner, overriding browser auto-detection. When null the banner uses navigator.language / data-locale as before (existing behaviour). Changes: - DB: add default_language column to site_configs (nullable, String(10)) - API model: SiteConfig.default_language field - API schema: SiteConfigCreate/Update/Response schemas - Config resolver: pass default_language through to public config - Banner types: SiteConfig.default_language field - Banner logic: use config.default_language ?? detectLocale() - Admin UI: SiteConfigTab dropdown with auto-detect option - Admin UI types: SiteConfig.default_language added --- .../admin-ui/src/components/SiteConfigTab.tsx | 34 +++++++++++++++++++ apps/admin-ui/src/types/api.ts | 1 + .../alembic/versions/0004_default_language.py | 34 +++++++++++++++++++ apps/api/src/models/site_config.py | 5 +++ apps/api/src/schemas/site.py | 3 ++ apps/api/src/services/config_resolver.py | 2 ++ apps/banner/src/banner.ts | 3 +- apps/banner/src/types.ts | 1 + 8 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/api/alembic/versions/0004_default_language.py diff --git a/apps/admin-ui/src/components/SiteConfigTab.tsx b/apps/admin-ui/src/components/SiteConfigTab.tsx index f3553c1..56d7598 100644 --- a/apps/admin-ui/src/components/SiteConfigTab.tsx +++ b/apps/admin-ui/src/components/SiteConfigTab.tsx @@ -115,6 +115,7 @@ export default function SiteConfigTab({ siteId, config }: Props) { const [consentExpiry, setConsentExpiry] = useState(config?.consent_expiry_days ?? 365); const [privacyUrl, setPrivacyUrl] = useState(config?.privacy_policy_url ?? ''); const [termsUrl, setTermsUrl] = useState(config?.terms_url ?? ''); + const [defaultLanguage, setDefaultLanguage] = useState(config?.default_language ?? ''); // GPP state const [gppEnabled, setGppEnabled] = useState(config?.gpp_enabled ?? true); @@ -167,6 +168,7 @@ export default function SiteConfigTab({ siteId, config }: Props) { consent_expiry_days: consentExpiry, privacy_policy_url: privacyUrl || null, terms_url: termsUrl || null, + default_language: defaultLanguage || null, gpp_enabled: gppEnabled, gpp_supported_apis: gppEnabled ? gppSupportedApis : null, gpc_enabled: gpcEnabled, @@ -287,6 +289,38 @@ export default function SiteConfigTab({ siteId, config }: Props) { {'[Privacy Policy]({{privacy_policy}})'}

+ +
+
+ + + + + { setDefaultLanguage(''); markReset('default_language'); }} /> +
+

+ Overrides browser language detection. Set to “Auto-detect” to let the + banner use the visitor’s browser or device language. +

+
diff --git a/apps/admin-ui/src/types/api.ts b/apps/admin-ui/src/types/api.ts index 27c731f..cbe23bb 100644 --- a/apps/admin-ui/src/types/api.ts +++ b/apps/admin-ui/src/types/api.ts @@ -125,6 +125,7 @@ export interface SiteConfig { banner_config: BannerConfig | null; privacy_policy_url: string | null; terms_url: string | null; + default_language: string | null; consent_expiry_days: number; scan_enabled: boolean; scan_frequency_hours: number; diff --git a/apps/api/alembic/versions/0004_default_language.py b/apps/api/alembic/versions/0004_default_language.py new file mode 100644 index 0000000..1f22f2f --- /dev/null +++ b/apps/api/alembic/versions/0004_default_language.py @@ -0,0 +1,34 @@ +"""default_language on site configs + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-06-15 + +Allows site owners to set a default language for the banner, +overriding the browser's auto-detection. The banner script uses +this when loading translations. + +The column is nullable — NULL means "auto-detect from browser". +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0004" +down_revision: str | Sequence[str] | None = "0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "site_configs", + sa.Column("default_language", sa.String(10), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("site_configs", "default_language") diff --git a/apps/api/src/models/site_config.py b/apps/api/src/models/site_config.py index 3e1261b..b8b8ca3 100644 --- a/apps/api/src/models/site_config.py +++ b/apps/api/src/models/site_config.py @@ -51,6 +51,11 @@ class SiteConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True) terms_url: Mapped[str | None] = mapped_column(Text, nullable=True) + # Banner default language override. When set, the banner uses this locale + # instead of auto-detecting from the browser (navigator.language / data-locale). + # Null = auto-detect (existing behaviour). + default_language: Mapped[str | None] = mapped_column(String(10), nullable=True) + # Cookie categories shown in the banner. When NULL, inherit from the # cascade (site-group → org → system default of all five). An explicit # list overrides. ``necessary`` is always implicit and will be forced diff --git a/apps/api/src/schemas/site.py b/apps/api/src/schemas/site.py index 398600f..1b0b292 100644 --- a/apps/api/src/schemas/site.py +++ b/apps/api/src/schemas/site.py @@ -61,6 +61,7 @@ class SiteConfigCreate(BaseModel): banner_config: dict | None = None privacy_policy_url: str | None = None terms_url: str | None = None + default_language: str | None = Field(default=None, max_length=10) scan_schedule_cron: str | None = None scan_max_pages: int = Field(default=50, ge=1, le=1000) consent_expiry_days: int = Field(default=365, ge=1, le=730) @@ -87,6 +88,7 @@ class SiteConfigUpdate(BaseModel): banner_config: dict | None = None privacy_policy_url: str | None = None terms_url: str | None = None + default_language: str | None = Field(default=None, max_length=10) scan_schedule_cron: str | None = None scan_max_pages: int | None = Field(default=None, ge=1, le=1000) consent_expiry_days: int | None = Field(default=None, ge=1, le=730) @@ -112,6 +114,7 @@ class SiteConfigResponse(BaseModel): banner_config: dict | None = None privacy_policy_url: str | None = None terms_url: str | None = None + default_language: str | None = None scan_schedule_cron: str | None = None scan_max_pages: int = 50 consent_expiry_days: int = 365 diff --git a/apps/api/src/services/config_resolver.py b/apps/api/src/services/config_resolver.py index add8180..e5bd38b 100644 --- a/apps/api/src/services/config_resolver.py +++ b/apps/api/src/services/config_resolver.py @@ -148,6 +148,7 @@ def build_public_config( "gcm_default": resolved.get("gcm_default"), "shopify_privacy_enabled": resolved["shopify_privacy_enabled"], "banner_config": resolved.get("banner_config"), + "default_language": resolved.get("default_language"), "privacy_policy_url": resolved.get("privacy_policy_url"), "terms_url": resolved.get("terms_url"), "consent_expiry_days": resolved["consent_expiry_days"], @@ -173,6 +174,7 @@ CONFIG_FIELDS = ( "gcm_default", "shopify_privacy_enabled", "banner_config", + "default_language", "privacy_policy_url", "terms_url", "consent_expiry_days", diff --git a/apps/banner/src/banner.ts b/apps/banner/src/banner.ts index 159fad0..7779c1a 100644 --- a/apps/banner/src/banner.ts +++ b/apps/banner/src/banner.ts @@ -220,7 +220,8 @@ async function init(): Promise { } // Load translations - const locale = detectLocale(); + // Use site-configured default_language if set, otherwise auto-detect + const locale = config.default_language ?? detectLocale(); const t = await loadTranslations(cdnBase, locale); // Capture a closure that re-opens the banner with current consent diff --git a/apps/banner/src/types.ts b/apps/banner/src/types.ts index 2534558..c517947 100644 --- a/apps/banner/src/types.ts +++ b/apps/banner/src/types.ts @@ -80,6 +80,7 @@ export interface SiteConfig { gcm_default: Record | null; shopify_privacy_enabled: boolean; banner_config: BannerConfig | null; + default_language: string | null; privacy_policy_url: string | null; terms_url: string | null; consent_expiry_days: number;