From cb98314bec3aeccf7cab58f194743ce76f9fbe50 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 19 Feb 2026 00:56:57 +0100 Subject: [PATCH] Add metric vs imperial switch --- web/components/filters/location-filter.tsx | 52 ++- web/components/measurement-system-toggle.tsx | 47 +++ web/components/profile-about.tsx | 191 +++++++---- .../profile/profile-primary-info.tsx | 65 ++-- web/hooks/use-measurement-system.ts | 39 +++ web/lib/measurement-utils.ts | 69 ++++ web/messages/de.json | 3 + web/messages/fr.json | 3 + web/pages/settings.tsx | 298 +++++++++++------- 9 files changed, 545 insertions(+), 222 deletions(-) create mode 100644 web/components/measurement-system-toggle.tsx create mode 100644 web/hooks/use-measurement-system.ts create mode 100644 web/lib/measurement-utils.ts diff --git a/web/components/filters/location-filter.tsx b/web/components/filters/location-filter.tsx index af381e08..cfd9e3fd 100644 --- a/web/components/filters/location-filter.tsx +++ b/web/components/filters/location-filter.tsx @@ -11,7 +11,9 @@ import {useT} from 'web/lib/locale' import {XIcon} from '@heroicons/react/solid' import {uniqBy} from 'lodash' import {buildArray} from 'common/util/array' -import {OriginLocation} from "common/filters"; +import {OriginLocation} from 'common/filters' +import {useMeasurementSystem} from 'web/hooks/use-measurement-system' +import {formatDistance, kmToMiles, milesToKm} from 'web/lib/measurement-utils' export function LocationFilterText(props: { location: OriginLocation | undefined | null @@ -19,21 +21,27 @@ export function LocationFilterText(props: { radius: number highlightedClass?: string }) { - const { location, radius, highlightedClass } = props + const {location, radius, highlightedClass} = props + const {measurementSystem} = useMeasurementSystem() const t = useT() if (!location) { return ( - {t('filter.location.any', 'Any')} + + {t('filter.location.any', 'Any')} + {t('filter.location', 'location')} ) } + + const formattedDistance = formatDistance(radius, measurementSystem) + return ( - {radius} miles + {formattedDistance} {' '} {t('filter.near', 'near')}{' '} {location.name} @@ -62,9 +70,9 @@ export function LocationFilter(props: { youProfile: Profile | undefined | null locationFilterProps: LocationFilterProps }) { - const { youProfile } = props + const {youProfile} = props - const { location, setLocation, radius, setRadius } = props.locationFilterProps + const {location, setLocation, radius, setRadius} = props.locationFilterProps const youCity = youProfile && profileToCity(youProfile) @@ -79,13 +87,18 @@ export function LocationFilter(props: { if (!city) { setLocation(undefined) } else { - setLocation({ id: city.geodb_city_id, name: city.city, lat: city.latitude, lon: city.longitude }) + setLocation({ + id: city.geodb_city_id, + name: city.city, + lat: city.latitude, + lon: city.longitude, + }) setLastCity(city) } } // search results - const { cities, loading, query, setQuery } = useCitySearch() + const {cities, loading, query, setQuery} = useCitySearch() const listedCities = uniqBy( buildArray(cities, lastCity, youCity), @@ -105,7 +118,7 @@ export function LocationFilter(props: { /> - {location && } + {location && } void }) { - const { radius, setRadius } = props + const {radius, setRadius} = props + const {measurementSystem} = useMeasurementSystem() const snapValues = [10, 50, 100, 200, 300, 500] @@ -133,16 +147,19 @@ function DistanceSlider(props: { const closest = snapValues.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev ) - setRadius(closest) + // Convert back to miles if needed for internal storage + const closestMiles = measurementSystem === 'metric' ? kmToMiles(closest) : closest + setRadius(closestMiles) } const min = snapValues[0] const max = snapValues[snapValues.length - 1] + return ( ({ @@ -160,7 +177,7 @@ function LocationResults(props: { loading: boolean className?: string }) { - const { showAny, cities, onCitySelected, loading, className } = props + const {showAny, cities, onCitySelected, loading, className} = props // delay loading animation by 150 ms const [debouncedLoading, setDebouncedLoading] = useState(loading) @@ -182,7 +199,10 @@ function LocationResults(props: { className="hover:bg-primary-200 hover:text-ink-950 cursor-pointer px-4 py-2 transition-colors" > - + {t('filter.location.set_any_city', 'Set to Any City')} @@ -200,8 +220,8 @@ function LocationResults(props: { })} {debouncedLoading && (
-
-
+
+
)} diff --git a/web/components/measurement-system-toggle.tsx b/web/components/measurement-system-toggle.tsx new file mode 100644 index 00000000..c04dabe5 --- /dev/null +++ b/web/components/measurement-system-toggle.tsx @@ -0,0 +1,47 @@ +import {Switch} from '@headlessui/react' +import {useT} from 'web/lib/locale' +import {useMeasurementSystem} from 'web/hooks/use-measurement-system' +import clsx from 'clsx' +import {Row} from 'web/components/layout/row' + +export default function MeasurementSystemToggle(props: { className?: string }) { + const {className} = props + const {measurementSystem, setMeasurementSystem} = useMeasurementSystem() + const t = useT() + + const isEnabled = measurementSystem === 'metric' + + return ( + + + {t('settings.measurement.imperial', 'Imperial')} + + + + setMeasurementSystem(enabled ? 'metric' : 'imperial') + } + className={clsx( + isEnabled ? 'bg-primary-500' : 'bg-ink-300', + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none' + )} + > + + + + + {t('settings.measurement.metric', 'Metric')} + + + ) +} diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index 8dad4c6d..0b90a414 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import {convertRace, type RelationshipType,} from 'web/lib/util/convert-types' +import {convertRace, type RelationshipType} from 'web/lib/util/convert-types' import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text' import React, {ReactNode} from 'react' import { @@ -10,14 +10,14 @@ import { INVERTED_POLITICAL_CHOICES, INVERTED_RELATIONSHIP_STATUS_CHOICES, INVERTED_RELIGION_CHOICES, - RELATIONSHIP_ICONS + RELATIONSHIP_ICONS, } from 'web/components/filters/choices' import {BiSolidDrink} from 'react-icons/bi' import {BsPersonHeart, BsPersonVcard} from 'react-icons/bs' import {FaChild} from 'react-icons/fa6' import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap,} from 'react-icons/lu' import {MdLanguage, MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md' -import {PiHandsPrayingBold, PiMagnifyingGlassBold,} from 'react-icons/pi' +import {PiHandsPrayingBold, PiMagnifyingGlassBold} from 'react-icons/pi' import {RiScales3Line} from 'react-icons/ri' import {Col} from 'web/components/layout/col' import {Row} from 'web/components/layout/row' @@ -26,15 +26,17 @@ import {convertGenderPlural, Gender} from 'common/gender' import {HiOutlineGlobe} from 'react-icons/hi' import {UserHandles} from 'web/components/user/user-handles' import {Profile} from 'common/profiles/profile' -import {UserActivity} from "common/user"; -import {ClockIcon} from "@heroicons/react/solid"; -import {MAX_INT, MIN_INT} from "common/constants"; -import {GiFruitBowl} from "react-icons/gi"; -import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa"; -import {useLocale, useT} from "web/lib/locale"; -import {useChoices} from "web/hooks/use-choices"; -import {getSeekingGenderText} from "web/lib/profile/seeking"; -import {TbBulb, TbCheck, TbMoodSad, TbUsers} from "react-icons/tb"; +import {UserActivity} from 'common/user' +import {ClockIcon} from '@heroicons/react/solid' +import {MeasurementSystem} from 'web/hooks/use-measurement-system' +import {formatHeight} from 'web/lib/measurement-utils' +import {MAX_INT, MIN_INT} from 'common/constants' +import {GiFruitBowl} from 'react-icons/gi' +import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from 'react-icons/fa' +import {useLocale, useT} from 'web/lib/locale' +import {useChoices} from 'web/hooks/use-choices' +import {getSeekingGenderText} from 'web/lib/profile/seeking' +import {TbBulb, TbCheck, TbMoodSad, TbUsers} from 'react-icons/tb' export function AboutRow(props: { icon: ReactNode @@ -66,17 +68,15 @@ export function AboutRow(props: { return (
{icon}
-
- {formattedText} -
+
{formattedText}
) } export default function ProfileAbout(props: { - profile: Profile, - userActivity?: UserActivity, - isCurrentUser: boolean, + profile: Profile + userActivity?: UserActivity + isCurrentUser: boolean }) { const {profile, userActivity, isCurrentUser} = props const t = useT() @@ -96,25 +96,44 @@ export default function ProfileAbout(props: { } - text={profile.work?.map(id => workById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} + text={ + profile.work + ?.map((id) => workById[id]) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, locale)) as string[] + } /> } - text={profile.political_beliefs?.map(belief => t(`profile.political.${belief}`, INVERTED_POLITICAL_CHOICES[belief]))} + text={profile.political_beliefs?.map((belief) => + t(`profile.political.${belief}`, INVERTED_POLITICAL_CHOICES[belief]) + )} suffix={profile.political_details} /> } - text={profile.religion?.map(belief => t(`profile.religion.${belief}`, INVERTED_RELIGION_CHOICES[belief]))} + text={profile.religion?.map((belief) => + t(`profile.religion.${belief}`, INVERTED_RELIGION_CHOICES[belief]) + )} suffix={profile.religious_beliefs} /> } - text={profile.interests?.map(id => interestsById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} + text={ + profile.interests + ?.map((id) => interestsById[id]) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, locale)) as string[] + } /> } - text={profile.causes?.map(id => causesById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} + text={ + profile.causes + ?.map((id) => causesById[id]) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, locale)) as string[] + } /> } @@ -131,15 +150,21 @@ export default function ProfileAbout(props: { } - text={profile.diet?.map(e => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e]))} + text={profile.diet?.map((e) => + t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e]) + )} /> } - text={profile.languages?.map(v => t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]))} + text={profile.languages?.map((v) => + t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]) + )} /> - {!isCurrentUser && } + {!isCurrentUser && ( + + )} ) @@ -155,7 +180,12 @@ function Seeking(props: { profile: Profile }) { text: prefGender?.length == 5 ? ['people'] - : prefGender?.map((gender) => t(`profile.gender.plural.${gender}`, convertGenderPlural(gender as Gender)).toLowerCase()), + : prefGender?.map((gender) => + t( + `profile.gender.plural.${gender}`, + convertGenderPlural(gender as Gender) + ).toLowerCase() + ), preText: t('profile.interested_in', 'Interested in'), asSentence: true, capitalizeFirstLetterOption: false, @@ -174,7 +204,10 @@ function Seeking(props: { profile: Profile }) { ? t('profile.age_older_than', 'older than {min}', {min}) : noMin ? t('profile.age_younger_than', 'younger than {max}', {max}) - : t('profile.age_between', 'between {min} - {max} years old', {min, max}) + : t('profile.age_between', 'between {min} - {max} years old', { + min, + max, + }) if (!prefGender || prefGender.length < 1) { return <> @@ -209,7 +242,12 @@ function RelationshipStatus(props: { profile: Profile }) { return ( t(`profile.relationship_status.${v}`, INVERTED_RELATIONSHIP_STATUS_CHOICES[v]))} + text={relationship_status?.map((v) => + t( + `profile.relationship_status.${v}`, + INVERTED_RELATIONSHIP_STATUS_CHOICES[v] + ) + )} /> ) } @@ -223,7 +261,12 @@ function Education(props: { profile: Profile }) { let text = '' if (educationLevel) { - text += capitalizeAndRemoveUnderscores(t(`profile.education.${educationLevel}`, INVERTED_EDUCATION_CHOICES[educationLevel])) + text += capitalizeAndRemoveUnderscores( + t( + `profile.education.${educationLevel}`, + INVERTED_EDUCATION_CHOICES[educationLevel] + ) + ) } if (university) { if (educationLevel) text += ` ${t('profile.at', 'at')} ` @@ -232,12 +275,7 @@ function Education(props: { profile: Profile }) { if (text.length === 0) { return <> } - return ( - } - text={text} - /> - ) + return } text={text}/> } function Occupation(props: { profile: Profile }) { @@ -269,7 +307,10 @@ function Smoker(props: { profile: Profile }) { if (isSmoker == null) return null if (isSmoker) { return ( - } text={t('profile.smokes', 'Smokes')}/> + } + text={t('profile.smokes', 'Smokes')} + /> ) } return ( @@ -299,7 +340,9 @@ function Drinks(props: { profile: Profile }) { text={ drinksPerMonth === 1 ? t('profile.drinks_one', '1 drink per month') - : t('profile.drinks_many', '{count} drinks per month', {count: drinksPerMonth}) + : t('profile.drinks_many', '{count} drinks per month', { + count: drinksPerMonth, + }) } /> ) @@ -312,14 +355,14 @@ function WantsKids(props: { profile: Profile }) { if (wantsKidsStrength == null || wantsKidsStrength < 0) return null const wantsKidsText = wantsKidsStrength == 0 - ? t('profile.wants_kids_0', "Does not want children") + ? t('profile.wants_kids_0', 'Does not want children') : wantsKidsStrength == 1 - ? t('profile.wants_kids_1', "Prefers not to have children") + ? t('profile.wants_kids_1', 'Prefers not to have children') : wantsKidsStrength == 2 - ? t('profile.wants_kids_2', "Neutral or open to having children") + ? t('profile.wants_kids_2', 'Neutral or open to having children') : wantsKidsStrength == 3 - ? t('profile.wants_kids_3', "Leaning towards wanting children") - : t('profile.wants_kids_4', "Wants children") + ? t('profile.wants_kids_3', 'Leaning towards wanting children') + : t('profile.wants_kids_4', 'Wants children') return ( } - text={t('profile.last_online', 'Active {time}', {time: fromNow(lastOnlineTime, true, t, locale)})} + text={t('profile.last_online', 'Active {time}', { + time: fromNow(lastOnlineTime, true, t, locale), + })} /> ) } @@ -351,35 +396,37 @@ function Big5Traits(props: { profile: Profile }) { key: 'big5_openness', icon: , label: t('profile.big5_openness', 'Openness'), - value: profile.big5_openness + value: profile.big5_openness, }, { key: 'big5_conscientiousness', icon: , label: t('profile.big5_conscientiousness', 'Conscientiousness'), - value: profile.big5_conscientiousness + value: profile.big5_conscientiousness, }, { key: 'big5_extraversion', icon: , label: t('profile.big5_extraversion', 'Extraversion'), - value: profile.big5_extraversion + value: profile.big5_extraversion, }, { key: 'big5_agreeableness', icon: , label: t('profile.big5_agreeableness', 'Agreeableness'), - value: profile.big5_agreeableness + value: profile.big5_agreeableness, }, { key: 'big5_neuroticism', icon: , label: t('profile.big5_neuroticism', 'Neuroticism'), - value: profile.big5_neuroticism - } + value: profile.big5_neuroticism, + }, ] - const hasAnyTraits = traits.some(trait => trait.value !== null && trait.value !== undefined) + const hasAnyTraits = traits.some( + (trait) => trait.value !== null && trait.value !== undefined + ) if (!hasAnyTraits) { return <> @@ -425,25 +472,37 @@ function HasKids(props: { profile: Profile }) { const t = useT() const {profile} = props if (typeof profile.has_kids !== 'number') return null - const hasKidsText = profile.has_kids == 0 - ? t('profile.has_kids.doesnt_have_kids', 'Does not have children') - : profile.has_kids > 1 - ? t('profile.has_kids_many', 'Has {count} kids', {count: profile.has_kids}) - : t('profile.has_kids_one', 'Has {count} kid', {count: profile.has_kids}) + const hasKidsText = + profile.has_kids == 0 + ? t('profile.has_kids.doesnt_have_kids', 'Does not have children') + : profile.has_kids > 1 + ? t('profile.has_kids_many', 'Has {count} kids', { + count: profile.has_kids, + }) + : t('profile.has_kids_one', 'Has {count} kid', { + count: profile.has_kids, + }) const faChild = - const icon = profile.has_kids === 0 ? ( -
- {faChild} -
- {/*
*/} -
+ const icon = + profile.has_kids === 0 ? ( +
+ {faChild} +
+ {/*
*/} +
+
-
- ) : faChild + ) : ( + faChild + ) return } -export const formatProfileValue = (key: string, value: any) => { +export const formatProfileValue = ( + key: string, + value: any, + measurementSystem: MeasurementSystem = 'imperial' +) => { if (Array.isArray(value)) { return value.join(', ') } @@ -456,7 +515,7 @@ export const formatProfileValue = (key: string, value: any) => { case 'has_pets': return value ? 'Yes' : 'No' case 'height_in_inches': - return `${Math.floor(value / 12)}' ${Math.round(value % 12)}"` + return formatHeight(value, measurementSystem) case 'pref_age_max': case 'pref_age_min': return null // handle this in a special case diff --git a/web/components/profile/profile-primary-info.tsx b/web/components/profile/profile-primary-info.tsx index 4ac0ef99..e77d4b30 100644 --- a/web/components/profile/profile-primary-info.tsx +++ b/web/components/profile/profile-primary-info.tsx @@ -1,38 +1,55 @@ -import { ReactNode } from 'react' -import { capitalize } from 'lodash' -import { IoLocationOutline } from 'react-icons/io5' -import { MdHeight } from 'react-icons/md' - -import { Row } from 'web/components/layout/row' +import {ReactNode} from 'react' +import {capitalize} from 'lodash' +import {IoLocationOutline} from 'react-icons/io5' +import {MdHeight} from 'react-icons/md' +import {Row} from 'web/components/layout/row' import GenderIcon from '../gender-icon' -import { Gender, convertGender } from 'common/gender' -import { formatProfileValue } from '../profile-about' -import { Profile } from 'common/profiles/profile' -import { useT } from 'web/lib/locale' +import {convertGender, Gender} from 'common/gender' +import {formatProfileValue} from '../profile-about' +import {Profile} from 'common/profiles/profile' +import {useT} from 'web/lib/locale' +import {useMeasurementSystem} from 'web/hooks/use-measurement-system' export default function ProfilePrimaryInfo(props: { profile: Profile }) { - const { profile } = props + const {profile} = props const t = useT() + const {measurementSystem} = useMeasurementSystem() const stateOrCountry = profile.country === 'United States of America' ? profile.region_code : profile.country return ( - {profile.city && } - />} - {profile.gender && - } - />} + {profile.city && ( + } + /> + )} + {profile.gender && ( + + } + /> + )} {profile.height_in_inches != null && ( } + text={formatProfileValue( + 'height_in_inches', + profile.height_in_inches, + measurementSystem + )} + icon={} /> )} @@ -40,7 +57,7 @@ export default function ProfilePrimaryInfo(props: { profile: Profile }) { } function IconWithInfo(props: { text: string; icon: ReactNode }) { - const { text, icon } = props + const {text, icon} = props return (
{icon}
diff --git a/web/hooks/use-measurement-system.ts b/web/hooks/use-measurement-system.ts new file mode 100644 index 00000000..48677fe0 --- /dev/null +++ b/web/hooks/use-measurement-system.ts @@ -0,0 +1,39 @@ +import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state' +import {getLocale} from "web/lib/locale-cookie"; + +export type MeasurementSystem = 'metric' | 'imperial' + +export const useMeasurementSystem = () => { + // Get default based on locale + const getDefaultMeasurementSystem = (): MeasurementSystem => { + if (typeof window !== 'undefined') { + try { + const saved = localStorage.getItem('measurement-system') + if (saved) { + const parsed = JSON.parse(saved) + if (parsed === 'metric' || parsed === 'imperial') { + return parsed + } + } + // Default based on locale + return getLocale() === 'en' ? 'imperial' : 'metric' + } catch (e) { + // Fallback to imperial if anything goes wrong + return 'imperial' + } + } + return 'imperial' // server-side default + } + + const [measurementSystem, setMeasurementSystemState] = + usePersistentLocalState( + getDefaultMeasurementSystem(), + 'measurement-system' + ) + + const setMeasurementSystem = (newSystem: MeasurementSystem) => { + setMeasurementSystemState(newSystem) + } + + return {measurementSystem, setMeasurementSystem} +} diff --git a/web/lib/measurement-utils.ts b/web/lib/measurement-utils.ts new file mode 100644 index 00000000..a1429033 --- /dev/null +++ b/web/lib/measurement-utils.ts @@ -0,0 +1,69 @@ +import {MeasurementSystem} from 'web/hooks/use-measurement-system' + +// Conversion factors +const INCHES_TO_CM = 2.54 +const MILES_TO_KM = 1.60934 + +/** + * Format height in inches according to the specified measurement system + */ +export function formatHeight( + heightInInches: number, + measurementSystem: MeasurementSystem +): string { + if (measurementSystem === 'metric') { + // Convert to centimeters + const cm = Math.round(heightInInches * INCHES_TO_CM) + return `${cm} cm` + } else { + // Show in feet and inches + const feet = Math.floor(heightInInches / 12) + const inches = Math.round(heightInInches % 12) + return `${feet}' ${inches}"` + } +} + +/** + * Format distance in miles according to the specified measurement system + */ +export function formatDistance( + distanceInMiles: number, + measurementSystem: MeasurementSystem +): string { + if (measurementSystem === 'metric') { + // Convert to kilometers + const km = Math.round(distanceInMiles * MILES_TO_KM) + return `${km} km` + } else { + // Show in miles + return `${distanceInMiles} miles` + } +} + +/** + * Convert inches to centimeters + */ +export function inchesToCm(inches: number): number { + return inches * INCHES_TO_CM +} + +/** + * Convert centimeters to inches + */ +export function cmToInches(cm: number): number { + return cm / INCHES_TO_CM +} + +/** + * Convert miles to kilometers + */ +export function milesToKm(miles: number): number { + return miles * MILES_TO_KM +} + +/** + * Convert kilometers to miles + */ +export function kmToMiles(km: number): number { + return km / MILES_TO_KM +} diff --git a/web/messages/de.json b/web/messages/de.json index 20eb457d..f89834e2 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1116,6 +1116,9 @@ "more_options_user.edit_bio": "Bio bearbeiten", "vote.with_priority": "mit Priorität", "settings.general.font": "Schriftart", + "settings.general.measurement": "Maßeinheitensystem", + "settings.measurement.imperial": "Imperial", + "settings.measurement.metric": "Metrisch", "font.atkinson": "Atkinson Hyperlegible", "font.system-sans": "System Sans", "font.classic-serif": "Klassische Serifenschrift" diff --git a/web/messages/fr.json b/web/messages/fr.json index 631c8d10..9c745c5b 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1116,6 +1116,9 @@ "more_options_user.edit_bio": "Options de biographie", "vote.with_priority": "avec priorité", "settings.general.font": "Police", + "settings.general.measurement": "Système de mesure", + "settings.measurement.imperial": "Impérial", + "settings.measurement.metric": "Métrique", "font.atkinson": "Atkinson Hyperlegible", "font.system-sans": "Sans-serif système", "font.classic-serif": "Serif classique" diff --git a/web/pages/settings.tsx b/web/pages/settings.tsx index 89066223..e7f88584 100644 --- a/web/pages/settings.tsx +++ b/web/pages/settings.tsx @@ -8,25 +8,26 @@ import {NoSEO} from 'web/components/NoSEO' import {UncontrolledTabs} from 'web/components/layout/tabs' import {PageBase} from 'web/components/page-base' import {Title} from 'web/components/widgets/title' -import {useRedirectIfSignedOut} from "web/hooks/use-redirect-if-signed-out"; -import {deleteAccount} from "web/lib/util/delete"; -import router from "next/router"; -import {Button} from "web/components/buttons/button"; -import {updateEmail} from 'firebase/auth'; -import {NotificationSettings} from "web/components/notifications"; -import ThemeIcon from "web/components/theme-icon"; -import {WithPrivateUser} from "web/components/user/with-user"; -import {sendPasswordReset} from "web/lib/firebase/password"; -import {AboutSettings} from "web/components/about-settings"; -import {LanguagePicker} from "web/components/language/language-picker"; +import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out' +import {deleteAccount} from 'web/lib/util/delete' +import router from 'next/router' +import {Button} from 'web/components/buttons/button' +import {updateEmail} from 'firebase/auth' +import {NotificationSettings} from 'web/components/notifications' +import ThemeIcon from 'web/components/theme-icon' +import {WithPrivateUser} from 'web/components/user/with-user' +import {sendPasswordReset} from 'web/lib/firebase/password' +import {AboutSettings} from 'web/components/about-settings' +import {LanguagePicker} from 'web/components/language/language-picker' import {FontPicker} from 'web/components/font-picker' -import {useT} from "web/lib/locale"; +import {useT} from 'web/lib/locale' import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal' -import {EmailVerificationButton} from "web/components/email-verification-button"; +import {EmailVerificationButton} from 'web/components/email-verification-button' import {api} from 'web/lib/api' -import {useUser} from "web/hooks/use-user"; -import {isNativeMobile} from "web/lib/util/webview"; -import {useFirebaseUser} from "web/hooks/use-firebase-user"; +import {useUser} from 'web/hooks/use-user' +import {isNativeMobile} from 'web/lib/util/webview' +import {useFirebaseUser} from 'web/hooks/use-firebase-user' +import MeasurementSystemToggle from 'web/components/measurement-system-toggle' export default function NotificationsPage() { const t = useT() @@ -38,9 +39,18 @@ export default function NotificationsPage() { }, - {title: t('settings.tabs.notifications', 'Notifications'), content: }, - {title: t('settings.tabs.about', 'About'), content: }, + { + title: t('settings.tabs.general', 'General'), + content: , + }, + { + title: t('settings.tabs.notifications', 'Notifications'), + content: , + }, + { + title: t('settings.tabs.about', 'About'), + content: , + }, ]} trackingName={'settings page'} /> @@ -50,39 +60,50 @@ export default function NotificationsPage() { export const GeneralSettings = () => ( - {user => } + {(user) => } ) -const LoadedGeneralSettings = (props: { - privateUser: PrivateUser, -}) => { +const LoadedGeneralSettings = (props: { privateUser: PrivateUser }) => { const {privateUser} = props const [isChangingEmail, setIsChangingEmail] = useState(false) const [showHiddenProfiles, setShowHiddenProfiles] = useState(false) - const {register, handleSubmit, formState: {errors}, reset} = useForm<{ newEmail: string }>() + const { + register, + handleSubmit, + formState: {errors}, + reset, + } = useForm<{ newEmail: string }>() const t = useT() const user = useFirebaseUser() if (!user) return null const handleDeleteAccount = async () => { - const confirmed = confirm(t('settings.delete_confirm', "Are you sure you want to delete your profile? This cannot be undone.")) + const confirmed = confirm( + t( + 'settings.delete_confirm', + 'Are you sure you want to delete your profile? This cannot be undone.' + ) + ) if (confirmed) { toast .promise(deleteAccount(), { loading: t('settings.delete.loading', 'Deleting account...'), success: () => { router.push('/') - return t('settings.delete.success', 'Your account has been deleted.') + return t( + 'settings.delete.success', + 'Your account has been deleted.' + ) }, error: () => { return t('settings.delete.error', 'Failed to delete account.') }, }) .catch(() => { - console.log("Failed to delete account") + console.log('Failed to delete account') }) } } @@ -92,115 +113,151 @@ const LoadedGeneralSettings = (props: { try { await updateEmail(user, newEmail) - toast.success(t('settings.email.updated_success', 'Email updated successfully')) + toast.success( + t('settings.email.updated_success', 'Email updated successfully') + ) setIsChangingEmail(false) reset() // Force a reload to update the UI with the new email // window.location.reload() } catch (error: any) { console.error('Error updating email:', error) - toast.error(error.message || t('settings.email.update_failed', 'Failed to update email')) + toast.error( + error.message || + t('settings.email.update_failed', 'Failed to update email') + ) } } const onSubmitEmailChange = (data: { newEmail: string }) => { if (!user) return if (data.newEmail === user.email) { - toast.error(t('settings.email.same_as_current', 'New email is the same as current email')) + toast.error( + t( + 'settings.email.same_as_current', + 'New email is the same as current email' + ) + ) return } changeUserEmail(data.newEmail) } - return <> -
-

{t('settings.general.theme', 'Theme')}

- + return ( + <> +
-

{t('settings.general.language', 'Language')}

- +

{t('settings.general.language', 'Language')}

+ -

{t('settings.general.font', 'Font')}

- +

{t('settings.general.measurement', 'Measurement System')}

+ -

{t('settings.data_privacy.title', 'Data & Privacy')}

- +

{t('settings.general.theme', 'Theme')}

+ -

{t('settings.general.people', 'People')}

- {/*
{t('settings.hidden_profiles.title', 'Hidden profiles')}
*/} - +

{t('settings.general.font', 'Font')}

+ -

{t('settings.general.account', 'Account')}

-
{t('settings.general.email', 'Email')}
+

{t('settings.data_privacy.title', 'Data & Privacy')}

+ - - - {!isChangingEmail ? ( - - ) : ( -
- - - {errors.newEmail && ( - - {errors.newEmail.message === 'Email is required' - ? t('settings.email.required', 'Email is required') - : errors.newEmail.message} - - )} - -
- - -
-
- )} -
{t('settings.general.password', 'Password')}
- +

{t('settings.general.account', 'Account')}

+
{t('settings.general.email', 'Email')}
-
{t('settings.danger_zone', 'Danger Zone')}
- -
+ - {/* Hidden profiles modal */} - - + {!isChangingEmail ? ( + + ) : ( +
+ + + {errors.newEmail && ( + + {errors.newEmail.message === 'Email is required' + ? t('settings.email.required', 'Email is required') + : errors.newEmail.message} + + )} + +
+ + +
+
+ )} + +
{t('settings.general.password', 'Password')}
+ + +
{t('settings.danger_zone', 'Danger Zone')}
+ +
+ + {/* Hidden profiles modal */} + + + ) } const DataPrivacySettings = () => { @@ -215,8 +272,12 @@ const DataPrivacySettings = () => { setIsDownloading(true) const data = await api('me/data', {}) const jsonString = JSON.stringify(data, null, 2) - const filename = `compass-data-export${user?.username ? `-${user.username}` : ''}.json`; - if (isNativeMobile() && window.AndroidBridge && window.AndroidBridge.downloadFile) { + const filename = `compass-data-export${user?.username ? `-${user.username}` : ''}.json` + if ( + isNativeMobile() && + window.AndroidBridge && + window.AndroidBridge.downloadFile + ) { window.AndroidBridge.downloadFile(filename, jsonString) } else { const blob = new Blob([jsonString], {type: 'application/json'}) @@ -256,11 +317,16 @@ const DataPrivacySettings = () => { 'Download a JSON file containing all your information: profile, account, messages, compatibility answers, starred profiles, votes, endorsements, search bookmarks, etc.' )}

-
)