Add metric vs imperial switch

This commit is contained in:
MartinBraquet
2026-02-19 00:56:57 +01:00
parent 4046cc19ef
commit cb98314bec
9 changed files with 545 additions and 222 deletions

View File

@@ -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 (
<span>
<span className={clsx('text-semibold', highlightedClass)}>{t('filter.location.any', 'Any')}</span>
<span className={clsx('text-semibold', highlightedClass)}>
{t('filter.location.any', 'Any')}
</span>
<span className=""> {t('filter.location', 'location')}</span>
</span>
)
}
const formattedDistance = formatDistance(radius, measurementSystem)
return (
<span className="font-semibold">
<span className="">
<span className={clsx(highlightedClass)}>{radius}</span> miles
<span className={clsx(highlightedClass)}>{formattedDistance}</span>
</span>{' '}
<span className="sm:normal-case">{t('filter.near', 'near')}</span>{' '}
<span className={highlightedClass}>{location.name}</span>
@@ -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: {
/>
</Row>
{location && <DistanceSlider radius={radius} setRadius={setRadius} />}
{location && <DistanceSlider radius={radius} setRadius={setRadius}/>}
<LocationResults
showAny={!!location && query === ''}
@@ -125,7 +138,8 @@ function DistanceSlider(props: {
radius: number
setRadius: (radius: number) => 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 (
<Slider
min={min}
max={max}
amount={radius}
amount={measurementSystem === 'metric' ? milesToKm(radius) : radius}
onChange={snapToValue}
className="mb-4 w-full"
marks={snapValues.map((value) => ({
@@ -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"
>
<Row className="items-center gap-2">
<XIcon className="h-4 w-4 text-ink-400" aria-label={t('common.close', 'Close')}/>
<XIcon
className="h-4 w-4 text-ink-400"
aria-label={t('common.close', 'Close')}
/>
<span>{t('filter.location.set_any_city', 'Set to Any City')}</span>
</Row>
</button>
@@ -200,8 +220,8 @@ function LocationResults(props: {
})}
{debouncedLoading && (
<div className="flex flex-col gap-2 px-4 py-2">
<div className="bg-ink-600 h-4 w-1/3 animate-pulse rounded-full" />
<div className="bg-ink-400 h-4 w-2/3 animate-pulse rounded-full" />
<div className="bg-ink-600 h-4 w-1/3 animate-pulse rounded-full"/>
<div className="bg-ink-400 h-4 w-2/3 animate-pulse rounded-full"/>
</div>
)}
</Col>

View File

@@ -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 (
<Row className={clsx('items-center gap-2', className)}>
<span
className={clsx('text-sm', !isEnabled ? 'font-bold' : 'text-ink-500')}
>
{t('settings.measurement.imperial', 'Imperial')}
</span>
<Switch
checked={isEnabled}
onChange={(enabled: boolean) =>
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'
)}
>
<span
className={clsx(
isEnabled ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform'
)}
/>
</Switch>
<span
className={clsx('text-sm', isEnabled ? 'font-bold' : 'text-ink-500')}
>
{t('settings.measurement.metric', 'Metric')}
</span>
</Row>
)
}

View File

@@ -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 (
<Row className="items-center gap-2">
<div className="text-ink-600 w-5">{icon}</div>
<div>
{formattedText}
</div>
<div>{formattedText}</div>
</Row>
)
}
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: {
<Occupation profile={profile}/>
<AboutRow
icon={<FaBriefcase className="h-5 w-5"/>}
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[]
}
/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5"/>}
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}
/>
<AboutRow
icon={<PiHandsPrayingBold className="h-5 w-5"/>}
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}
/>
<AboutRow
icon={<FaStar className="h-5 w-5"/>}
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[]
}
/>
<AboutRow
icon={<FaHandsHelping className="h-5 w-5"/>}
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[]
}
/>
<AboutRow
icon={<BsPersonVcard className="h-5 w-5"/>}
@@ -131,15 +150,21 @@ export default function ProfileAbout(props: {
<Drinks profile={profile}/>
<AboutRow
icon={<GiFruitBowl className="h-5 w-5"/>}
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])
)}
/>
<AboutRow
icon={<MdLanguage className="h-5 w-5"/>}
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])
)}
/>
<HasKids profile={profile}/>
<WantsKids profile={profile}/>
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time}/>}
{!isCurrentUser && (
<LastOnline lastOnlineTime={userActivity?.last_online_time}/>
)}
<UserHandles links={profile.user.link}/>
</Col>
)
@@ -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 (
<AboutRow
icon={icon ? React.createElement(icon, {className: 'h-5 w-5'}) : null}
text={relationship_status?.map(v => 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 (
<AboutRow
icon={<LuGraduationCap className="h-5 w-5"/>}
text={text}
/>
)
return <AboutRow icon={<LuGraduationCap className="h-5 w-5"/>} text={text}/>
}
function Occupation(props: { profile: Profile }) {
@@ -269,7 +307,10 @@ function Smoker(props: { profile: Profile }) {
if (isSmoker == null) return null
if (isSmoker) {
return (
<AboutRow icon={<LuCigarette className="h-5 w-5"/>} text={t('profile.smokes', 'Smokes')}/>
<AboutRow
icon={<LuCigarette className="h-5 w-5"/>}
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 (
<AboutRow
@@ -337,7 +380,9 @@ function LastOnline(props: { lastOnlineTime?: string }) {
return (
<AboutRow
icon={<ClockIcon className="h-5 w-5"/>}
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: <TbBulb className="h-5 w-5"/>,
label: t('profile.big5_openness', 'Openness'),
value: profile.big5_openness
value: profile.big5_openness,
},
{
key: 'big5_conscientiousness',
icon: <TbCheck className="h-5 w-5"/>,
label: t('profile.big5_conscientiousness', 'Conscientiousness'),
value: profile.big5_conscientiousness
value: profile.big5_conscientiousness,
},
{
key: 'big5_extraversion',
icon: <TbUsers className="h-5 w-5"/>,
label: t('profile.big5_extraversion', 'Extraversion'),
value: profile.big5_extraversion
value: profile.big5_extraversion,
},
{
key: 'big5_agreeableness',
icon: <FaHeart className="h-5 w-5"/>,
label: t('profile.big5_agreeableness', 'Agreeableness'),
value: profile.big5_agreeableness
value: profile.big5_agreeableness,
},
{
key: 'big5_neuroticism',
icon: <TbMoodSad className="h-5 w-5"/>,
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 = <FaChild className="h-5 w-5"/>
const icon = profile.has_kids === 0 ? (
<div className="relative h-5 w-5">
{faChild}
<div className="absolute inset-0">
{/*<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 rotate-45 transform bg-ink-500"/>*/}
<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 -rotate-45 transform bg-ink-1000"/>
const icon =
profile.has_kids === 0 ? (
<div className="relative h-5 w-5">
{faChild}
<div className="absolute inset-0">
{/*<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 rotate-45 transform bg-ink-500"/>*/}
<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 -rotate-45 transform bg-ink-1000"/>
</div>
</div>
</div>
) : faChild
) : (
faChild
)
return <AboutRow icon={icon} text={hasKidsText}/>
}
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

View File

@@ -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 (
<Row className="text-ink-700 gap-4 text-sm">
{profile.city && <IconWithInfo
text={`${profile.city ?? ''}, ${stateOrCountry ?? ''}`}
icon={<IoLocationOutline className="h-4 w-4" />}
/>}
{profile.gender && <IconWithInfo
text={capitalize(t(`profile.gender.${profile.gender}`, convertGender(profile.gender as Gender)))}
icon={
<GenderIcon gender={profile.gender as Gender} className="h-4 w-4 " />
}
/>}
{profile.city && (
<IconWithInfo
text={`${profile.city ?? ''}, ${stateOrCountry ?? ''}`}
icon={<IoLocationOutline className="h-4 w-4"/>}
/>
)}
{profile.gender && (
<IconWithInfo
text={capitalize(
t(
`profile.gender.${profile.gender}`,
convertGender(profile.gender as Gender)
)
)}
icon={
<GenderIcon
gender={profile.gender as Gender}
className="h-4 w-4 "
/>
}
/>
)}
{profile.height_in_inches != null && (
<IconWithInfo
text={formatProfileValue('height_in_inches', profile.height_in_inches)}
icon={<MdHeight className="h-4 w-4 " />}
text={formatProfileValue(
'height_in_inches',
profile.height_in_inches,
measurementSystem
)}
icon={<MdHeight className="h-4 w-4 "/>}
/>
)}
</Row>
@@ -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 (
<Row className="items-center gap-0.5">
<div className="text-ink-500">{icon}</div>

View File

@@ -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<MeasurementSystem>(
getDefaultMeasurementSystem(),
'measurement-system'
)
const setMeasurementSystem = (newSystem: MeasurementSystem) => {
setMeasurementSystemState(newSystem)
}
return {measurementSystem, setMeasurementSystem}
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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() {
<UncontrolledTabs
name={'settings-page'}
tabs={[
{title: t('settings.tabs.general', 'General'), content: <GeneralSettings/>},
{title: t('settings.tabs.notifications', 'Notifications'), content: <NotificationSettings/>},
{title: t('settings.tabs.about', 'About'), content: <AboutSettings/>},
{
title: t('settings.tabs.general', 'General'),
content: <GeneralSettings/>,
},
{
title: t('settings.tabs.notifications', 'Notifications'),
content: <NotificationSettings/>,
},
{
title: t('settings.tabs.about', 'About'),
content: <AboutSettings/>,
},
]}
trackingName={'settings page'}
/>
@@ -50,39 +60,50 @@ export default function NotificationsPage() {
export const GeneralSettings = () => (
<WithPrivateUser>
{user => <LoadedGeneralSettings privateUser={user}/>}
{(user) => <LoadedGeneralSettings privateUser={user}/>}
</WithPrivateUser>
)
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 <>
<div className="flex flex-col gap-2 max-w-fit">
<h3>{t('settings.general.theme', 'Theme')}</h3>
<ThemeIcon className="h-6 w-6"/>
return (
<>
<div className="flex flex-col gap-2 max-w-fit">
<h3>{t('settings.general.language', 'Language')}</h3>
<LanguagePicker className={'w-fit min-w-[120px]'}/>
<h3>{t('settings.general.language', 'Language')}</h3>
<LanguagePicker className={'w-fit min-w-[120px]'}/>
<h3>{t('settings.general.font', 'Font')}</h3>
<FontPicker className={'w-fit min-w-[180px]'}/>
<h3>{t('settings.general.measurement', 'Measurement System')}</h3>
<MeasurementSystemToggle/>
<h3>{t('settings.data_privacy.title', 'Data & Privacy')}</h3>
<DataPrivacySettings/>
<h3>{t('settings.general.theme', 'Theme')}</h3>
<ThemeIcon className="h-6 w-6"/>
<h3>{t('settings.general.people', 'People')}</h3>
{/*<h5>{t('settings.hidden_profiles.title', 'Hidden profiles')}</h5>*/}
<Button color={'gray-outline'} onClick={() => setShowHiddenProfiles(true)} className="w-fit">
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
</Button>
<h3>{t('settings.general.font', 'Font')}</h3>
<FontPicker className={'w-fit min-w-[180px]'}/>
<h3>{t('settings.general.account', 'Account')}</h3>
<h5>{t('settings.general.email', 'Email')}</h5>
<h3>{t('settings.data_privacy.title', 'Data & Privacy')}</h3>
<DataPrivacySettings/>
<EmailVerificationButton/>
{!isChangingEmail ? (
<Button color={'gray-outline'} onClick={() => setIsChangingEmail(true)} className="w-fit">
{t('settings.email.change', 'Change email address')}
<h3>{t('settings.general.people', 'People')}</h3>
{/*<h5>{t('settings.hidden_profiles.title', 'Hidden profiles')}</h5>*/}
<Button
color={'gray-outline'}
onClick={() => setShowHiddenProfiles(true)}
className="w-fit"
>
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
</Button>
) : (
<form onSubmit={handleSubmit(onSubmitEmailChange)} className="flex flex-col gap-2">
<Col>
<Input
type="email"
placeholder={t('settings.email.new_placeholder', 'New email address')}
{...register('newEmail', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t('settings.email.invalid', 'Invalid email address'),
},
})}
disabled={!user}
/>
{errors.newEmail && (
<span className="text-red-500 text-sm">
{errors.newEmail.message === 'Email is required'
? t('settings.email.required', 'Email is required')
: errors.newEmail.message}
</span>
)}
</Col>
<div className="flex gap-2">
<Button type="submit" color="green" className="w-fit">
{t('settings.action.save', 'Save')}
</Button>
<Button
type="button"
color="gray"
onClick={() => {
setIsChangingEmail(false)
reset()
}}
className="w-fit"
>
{t('settings.action.cancel', 'Cancel')}
</Button>
</div>
</form>
)}
<h5>{t('settings.general.password', 'Password')}</h5>
<Button
onClick={() => sendPasswordReset(privateUser?.email)}
className="mb-2 max-w-[250px] w-fit"
color={'gray-outline'}
>
{t('settings.password.send_reset', 'Send password reset email')}
</Button>
<h3>{t('settings.general.account', 'Account')}</h3>
<h5>{t('settings.general.email', 'Email')}</h5>
<h5>{t('settings.danger_zone', 'Danger Zone')}</h5>
<Button color="red" onClick={handleDeleteAccount} className="w-fit">
{t('settings.delete_account', 'Delete Account')}
</Button>
</div>
<EmailVerificationButton/>
{/* Hidden profiles modal */}
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles}/>
</>
{!isChangingEmail ? (
<Button
color={'gray-outline'}
onClick={() => setIsChangingEmail(true)}
className="w-fit"
>
{t('settings.email.change', 'Change email address')}
</Button>
) : (
<form
onSubmit={handleSubmit(onSubmitEmailChange)}
className="flex flex-col gap-2"
>
<Col>
<Input
type="email"
placeholder={t(
'settings.email.new_placeholder',
'New email address'
)}
{...register('newEmail', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t(
'settings.email.invalid',
'Invalid email address'
),
},
})}
disabled={!user}
/>
{errors.newEmail && (
<span className="text-red-500 text-sm">
{errors.newEmail.message === 'Email is required'
? t('settings.email.required', 'Email is required')
: errors.newEmail.message}
</span>
)}
</Col>
<div className="flex gap-2">
<Button type="submit" color="green" className="w-fit">
{t('settings.action.save', 'Save')}
</Button>
<Button
type="button"
color="gray"
onClick={() => {
setIsChangingEmail(false)
reset()
}}
className="w-fit"
>
{t('settings.action.cancel', 'Cancel')}
</Button>
</div>
</form>
)}
<h5>{t('settings.general.password', 'Password')}</h5>
<Button
onClick={() => sendPasswordReset(privateUser?.email)}
className="mb-2 max-w-[250px] w-fit"
color={'gray-outline'}
>
{t('settings.password.send_reset', 'Send password reset email')}
</Button>
<h5>{t('settings.danger_zone', 'Danger Zone')}</h5>
<Button color="red" onClick={handleDeleteAccount} className="w-fit">
{t('settings.delete_account', 'Delete Account')}
</Button>
</div>
{/* Hidden profiles modal */}
<HiddenProfilesModal
open={showHiddenProfiles}
setOpen={setShowHiddenProfiles}
/>
</>
)
}
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.'
)}
</p>
<Button color="gray-outline" onClick={handleDownload} className="w-fit" disabled={isDownloading}
loading={isDownloading}>
{isDownloading ? t('settings.data_privacy.downloading', 'Downloading...')
: t('settings.data_privacy.download', 'Download all my data (JSON)')
}
<Button
color="gray-outline"
onClick={handleDownload}
className="w-fit"
disabled={isDownloading}
loading={isDownloading}
>
{isDownloading
? t('settings.data_privacy.downloading', 'Downloading...')
: t('settings.data_privacy.download', 'Download all my data (JSON)')}
</Button>
</div>
)