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
@@ -23,7 +23,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
import { Button, Input } from '../common';
import { Button, CountrySelect, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
@@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
// Seed preferredLanguage with the profile default once the profile
// arrives (only if the field is still empty so we don't clobber
// explicit user input).
// Seed preferredLanguage + countryCode with the profile defaults once
// the profile arrives (only if the field is still empty so we don't
// clobber explicit user input).
React.useEffect(() => {
if (profileDefaultLocale && !form.preferredLanguage) {
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
}
setForm((prev) => {
const next = { ...prev };
if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale;
if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase();
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profileDefaultLocale]);
}, [profileDefaultLocale, profileCountryCode]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email);
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
const hasName = !!(form.companyName.trim() || form.displayName.trim()
|| form.firstName.trim() || form.lastName.trim());
const isValid = hasEmail && hasName;
const handleSave = async (mode: 'passive' | 'invite') => {
if (!isValid) {
if (!hasEmail) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
if (!hasName) {
toast.error(t('customers.create.nameRequired',
'Enter at least a company name or a contact name.'));
return;
}
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
@@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
value={form.state}
onChange={setField('state')}
/>
<Input
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
<CountrySelect
label={t('customers.detail.country', 'Country') as string}
value={form.countryCode}
onChange={setField('countryCode')}
placeholder="CH"
maxLength={2}
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
@@ -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';