mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-24 10:56:21 -05:00
Add metric vs imperial switch
This commit is contained in:
@@ -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>
|
||||
|
||||
47
web/components/measurement-system-toggle.tsx
Normal file
47
web/components/measurement-system-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
web/hooks/use-measurement-system.ts
Normal file
39
web/hooks/use-measurement-system.ts
Normal 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}
|
||||
}
|
||||
69
web/lib/measurement-utils.ts
Normal file
69
web/lib/measurement-utils.ts
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user