From 9a0f0c089245751b3b69fa7a2e45041e188a85aa Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sat, 3 Jan 2026 13:18:21 +0200 Subject: [PATCH] Translate profile forms --- common/src/parsing.ts | 3 + web/components/add-option-entry.tsx | 6 +- web/components/filters/choices.tsx | 9 +- web/components/multi-checkbox.tsx | 13 +- web/components/optional-profile-form.tsx | 173 ++++++++++++++++------- web/components/profile-about.tsx | 5 +- web/components/required-profile-form.tsx | 26 ++-- web/components/widgets/add-photos.tsx | 6 +- web/messages/fr.json | 53 ++++++- 9 files changed, 218 insertions(+), 76 deletions(-) create mode 100644 common/src/parsing.ts diff --git a/common/src/parsing.ts b/common/src/parsing.ts new file mode 100644 index 00000000..c574c66c --- /dev/null +++ b/common/src/parsing.ts @@ -0,0 +1,3 @@ +export const toKey = (str: string) => { + return str.replace(/ /g, '_').toLowerCase() +} \ No newline at end of file diff --git a/web/components/add-option-entry.tsx b/web/components/add-option-entry.tsx index 9ceb1422..2cd07410 100644 --- a/web/components/add-option-entry.tsx +++ b/web/components/add-option-entry.tsx @@ -7,18 +7,20 @@ import {MultiCheckbox} from "web/components/multi-checkbox"; import {capitalize} from "lodash"; export function AddOptionEntry(props: { + title: string choices: { [key: string]: string } setChoices: (choices: any) => void profile: ProfileWithoutUser, setProfile: (key: K, value: ProfileWithoutUser[K]) => void label: OptionTableKey, }) { - const {profile, setProfile, label, choices, setChoices} = props + const {profile, setProfile, label, choices, setChoices, title} = props return - + setProfile(label, selected)} addOption={(v: string) => { console.log(`Adding ${label}:`, v) diff --git a/web/components/filters/choices.tsx b/web/components/filters/choices.tsx index 1a682472..84b218a6 100644 --- a/web/components/filters/choices.tsx +++ b/web/components/filters/choices.tsx @@ -219,6 +219,12 @@ export const MBTI_CHOICES = { 'ESFP': 'esfp', } +export const GENDERS = { + Woman: 'female', + Man: 'male', + Other: 'other', +} + export const INVERTED_RELATIONSHIP_CHOICES = invert(RELATIONSHIP_CHOICES) export const INVERTED_RELATIONSHIP_STATUS_CHOICES = invert(RELATIONSHIP_STATUS_CHOICES) export const INVERTED_ROMANTIC_CHOICES = invert(ROMANTIC_CHOICES) @@ -228,4 +234,5 @@ export const INVERTED_EDUCATION_CHOICES = invert(EDUCATION_CHOICES) export const INVERTED_RELIGION_CHOICES = invert(RELIGION_CHOICES) export const INVERTED_LANGUAGE_CHOICES = invert(LANGUAGE_CHOICES) export const INVERTED_RACE_CHOICES = invert(RACE_CHOICES) -export const INVERTED_MBTI_CHOICES = invert(MBTI_CHOICES) \ No newline at end of file +export const INVERTED_MBTI_CHOICES = invert(MBTI_CHOICES) +export const INVERTED_GENDERS = invert(GENDERS) \ No newline at end of file diff --git a/web/components/multi-checkbox.tsx b/web/components/multi-checkbox.tsx index 743acdf9..65982acf 100644 --- a/web/components/multi-checkbox.tsx +++ b/web/components/multi-checkbox.tsx @@ -4,6 +4,8 @@ import { Input } from 'web/components/widgets/input' import { Button } from 'web/components/buttons/button' import clsx from 'clsx' import { useEffect, useMemo, useState } from 'react' +import {useT} from "web/lib/locale"; +import {toKey} from "common/parsing"; export const MultiCheckbox = (props: { // Map of label -> value @@ -19,8 +21,9 @@ export const MultiCheckbox = (props: { // - null/undefined to indicate failure/cancellation addOption?: (label: string) => string | { key: string; value: string } | null | undefined addPlaceholder?: string + translationPrefix?: string }) => { - const { choices, selected, onChange, className, addOption, addPlaceholder } = props + const { choices, selected, onChange, className, addOption, addPlaceholder, translationPrefix } = props // Keep a local merged copy to allow optimistic adds while remaining in sync with props const [localChoices, setLocalChoices] = useState<{ [key: string]: string }>(choices) @@ -39,6 +42,8 @@ export const MultiCheckbox = (props: { const [adding, setAdding] = useState(false) const [error, setError] = useState(null) + const t = useT() + // Filter visible options while typing a new option (case-insensitive label match) const filteredEntries = useMemo(() => { if (!addOption) return entries @@ -90,7 +95,7 @@ export const MultiCheckbox = (props: { { setNewLabel(e.target.value) setError(null) @@ -104,7 +109,7 @@ export const MultiCheckbox = (props: { className="h-10" /> {error && {error}} @@ -114,7 +119,7 @@ export const MultiCheckbox = (props: { {filteredEntries.map(([key, value]) => ( { if (checked) { diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 3cd27bd4..6fb32366 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -3,6 +3,7 @@ import {Title} from 'web/components/widgets/title' import {Col} from 'web/components/layout/col' import clsx from 'clsx' import {MultiCheckbox} from 'web/components/multi-checkbox' +import {useT} from 'web/lib/locale' import {Row} from 'web/components/layout/row' import {Input} from 'web/components/widgets/input' import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group' @@ -29,6 +30,7 @@ import {MultipleChoiceOptions} from "common/profiles/multiple-choice"; import { DIET_CHOICES, EDUCATION_CHOICES, + GENDERS, LANGUAGE_CHOICES, MBTI_CHOICES, POLITICAL_CHOICES, @@ -42,6 +44,7 @@ import toast from "react-hot-toast"; import {db} from "web/lib/supabase/db"; import {fetchChoices} from "web/hooks/use-choices"; import {AddOptionEntry} from "web/components/add-option-entry"; +import {convertGenderPlural, Gender} from "common/gender"; export const OptionalProfileUserForm = (props: { @@ -57,6 +60,7 @@ export const OptionalProfileUserForm = (props: { const [isSubmitting, setIsSubmitting] = useState(false) const [lookingRelationship, setLookingRelationship] = useState((profile.pref_relation_styles || []).includes('relationship')) const router = useRouter() + const t = useT() const [heightFeet, setHeightFeet] = useState( profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) / 12) @@ -180,17 +184,21 @@ export const OptionalProfileUserForm = (props: { {/* loading={isSubmitting}*/} {/* onClick={handleSubmit}*/} {/* >*/} - {/* {buttonLabel ?? 'Next / Skip'}*/} + {/* {buttonLabel ?? t('common.next', 'Next')} / {t('common.skip', 'Skip')}*/} {/* */} {/**/} - More about me -
Optional information
+ {t('profile.optional.title', 'More about me')} +
+ {t('profile.optional.subtitle', 'Optional information')} +
- + {profile.city ? ( - Change + {t('common.change', 'Change')} ) : ( @@ -218,10 +226,12 @@ export const OptionalProfileUserForm = (props: { - + - + [t(`profile.gender.${v}`, k), v]))} setChoice={(c) => setProfile('gender', c)} /> - + setProfile('pref_gender', selected)} /> - + - Min + {t('common.min', 'Min')} - Max + {t('common.max', 'Max')} { @@ -361,7 +385,9 @@ export const OptionalProfileUserForm = (props: { } - +
{Object.entries(newLinks) @@ -383,7 +409,9 @@ export const OptionalProfileUserForm = (props: { /> updateUserLink(platform, null)}> -
Remove
+
+ {t('common.remove', 'Remove')} +
))} @@ -401,8 +429,8 @@ export const OptionalProfileUserForm = (props: { placeholder={ SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site' - ? 'Username or URL' - : 'Site URL' + ? t('profile.optional.username_or_url', 'Username or URL') + : t('profile.optional.site_url', 'Site URL') } value={newLinkValue} onChange={(e) => setNewLinkValue(e.target.value)} @@ -420,19 +448,24 @@ export const OptionalProfileUserForm = (props: { disabled={!newLinkPlatform || !newLinkValue} > -
Add
+
+ {t('common.add', 'Add')} +
- +
setProfile('languages', selected)} />
@@ -441,13 +474,16 @@ export const OptionalProfileUserForm = (props: { - + setProfile('political_beliefs', selected)} /> -

Details:

+

{t('profile.optional.details', 'Details')}

setProfile('political_details', e.target.value)} @@ -457,22 +493,26 @@ export const OptionalProfileUserForm = (props: { - + setProfile('religion', selected)} /> -

Details:

setProfile('religious_beliefs', e.target.value)} className={'w-full sm:w-96'} value={profile['religious_beliefs'] ?? undefined} /> +

{t('profile.optional.details', 'Details')}

- + - + setProfile('diet', selected)} /> - + setProfile('company', e.target.value)} @@ -527,7 +576,9 @@ export const OptionalProfileUserForm = (props: { [t(`profile.education.${v}`, k), v]))} setChoice={(c) => setProfile('education_level', c)} /> - + setProfile('university', e.target.value)} @@ -561,20 +614,22 @@ export const OptionalProfileUserForm = (props: { - + [t(`common.${k.toLowerCase()}`, k), v]))} setChoice={(c) => setProfile('is_smoker', c)} /> - + - Feet + {t('profile.optional.feet', 'Feet')} { @@ -610,7 +667,7 @@ export const OptionalProfileUserForm = (props: { /> - Inches + {t('profile.optional.inches', 'Inches')} { @@ -626,9 +683,11 @@ export const OptionalProfileUserForm = (props: { value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''} /> -
OR
+
+ {t('common.or', 'OR').toUpperCase()} +
- Centimeters + {t('profile.optional.centimeters', 'Centimeters')} { @@ -664,9 +723,12 @@ export const OptionalProfileUserForm = (props: { */} - + setProfile('ethnicity', selected)} /> @@ -682,7 +744,9 @@ export const OptionalProfileUserForm = (props: { {/**/} - + {/*
*/} {/* A real or stylized photo of you is required.*/} @@ -712,7 +776,7 @@ export const OptionalProfileUserForm = (props: { loading={isSubmitting} onClick={handleSubmit} > - {buttonLabel ?? 'Next'} + {buttonLabel ?? t('common.next', 'Next')} @@ -726,6 +790,7 @@ const CitySearchBox = (props: { // search results const {cities, query, setQuery} = useCitySearch() const [focused, setFocused] = useState(false) + const t = useT() const dropdownRef = useRef(null) @@ -734,7 +799,7 @@ const CitySearchBox = (props: { setQuery(e.target.value)} - placeholder={'Search city...'} + placeholder={t('profile.optional.search_city', 'Search city...')} onFocus={() => setFocused(true)} onBlur={(e) => { // Do not hide the dropdown if clicking inside the dropdown diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index b1c15189..15eb00fc 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -33,6 +33,7 @@ import {MAX_INT, MIN_INT} from "common/constants"; import {GiFruitBowl} from "react-icons/gi"; import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa"; import {useT} from "web/lib/locale"; +import {toKey} from "common/parsing"; export function AboutRow(props: { icon: ReactNode @@ -396,7 +397,3 @@ const capitalizeAndRemoveUnderscores = (str: string) => { const withSpaces = str.replace(/_/g, ' ') return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1) } - -const toKey = (str: string) => { - return str.replace(/ /g, '_').toLowerCase() -} diff --git a/web/components/required-profile-form.tsx b/web/components/required-profile-form.tsx index 02d19df9..7db99aea 100644 --- a/web/components/required-profile-form.tsx +++ b/web/components/required-profile-form.tsx @@ -10,8 +10,9 @@ import {User} from 'common/user' import {useEditableUserInfo} from 'web/hooks/use-editable-user-info' import {LoadingIndicator} from 'web/components/widgets/loading-indicator' import {ProfileRow, ProfileWithoutUser} from 'common/profiles/profile' -import {SignupBio} from "web/components/bio/editable-bio"; -import {Editor} from "@tiptap/core"; +import {SignupBio} from "web/components/bio/editable-bio" +import {Editor} from "@tiptap/core" +import {useT} from 'web/lib/locale' export const initialRequiredState = { age: undefined, @@ -50,6 +51,7 @@ export const RequiredProfileUserForm = (props: { const {updateUsername, updateDisplayName, userInfo, updateUserState} = useEditableUserInfo(user) const [step, setStep] = useState(0) + const t = useT() const { name, @@ -84,12 +86,16 @@ export const RequiredProfileUserForm = (props: { return ( <> - The Basics + {t('profile.basics.title', 'The Basics')} {step === 1 && !profileCreatedAlready && -
No endless forms—write your own bio, your own way.
} +
+ {t('profile.basics.subtitle', 'No endless forms—write your own bio, your own way.')} +
} {step === 0 && - + {step === 0 && - + } {step === 1 && - + { console.debug('bio changed', e, profile.bio) @@ -153,7 +163,7 @@ export const RequiredProfileUserForm = (props: { } }} > - Next + {t('common.next', 'Next')} )} diff --git a/web/components/widgets/add-photos.tsx b/web/components/widgets/add-photos.tsx index 905d40c8..52130b4a 100644 --- a/web/components/widgets/add-photos.tsx +++ b/web/components/widgets/add-photos.tsx @@ -3,6 +3,7 @@ import {PlusIcon, XIcon} from '@heroicons/react/solid' import Image from 'next/image' import {uniq} from 'lodash' import {useState} from 'react' +import {useT} from 'web/lib/locale' import clsx from 'clsx' import {Col} from 'web/components/layout/col' @@ -22,6 +23,7 @@ export const AddPhotosWidget = (props: { setDescription: (url: string, description: string) => void }) => { const {user, photo_urls, pinned_url, setPhotoUrls, setPinnedUrl, setDescription, image_descriptions} = props + const t = useT() const [uploadingImages, setUploadingImages] = useState(false) @@ -116,7 +118,7 @@ export const AddPhotosWidget = (props: { // stop click bubbling so clicking/focusing the input doesn't pin the image onClick={(e) => e.stopPropagation()} aria-label={`description for image ${index}`} - placeholder="Add description" + placeholder={t('add_photos.add_description', 'Add description')} value={image_descriptions?.[url] ?? ''} onChange={(e) => { e.stopPropagation() @@ -132,7 +134,7 @@ export const AddPhotosWidget = (props: { {photo_urls?.length ? ( - The highlighted image is your profile picture + {t('add_photos.profile_picture_hint', 'The highlighted image is your profile picture')} ) : null} diff --git a/web/messages/fr.json b/web/messages/fr.json index f57b07f5..9ae3bc53 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -293,6 +293,44 @@ "privacy.effective_date": "Date d'entrée en vigueur : 1er janvier 2025", "privacy.intro.prefix": "Chez ", "privacy.intro.suffix": ", nous accordons de l'importance à la transparence et au respect de vos données. Cette politique de confidentialité explique comment nous traitons vos informations.", + "profile.optional.title": "Plus d'informations à mon sujet", + "profile.optional.subtitle": "Informations optionnelles", + "profile.optional.location": "Localisation", + "profile.optional.search_city": "Rechercher une ville...", + "profile.optional.age": "Âge", + "profile.optional.gender": "Genre", + "profile.optional.interested_in": "Intéressé(e) par", + "profile.optional.age_range": "Âgés entre", + "profile.optional.connection_type": "Type de relation", + "profile.optional.relationship_status": "Situation amoureuse", + "profile.optional.relationship_style": "Style de relation", + "profile.optional.want_kids": "Je souhaite avoir des enfants", + "profile.optional.num_kids": "Nombre actuel d'enfants", + "profile.optional.socials": "Réseaux sociaux", + "profile.optional.username_or_url": "Nom d'utilisateur ou URL", + "profile.optional.site_url": "URL du site", + "profile.optional.languages": "Langues parlées", + "profile.optional.political_beliefs": "Opinions politiques", + "profile.optional.details": "Détails", + "profile.optional.religious_beliefs": "Croyances religieuses", + "profile.optional.mbti": "Type de personnalité MBTI", + "profile.optional.diet": "Régime alimentaire", + "profile.optional.company": "Entreprise", + "profile.optional.job_title": "Poste actuel", + "profile.optional.job_title_at_company": "Poste actuel chez {company}", + "profile.optional.education_level": "Niveau d'études le plus élevé", + "profile.optional.university": "Université", + "profile.optional.smoke": "Fumez-vous ?", + "profile.optional.drinks_per_month": "Boissons alcoolisées consommées par mois", + "profile.optional.height": "Taille", + "profile.optional.feet": "Pieds", + "profile.optional.inches": "Pouces", + "profile.optional.centimeters": "Centimètres", + "profile.optional.ethnicity": "Origine ethnique", + "profile.optional.photos": "Photos", + "profile.optional.interests": "Intérets", + "profile.optional.work": "Travail", + "profile.optional.causes": "Causes morales", "privacy.info.title": "1. Informations que nous collectons", "privacy.info.text": "Nous collectons des informations de base de compte telles que votre nom, votre courriel et les données de profil. De plus, nous pouvons collecter des données d'utilisation pour améliorer les fonctionnalités de la plateforme.", "privacy.use.title": "2. Comment nous utilisons vos données", @@ -523,6 +561,12 @@ "answers.importance.2": "Important", "answers.importance.3": "Très important", "answers.content.your_thoughts": "Vos pensées (optionnelles, mais recommendées)", + "profile.basics.title": "Les bases", + "profile.basics.subtitle": "Pas de formulaire sans fin — écrivez votre propre biographie à votre manière.", + "profile.basics.display_name": "Nom d'affichage", + "profile.basics.username": "Nom d'utilisateur", + "profile.basics.bio": "Biographie", + "common.next": "Suivant", "profile.interested_in": "Intéressé·e par", "profile.age_any": "de tout âge", "profile.age_exact": "exactement {min} ans", @@ -805,5 +849,12 @@ "profile.comments.current_user_hint": "Les autres utilisateurs peuvent vous laisser des recommandations ici.", "profile.comments.other_user_hint": "Si vous les connaissez, écrivez quelque chose de sympa qui enrichira leur profil.", "profile.comments.feature_disabled_self": "Cette fonctionnalité est désactivée", - "profile.comments.feature_disabled_other": "{name} a désactivé les recommandations des autres utilisateurs." + "profile.comments.feature_disabled_other": "{name} a désactivé les recommandations des autres utilisateurs.", + "multi-checkbox.search_or_add": "Chercher ou ajouter", + "common.add": "Ajouter", + "common.or": "Ou", + "common.yes": "Oui", + "common.no": "Non", + "add_photos.add_description": "Ajouter une description", + "add_photos.profile_picture_hint": "L'image mise en surbrillance est votre photo de profil" } \ No newline at end of file