'use client' /** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * Self-service Preferences form. * * General-purpose container for the signed-in admin's UI preferences. * Today the only setting is the interface locale; future preferences * (theme, default landing page, density, etc.) land here as additional * `` blocks rather than spinning up a new drawer per * setting. * * Locale write surface mirrors the chrome-bar `` — both * route through `setAdminLocale` (the service) → * `setAdminLocaleFn` (the host server fn) → the shared * cookie + `admin_users.preferred_locale` writes. The dropdown carries * an explicit "Use browser default" entry that maps to `null` * server-side, clearing the column and re-engaging the detection * cascade (cookie → Accept-Language → defaultLocale). The sentinel * value `__auto__` is the wire form for that option; the service * signature uses `string | null`. * * On save, the freshened `AccountResponse` returned by the server fn * is lifted into the parent container so the read-only Preferences * section re-renders without a route reload. Mirrors how * `UpdateAccount` threads its result through `onSuccess`. */ import { useMemo, useState } from 'react' import { revalidateLogic, useForm } from '@tanstack/react-form-start' import { getAdminConfig } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Alert, Button, LoaderEllipsis, Select } from '@byline/ui/react' import cx from 'clsx' import { z } from 'zod' import { useBylineAdminServices } from '../../../services/admin-services-context.js' import styles from './preferences.module.css' import type { AccountResponse } from '../index.js' /** * Sentinel for the "Use browser default" entry in the locale select. * The widget's value type is `string`; we map this sentinel to `null` * at submit time. Prefixed with double-underscore to make collision * with a real BCP 47 tag structurally impossible (no real locale code * contains underscores). */ const AUTO_VALUE = '__auto__' const preferencesSchema = z.object({ // Validation against the configured locale set happens server-side // (it's the single source of truth). The form schema just enforces // non-emptiness — `AUTO_VALUE` is a valid selection. locale: z.string().min(1, { message: 'Select a language' }), }) type PreferencesValues = z.infer function defaultsFrom(account: AccountResponse): PreferencesValues { return { locale: account.preferred_locale ?? AUTO_VALUE } } interface PreferencesProps { account: AccountResponse onClose?: () => void onSuccess?: (account: AccountResponse) => void } export function Preferences({ account, onClose, onSuccess }: PreferencesProps) { const { t } = useTranslation('byline-admin') const { setAdminLocale } = useBylineAdminServices() const [formError, setFormError] = useState(null) const [successMessage, setSuccessMessage] = useState(null) // Build the dropdown's items from the host's configured interface // locales. `localeDefinitions` carries host-authored display names // ("Français" vs CLDR's lowercase "français"); when it's not set, // fall back to the raw code so the form still functions. const localeItems = useMemo(() => { const { i18n } = getAdminConfig() const definitionByCode = new Map( (i18n.admin.localeDefinitions ?? []).map((d) => [d.code, d.nativeName]) ) const items = i18n.admin.locales.map((code) => ({ value: code, label: definitionByCode.get(code) ?? code, })) return [{ value: AUTO_VALUE, label: t('language.useBrowserDefault') }, ...items] }, [t]) const form = useForm({ defaultValues: defaultsFrom(account), validationLogic: revalidateLogic({ mode: 'blur', modeAfterSubmission: 'change', }), validators: { onDynamic: preferencesSchema, }, onSubmit: async ({ value }) => { setFormError(null) setSuccessMessage(null) const nextLocale = value.locale === AUTO_VALUE ? null : value.locale if (nextLocale === (account.preferred_locale ?? null)) { setSuccessMessage(t('common.feedback.noChanges')) return } try { const result = await setAdminLocale({ data: { locale: nextLocale } }) // Pre-auth path returns `account: null`; the form is only // reachable behind an admin session, so `account` should be // populated. Treat absence as a defensive no-op. setSuccessMessage(t('common.feedback.saved')) if (result.account != null) { onSuccess?.(result.account) } } catch { setFormError(t('common.errors.couldNotSave')) } }, }) return (
{ event.preventDefault() event.stopPropagation() void form.handleSubmit() }} className={cx('byline-account-preferences-form', styles.form)} > {formError ? {formError} : null} {successMessage ? {successMessage} : null} {(field) => (
id="preferences-locale-select" size="sm" ariaLabel={t('account.preferences.languageLabel')} value={field.state.value} items={localeItems} onValueChange={(value) => { if (value != null) field.handleChange(value) }} />

{t('account.preferences.languageHelp')}

)}
({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting, })} > {({ canSubmit, isSubmitting }) => ( )}
) }