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,40 @@
/**
* Migration: normalize the Liechtenstein country code from the
* colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code
* `LI`.
*
* Background: the customer create/edit UI used to accept a free-text
* 2-char country code and the placeholder suggested `FL` for
* Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware
* lookup (services/pdfService.js countryName) and the new country
* dropdown both key on ISO, so `FL` rows render as the bare code
* instead of "Liechtenstein". The dropdown now stores `LI`; this
* migration brings existing rows in line so they display correctly and
* match new records.
*
* Scope: customer_accounts.country_code and business_profile.country_code.
* Case-insensitive so a hand-entered `fl` is caught too. The free-text
* country_name override column is left untouched — it exists precisely
* for operators who want a custom display string.
*
* Idempotent: re-runs only touch rows still holding FL, so a second run
* is a no-op.
*/
async function normalizeColumn(knex, table) {
if (!(await knex.schema.hasTable(table))) return;
if (!(await knex.schema.hasColumn(table, 'country_code'))) return;
await knex(table)
.whereRaw('UPPER(country_code) = ?', ['FL'])
.update({ country_code: 'LI' });
}
exports.up = async function(knex) {
await normalizeColumn(knex, 'customer_accounts');
await normalizeColumn(knex, 'business_profile');
};
// Irreversible by design: once normalized to the ISO code there's no
// way to know which `LI` rows were originally `FL`, and reverting would
// reintroduce the non-ISO value the rest of the system can't read.
exports.down = async function() {};
+11
View File
@@ -245,6 +245,17 @@ router.post('/', [
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
body('prefill').custom((prefill) => {
const p = prefill || {};
const hasName = ['company_name', 'display_name', 'first_name', 'last_name']
.some((k) => typeof p[k] === 'string' && p[k].trim());
if (!hasName) {
throw new Error('At least a company name or a contact name is required');
}
return true;
}),
], handleAsync(async (req, res) => {
validateRequest(req);
const { id } = await customerAccountsService.createDirect({
@@ -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';
+31
View File
@@ -0,0 +1,31 @@
/**
* Country codes offered in the customer country picker. Stored value is
* always the ISO 3166-1 alpha-2 code; Liechtenstein is `LI` (NOT the
* colloquial `FL` plate code) so it matches ISO + the PDF renderer's
* lookup. Display names are derived at runtime from `Intl.DisplayNames`
* in the active UI language, so the list stays locale-aware without a
* hand-maintained translation map.
*/
export const COUNTRY_CODES = [
'LI', 'CH', 'AT', 'DE', 'FR', 'IT', 'ES', 'PT', 'NL', 'BE', 'LU',
'GB', 'US', 'DK', 'SE', 'NO', 'FI', 'PL', 'CZ', 'SK', 'HU', 'IE',
] as const;
export type CountryCode = (typeof COUNTRY_CODES)[number];
/** Localized country name for an ISO code, falling back to the code. */
export function countryLabel(code: string, lang: string): string {
if (!code) return '';
const upper = code.trim().toUpperCase();
try {
return new Intl.DisplayNames([lang || 'en'], { type: 'region' }).of(upper) || upper;
} catch {
return upper;
}
}
/** Country codes sorted by their localized label for the active language. */
export function sortedCountryOptions(lang: string): { code: string; label: string }[] {
return COUNTRY_CODES.map((code) => ({ code, label: countryLabel(code, lang) }))
.sort((a, b) => a.label.localeCompare(b.label, lang || 'en'));
}
+4 -1
View File
@@ -82,6 +82,7 @@
"edit": "Bearbeiten",
"add": "Hinzufügen",
"sortBy": "Sortieren nach",
"selectCountry": "Land auswählen…",
"yes": "Ja",
"no": "Nein",
"back": "Zurück",
@@ -2997,7 +2998,8 @@
"savedPassiveToast": "Passiver Kunde erstellt.",
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich."
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an."
},
"passive": {
"badge": "Passiv — nur Admin",
@@ -3102,6 +3104,7 @@
"city": "Stadt",
"state": "Bundesland / Region",
"countryCode": "Land (ISO-2)",
"country": "Land",
"countryName": "Land (vollständiger Name)",
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
"featuresSection": "Kundenfunktionen",
+4 -1
View File
@@ -82,6 +82,7 @@
"edit": "Edit",
"add": "Add",
"sortBy": "Sort by",
"selectCountry": "Select country…",
"yes": "Yes",
"no": "No",
"back": "Back",
@@ -2997,7 +2998,8 @@
"savedPassiveToast": "Passive customer created.",
"savedActiveToast": "Customer created and portal invitation sent.",
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
"emailRequired": "A valid email is required."
"emailRequired": "A valid email is required.",
"nameRequired": "Enter at least a company name or a contact name."
},
"passive": {
"badge": "Passive — admin only",
@@ -3102,6 +3104,7 @@
"city": "City",
"state": "State / region",
"countryCode": "Country (ISO 2)",
"country": "Country",
"countryName": "Country (full name)",
"notesHint": "Visible only to admins. Never shown to the customer.",
"featuresSection": "Customer features",
@@ -19,7 +19,7 @@ import {
Clock,
} from 'lucide-react';
import { Button, Card, Input, Loading } from '../../components/common';
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
import { DecimalInput } from '../../components/common/DecimalInput';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
@@ -538,20 +538,18 @@ export const CustomerDetailPage: React.FC = () => {
<Input value={form.state || ''} onChange={setField('state')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
<Input
<CountrySelect
label={t('customers.detail.country', 'Country') as string}
value={form.countryCode || ''}
onChange={setField('countryCode')}
maxLength={2}
placeholder="FL"
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
/>
</div>
<div>
{/* Free-text country name override (migration 107). When
left empty the PDF renderer falls back to the locale-
aware lookup on the abbreviation; useful when the
abbreviation isn't an ISO code (e.g. "FL" for
Liechtenstein, which is "LI" in ISO). */}
aware lookup on the ISO code. Kept for the rare case
where an operator wants a custom display name that
differs from the standard localized label. */}
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
<Input
value={form.countryName || ''}