feat(crm): country dropdown + name guard for customer create/edit

Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.

Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).

Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.

i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
This commit is contained in:
Luca
2026-06-02 01:28:00 +02:00
parent 840df52581
commit db2c482ae9
9 changed files with 202 additions and 26 deletions
@@ -0,0 +1,77 @@
import React from 'react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
import { countryLabel, sortedCountryOptions } from '../../constants/countries';
/**
* Country picker whose option labels are localized country names but
* whose stored/emitted value is always the ISO 3166-1 alpha-2 code.
* Labels come from `Intl.DisplayNames` in the active UI language, so the
* list stays locale-aware without a hand-maintained translation map.
*
* A value that isn't in the curated list (e.g. legacy data) is preserved
* as its own option so editing an existing record never silently drops it.
*/
interface CountrySelectProps {
label?: string;
value: string;
onChange: (code: string) => void;
error?: string;
disabled?: boolean;
/** Label for the empty option; defaults to a translated placeholder. */
placeholder?: string;
}
export const CountrySelect: React.FC<CountrySelectProps> = ({
label,
value,
onChange,
error,
disabled,
placeholder,
}) => {
const { i18n, t } = useTranslation();
const lang = i18n.language || 'en';
const selectId = React.useId();
const options = sortedCountryOptions(lang);
const current = (value || '').trim().toUpperCase();
const hasCurrent = options.some((o) => o.code === current);
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
>
{label}
</label>
)}
<select
id={selectId}
value={current}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={clsx('input', error && 'border-red-500 focus-visible:ring-red-500')}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${selectId}-error` : undefined}
>
<option value="">{placeholder ?? t('common.selectCountry', 'Select country…')}</option>
{!hasCurrent && current && (
<option value={current}>{countryLabel(current, lang)}</option>
)}
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.label}
</option>
))}
</select>
{error && (
<p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
};
+1
View File
@@ -1,6 +1,7 @@
export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
export { CountrySelect } from './CountrySelect';
export { LocalizedDateInput } from './LocalizedDateInput';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';