feat(profile): set a display name and greet with it on the dashboard

Settings: new Profil card with display name and Steam ID. Registration: optional display name. The dashboard falls back to the email prefix when no display name is set.
This commit is contained in:
lobo committed 2026-09-24 10:45:56 +02:00
1 parent 6773040d59
commit 7b676f0a1e
7 files changed
+90 -13

No files matched your search

+1 -1
View File
@@ -127,7 +127,7 @@ const Dashboard = () => {
<div className="relative min-w-0">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
</h1>
{hasSteamId ? (
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
+13
View File
@@ -17,6 +17,7 @@ const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
};
export default function RegistrationPage() {
@@ -36,6 +37,7 @@ export default function RegistrationPage() {
passwordConfirmation: '',
inviteCode: '',
steamId: '',
displayName: '',
},
});
@@ -51,6 +53,7 @@ export default function RegistrationPage() {
password: values.password,
inviteCode: values.inviteCode,
steamId: values.steamId,
displayName: values.displayName,
}),
});
@@ -122,6 +125,16 @@ export default function RegistrationPage() {
/>
</FormField>
<FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input
id="displayName"
type="text"
maxLength={24}
aria-invalid={!!errors.displayName}
{...register('displayName')}
/>
</FormField>
<FormField id="steamId" label={t('REGISTRATION.STEAM_ID')} error={errors.steamId} t={t}>
<Input
id="steamId"
+22 -6
View File
@@ -20,6 +20,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
@@ -49,7 +50,7 @@ export default function SettingsPage() {
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''},
defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
});
useEffect(() => {
@@ -86,6 +87,7 @@ export default function SettingsPage() {
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
steamId: data.steamId ?? '',
displayName: data.displayName ?? '',
});
} catch (err) {
console.error('Settings load error:', err);
@@ -146,6 +148,7 @@ export default function SettingsPage() {
useDarkTheme: next,
language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
};
@@ -155,17 +158,19 @@ export default function SettingsPage() {
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
});
};
const onSubmitSteamId = async (values) => {
const onSubmitProfile = async (values) => {
const result = await persistSettings({
useDarkTheme: isDarkMode,
language: i18n.language,
steamId: values.steamId,
displayName: values.displayName,
});
if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId} : p));
setProfile((p) => (p ? {...p, steamId: values.steamId, displayName: values.displayName} : p));
}
};
@@ -240,10 +245,21 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.STEAM')}</CardTitle>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate>
<form onSubmit={handleSubmit(onSubmitProfile)} className="space-y-4" noValidate>
<FormField id="displayName" label={t('SETTINGS.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input
id="displayName"
type="text"
maxLength={24}
placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')}
aria-invalid={!!errors.displayName}
{...register('displayName')}
/>
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
</FormField>
<FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}>
<Input
id="steamId"
@@ -256,7 +272,7 @@ export default function SettingsPage() {
</FormField>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
{t('SETTINGS.SAVE_STEAM_ID')}
{t('SETTINGS.SAVE_PROFILE')}
</Button>
</form>
</CardContent>
+13
View File
@@ -22,6 +22,17 @@ const steamIdField = z
.min(1, 'VALIDATION.STEAM_ID_REQUIRED')
.regex(/^\d{17}$/, 'VALIDATION.STEAM_ID_INVALID');
// Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends.
// An empty field means "no display name" and is sent as null.
const displayNameField = z
.string()
.trim()
.optional()
.transform((v) => (v ? v : null))
.refine((v) => v === null || /^[\p{L}\p{N}_.-][\p{L}\p{N} _.-]{1,22}[\p{L}\p{N}_.-]$/u.test(v), {
message: 'VALIDATION.DISPLAY_NAME_INVALID',
});
export const registrationSchema = z
.object({
email: emailField,
@@ -29,6 +40,7 @@ export const registrationSchema = z
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField,
steamId: steamIdField,
displayName: displayNameField,
})
.refine((d) => d.password === d.passwordConfirmation, {
path: ['passwordConfirmation'],
@@ -39,4 +51,5 @@ export const settingsSchema = z.object({
useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
steamId: steamIdField,
displayName: displayNameField,
});
+17
View File
@@ -28,3 +28,20 @@ describe('steamId validation', () => {
expect(result.steamId).toBe('76561198005679788');
});
});
describe('displayName validation', () => {
const withName = (displayName) => ({...settings('76561198005679788'), displayName});
it('turns an empty field into null (no display name)', () => {
expect(settingsSchema.parse(withName(' ')).displayName).toBeNull();
});
it('trims and keeps a valid name', () => {
expect(settingsSchema.parse(withName(' Lobo Corp_1 ')).displayName).toBe('Lobo Corp_1');
});
it.each(['ab', '<script>', 'a'.repeat(25)])('rejects %j', (displayName) => {
expect(settingsSchema.safeParse(withName(displayName)).success).toBe(false);
});
});