From 7b676f0a1e65b58dfaeee267c4e9bcd75ccf2619 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 10:45:56 +0200 Subject: [PATCH] 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. --- public/i18n/de.json | 15 ++++++++++++--- public/i18n/en.json | 15 ++++++++++++--- src/pages/Dashboard.jsx | 2 +- src/pages/RegistrationPage.jsx | 13 +++++++++++++ src/pages/SettingsPage.jsx | 28 ++++++++++++++++++++++------ src/utils/validation.js | 13 +++++++++++++ src/utils/validation.test.js | 17 +++++++++++++++++ 7 files changed, 90 insertions(+), 13 deletions(-) diff --git a/public/i18n/de.json b/public/i18n/de.json index e3f42ef..73a67a2 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -29,7 +29,9 @@ "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", - "ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft." + "ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.", + "DISPLAY_NAME": "Anzeigename (optional)", + "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben." }, "SETTINGS": { "TITLE": "Einstellungen", @@ -50,7 +52,13 @@ "STEAM_ID": "Steam-ID", "SAVE_STEAM_ID": "Steam-ID speichern", "SAVED": "Einstellungen gespeichert.", - "ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden." + "ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.", + "PROFILE": "Profil", + "DISPLAY_NAME": "Anzeigename", + "DISPLAY_NAME_PLACEHOLDER": "z. B. Lobo", + "DISPLAY_NAME_HINT": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . -", + "SAVE_PROFILE": "Profil speichern", + "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben." }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -61,7 +69,8 @@ "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.", "STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.", - "LANGUAGE_INVALID": "Nicht unterstützte Sprache." + "LANGUAGE_INVALID": "Nicht unterstützte Sprache.", + "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." }, "INFO": { "ERROR": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 3bab297..334a73d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -29,7 +29,9 @@ "ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", - "ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account." + "ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.", + "DISPLAY_NAME": "Display name (optional)", + "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken." }, "SETTINGS": { "TITLE": "Settings", @@ -50,7 +52,13 @@ "STEAM_ID": "Steam ID", "SAVE_STEAM_ID": "Save Steam ID", "SAVED": "Settings saved.", - "ERROR_SAVE": "Could not save settings." + "ERROR_SAVE": "Could not save settings.", + "PROFILE": "Profile", + "DISPLAY_NAME": "Display name", + "DISPLAY_NAME_PLACEHOLDER": "e.g. Lobo", + "DISPLAY_NAME_HINT": "3–24 characters: letters, digits, spaces, _ . -", + "SAVE_PROFILE": "Save profile", + "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken." }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -61,7 +69,8 @@ "INVITE_CODE_REQUIRED": "Invite code is required.", "STEAM_ID_REQUIRED": "Steam ID is required.", "STEAM_ID_INVALID": "Steam ID must be a positive number.", - "LANGUAGE_INVALID": "Unsupported language." + "LANGUAGE_INVALID": "Unsupported language.", + "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." }, "INFO": { "ERROR": { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 1274b26..a5bd0a5 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -127,7 +127,7 @@ const Dashboard = () => {

{t('DASHBOARD.WELCOME_BACK')}

- {t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋 + {t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋

{hasSteamId ? ( diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx index f0acf58..20ac609 100644 --- a/src/pages/RegistrationPage.jsx +++ b/src/pages/RegistrationPage.jsx @@ -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() { /> + + + + { @@ -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() { - {t('SETTINGS.STEAM')} + {t('SETTINGS.PROFILE')} -
+ + + +

{t('SETTINGS.DISPLAY_NAME_HINT')}

+
diff --git a/src/utils/validation.js b/src/utils/validation.js index 24addee..b7e0a35 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -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, }); diff --git a/src/utils/validation.test.js b/src/utils/validation.test.js index 310e76c..f24445e 100644 --- a/src/utils/validation.test.js +++ b/src/utils/validation.test.js @@ -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', '