Add comprehensive GeneralSettings component: refactor settings layout with structured cards, introduce new theme, font, and language pickers, enhance account and privacy management options with improved UX.

This commit is contained in:
MartinBraquet
2026-07-28 20:24:19 +02:00
parent 32f686a0e6
commit a31ae0b3de
15 changed files with 818 additions and 321 deletions

View File

@@ -1436,6 +1436,7 @@
"security.seo.title": "Sicherheit",
"security.title": "Sicherheit",
"send_message.button_label": "Nachricht",
"settings.account.card_description": "Wie Sie sich anmelden und wie wir Sie erreichen.",
"settings.action.cancel": "Abbrechen",
"settings.action.save": "Speichern",
"settings.connection_preferences.description": "Kontrollieren Sie, wie andere sich mit Ihnen verbinden können.",
@@ -1447,16 +1448,20 @@
"settings.connection_preferences.update_failed": "Fehler beim Aktualisieren der Einstellungen",
"settings.connection_preferences.updated": "Einstellungen aktualisiert",
"settings.danger_zone": "Gefahrenzone",
"settings.danger_zone.description": "Beim Löschen Ihres Kontos werden Profil, Nachrichten und Antworten entfernt. Das lässt sich nicht rückgängig machen.",
"settings.data_privacy.card_description": "Alles, was Compass über Sie speichert, gehört Ihnen.",
"settings.data_privacy.description": "Laden Sie eine JSON-Datei mit allen Ihren Informationen herunter: Profil, Konto, Nachrichten, Kompatibilitätsantworten, markierte Profile, Stimmen, Empfehlungen, Such-Lesezeichen usw.",
"settings.data_privacy.download": "Alle meine Daten herunterladen (JSON)",
"settings.data_privacy.download.error": "Fehler beim Herunterladen Ihrer Daten. Bitte versuchen Sie es erneut.",
"settings.data_privacy.download.success": "Ihr Daten-Export wurde heruntergeladen.",
"settings.data_privacy.downloading": "Herunterladen...",
"settings.data_privacy.export": "Ihre Daten exportieren",
"settings.data_privacy.title": "Daten & Datenschutz",
"settings.delete.error": "Löschen des Kontos fehlgeschlagen.",
"settings.delete.loading": "Konto wird gelöscht..",
"settings.delete.success": "Ihr Konto wurde gelöscht.",
"settings.delete_account": "Konto löschen",
"settings.delete_account.description": "Wir fragen Sie nach dem Grund, bevor etwas entfernt wird.",
"settings.delete_confirm": "Möchten Sie Ihr Profil wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"settings.email.change": "E-Mail-Adresse ändern",
"settings.email.invalid": "Ungültige E-Mail-Adresse",
@@ -1470,19 +1475,27 @@
"settings.email.send_verification": "Bestätigungs-E-Mail senden",
"settings.email.sending": "Bestätigungs-E-Mail wird gesendet...",
"settings.email.too_many_requests": "Sie können nicht mehr als eine E-Mail pro Minute anfordern. Bitte warten Sie, bevor Sie eine weitere Anfrage senden.",
"settings.email.unverified_short": "Nicht bestätigt",
"settings.email.update_failed": "Aktualisierung der E-Mail-Adresse fehlgeschlagen",
"settings.email.updated_success": "E-Mail-Adresse erfolgreich aktualisiert",
"settings.email.verification_failed": "Senden der Bestätigungs-E-Mail fehlgeschlagen.",
"settings.email.verification_sent": "Bestätigungs-E-Mail gesendet prüfen Sie Ihren Posteingang und Spam-Ordner.",
"settings.email.verified": "E-Mail bestätigt ✔️",
"settings.email.verified_short": "Bestätigt",
"settings.email.verify": "E-Mail-Adresse bestätigen",
"settings.email.verify_description": "Einige Funktionen bleiben gesperrt, bis Ihre Adresse bestätigt ist.",
"settings.general.account": "Konto",
"settings.general.email": "E-Mail",
"settings.general.font": "Schriftart",
"settings.general.font_description": "Atkinson Hyperlegible ist auf leichtere Lesbarkeit ausgelegt.",
"settings.general.language": "Sprache",
"settings.general.language_description": "Die Sprache der Benutzeroberfläche.",
"settings.general.measurement": "Maßeinheitensystem",
"settings.general.measurement_description": "Wird für Größen und Entfernungen verwendet.",
"settings.general.password": "Passwort",
"settings.general.people": "Personen",
"settings.general.theme": "Design",
"settings.general.theme_description": "„Automatisch“ folgt der Hell-/Dunkel-Einstellung Ihres Geräts.",
"settings.hidden_profiles.description": "Diese Personen erscheinen nicht in Ihren Suchergebnissen.",
"settings.hidden_profiles.empty": "Sie haben keine Profile ausgeblendet.",
"settings.hidden_profiles.load_error": "Fehler beim Laden ausgeblendeter Profile.",
@@ -1495,7 +1508,12 @@
"settings.hidden_profiles.unhiding": "Einblenden...",
"settings.measurement.imperial": "Imperial",
"settings.measurement.metric": "Metrisch",
"settings.password.description": "Wir senden Ihnen einen Link, um ein neues zu wählen.",
"settings.password.send_reset": "Passwort-Zurücksetzungs-E-Mail senden",
"settings.people.card_description": "Profile, die Sie nicht sehen möchten.",
"settings.saved": "Gespeichert",
"settings.section.appearance": "Darstellung & Sprache",
"settings.section.appearance_description": "Wie Compass auf diesem Gerät aussieht und sich liest.",
"settings.tabs.about": "Über",
"settings.tabs.general": "Allgemein",
"settings.tabs.notifications": "Benachrichtigungen",

View File

@@ -1435,6 +1435,7 @@
"security.seo.title": "Sécurité",
"security.title": "Sécurité",
"send_message.button_label": "Contacter",
"settings.account.card_description": "Comment vous vous connectez et comment nous vous contactons.",
"settings.action.cancel": "Annuler",
"settings.action.save": "Enregistrer",
"settings.connection_preferences.description": "Contrôlez comment les autres peuvent se connecter avec vous.",
@@ -1446,16 +1447,20 @@
"settings.connection_preferences.update_failed": "Échec de la mise à jour des préférences",
"settings.connection_preferences.updated": "Préférences mises à jour",
"settings.danger_zone": "Zone dangereuse",
"settings.danger_zone.description": "La suppression de votre compte efface votre profil, vos messages et vos réponses. Cette action est irréversible.",
"settings.data_privacy.card_description": "Tout ce que Compass conserve à votre sujet vous appartient.",
"settings.data_privacy.description": "Téléchargez un fichier JSON contenant toutes vos informations : profil, compte, messages, réponses de compatibilité, profils en favoris, votes, recommandations, recherches enregistrées, etc.",
"settings.data_privacy.download": "Télécharger toutes mes données (JSON)",
"settings.data_privacy.download.error": "Échec du téléchargement de vos données. Veuillez réessayer.",
"settings.data_privacy.download.success": "Votre export de données a été téléchargé.",
"settings.data_privacy.downloading": "Téléchargement...",
"settings.data_privacy.export": "Exporter vos données",
"settings.data_privacy.title": "Données & Confidentialité",
"settings.delete.error": "Échec de la suppression du compte.",
"settings.delete.loading": "Suppression du compte..",
"settings.delete.success": "Votre compte a été supprimé.",
"settings.delete_account": "Supprimer le compte",
"settings.delete_account.description": "Nous vous demanderons pourquoi avant de supprimer quoi que ce soit.",
"settings.delete_confirm": "Êtes-vous sûr de vouloir supprimer votre profil ? Cela ne peut pas être annulé.",
"settings.email.change": "Modifier l'adresse email",
"settings.email.invalid": "Adresse email invalide",
@@ -1469,19 +1474,27 @@
"settings.email.send_verification": "Envoyer un e-mail de vérification",
"settings.email.sending": "Envoi de l'e-mail de vérification...",
"settings.email.too_many_requests": "Vous ne pouvez pas demander plus d'un e-mail par minute. Veuillez attendre avant d'envoyer une autre demande.",
"settings.email.unverified_short": "Non vérifiée",
"settings.email.update_failed": "Échec de la mise à jour de l'adresse email",
"settings.email.updated_success": "Adresse email mise à jour avec succès",
"settings.email.verification_failed": "Échec de l'envoi de l'e-mail de vérification.",
"settings.email.verification_sent": "E-mail de vérification envoyé — vérifiez votre boîte de réception et les spams.",
"settings.email.verified": "E-mail vérifié ✔️",
"settings.email.verified_short": "Vérifiée",
"settings.email.verify": "Vérifier votre e-mail",
"settings.email.verify_description": "Certaines fonctionnalités restent bloquées tant que votre adresse nest pas confirmée.",
"settings.general.account": "Compte",
"settings.general.email": "E-mail",
"settings.general.font": "Police",
"settings.general.font_description": "Atkinson Hyperlegible est conçue pour une lecture plus facile.",
"settings.general.language": "Langue",
"settings.general.language_description": "La langue de linterface.",
"settings.general.measurement": "Système de mesure",
"settings.general.measurement_description": "Utilisé pour les tailles et les distances.",
"settings.general.password": "Mot de passe",
"settings.general.people": "Personnes",
"settings.general.theme": "Thème",
"settings.general.theme_description": "« Auto » suit le réglage clair ou sombre de votre appareil.",
"settings.hidden_profiles.description": "Ces personnes n'apparaissent pas dans vos résultats de recherche.",
"settings.hidden_profiles.empty": "Vous n'avez masqué aucun profil.",
"settings.hidden_profiles.load_error": "Échec du chargement des profils masqués.",
@@ -1494,7 +1507,12 @@
"settings.hidden_profiles.unhiding": "Affichage...",
"settings.measurement.imperial": "Impérial",
"settings.measurement.metric": "Métrique",
"settings.password.description": "Nous vous enverrons un lien pour en choisir un nouveau.",
"settings.password.send_reset": "Envoyer un email de réinitialisation de mot de passe",
"settings.people.card_description": "Les profils que vous avez choisi de ne pas voir.",
"settings.saved": "Enregistré",
"settings.section.appearance": "Apparence et langue",
"settings.section.appearance_description": "Lapparence et la lisibilité de Compass sur cet appareil.",
"settings.tabs.about": "À propos",
"settings.tabs.general": "Général",
"settings.tabs.notifications": "Notifications",

View File

@@ -58,9 +58,25 @@ export class SettingsPage {
await this.measurementSystemToggle.click()
}
/**
* The theme control is three radio pills (Light / Dark / Auto), not the single cycling button it
* used to be, so "toggle" means advancing to the next option rather than clicking one element.
*/
async toggleDisplayTheme() {
await expect(this.themeToggle).toBeVisible()
await this.themeToggle.click()
const options = this.themeToggle.getByRole('radio')
const count = await options.count()
let checked = 0
for (let i = 0; i < count; i++) {
if ((await options.nth(i).getAttribute('aria-checked')) === 'true') checked = i
}
await options.nth((checked + 1) % count).click()
}
async setDisplayTheme(theme: 'light' | 'dark' | 'auto') {
const option = this.page.getByTestId(`settings-theme-${theme}`)
await expect(option).toBeVisible()
await option.click()
}
async setFont(font: FontsTuple) {

View File

@@ -49,7 +49,13 @@ export function buttonClass(size: SizeType, color: ColorType) {
color === 'green' && [solid, 'bg-teal-500 hover:bg-teal-600'],
color === 'green-outline' && [outline, 'text-teal-500 hover:bg-teal-500'],
color === 'red' && [solid, 'bg-red-500 hover:bg-red-600'],
color === 'red-outline' && [outline, 'text-scarlet-500 hover:bg-scarlet-500'],
// `scarlet` is this theme's *grey* "no" token (`--color-no-500` is 140 140 140), so the red
// outline button rendered as a grey one — indistinguishable from `gray-outline` beside it. The
// red ramp is the actual red. Hover also flips the label to white, or red-on-red was unreadable.
color === 'red-outline' && [
outline,
'text-red-500 !ring-red-500/40 hover:bg-red-500 hover:text-white',
],
color === 'yellow' && [solid, 'bg-yellow-400 hover:bg-yellow-500'],
color === 'yellow-outline' && [outline, 'text-yellow-500 hover:bg-yellow-500'],
color === 'blue' && [solid, 'bg-blue-400 hover:bg-blue-500'],

View File

@@ -1,12 +1,19 @@
import {useState} from 'react'
import {CheckIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {SettingsRow} from 'web/components/settings/settings-card'
import {SwitchSetting} from 'web/components/switch-setting'
import {useProfile} from 'web/hooks/use-profile'
import {updateProfile} from 'web/lib/api'
import {useT} from 'web/lib/locale'
/**
* Renders as bare `SettingsRow`s so it can sit inside the Connections card on the settings page —
* the card header now carries the title and the "control how others can connect with you" line that
* this component used to print itself.
*/
export function ConnectionPreferencesSettings() {
const t = useT()
const profile = useProfile()
@@ -19,12 +26,17 @@ export function ConnectionPreferencesSettings() {
)
const [isUpdating, setIsUpdating] = useState(false)
// A fresh object per save, not just the field name: flipping the same toggle twice must show the
// confirmation twice, and a state value that compares equal to itself would not re-fire the effect
// that shows it.
const [saved, setSaved] = useState<{field: string; at: number} | null>(null)
const handleUpdate = async (field: string, value: boolean) => {
setIsUpdating(true)
try {
await updateProfile({[field]: value})
// toast.success(t('settings.connection_preferences.updated', 'Preferences updated'))
setSaved({field, at: Date.now()})
} catch (error) {
console.error('Error updating preferences:', error)
toast.error(
@@ -46,55 +58,76 @@ export function ConnectionPreferencesSettings() {
}
return (
<Col className="gap-4">
<div className="text-sm text-ink-500">
{t(
'settings.connection_preferences.description',
'Control how others can connect with you.',
<>
<SettingsRow
label={t('settings.connection_preferences.direct_messaging', 'Direct Messaging')}
description={t(
'settings.connection_preferences.dm_description',
'Let anyone start a conversation with you immediately.',
)}
</div>
>
<Row className="items-center gap-3">
<SavedFlash field="allow_direct_messaging" saved={saved} />
<SwitchSetting
testId="settings-direct-message-toggle"
checked={allowDirectMessaging}
onChange={handleDirectMessagingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
</SettingsRow>
<Row className="items-center justify-between gap-4">
<div className="flex-1">
<div className="font-medium">
{t('settings.connection_preferences.direct_messaging', 'Direct Messaging')}
</div>
<div className="text-ink-500 text-sm">
{t(
'settings.connection_preferences.dm_description',
'Let anyone start a conversation with you immediately.',
)}
</div>
</div>
<SwitchSetting
testId="settings-direct-message-toggle"
checked={allowDirectMessaging}
onChange={handleDirectMessagingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
<Row className="items-center justify-between gap-4">
<div className="flex-1">
<div className="font-medium">
{t('settings.connection_preferences.interest_indicator', 'Private interest signals')}
</div>
<div className="text-ink-500 text-sm">
{t(
'settings.connection_preferences.indicator_description',
'Allow people to privately signal interest. You are only notified if the interest is mutual.',
)}
</div>
</div>
<SwitchSetting
testId="settings-private-interest-signal-toggle"
checked={allowInterestIndicating}
onChange={handleInterestIndicatingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
</Col>
<SettingsRow
label={t('settings.connection_preferences.interest_indicator', 'Private interest signals')}
description={t(
'settings.connection_preferences.indicator_description',
'Allow people to privately signal interest. You are only notified if the interest is mutual.',
)}
>
<Row className="items-center gap-3">
<SavedFlash field="allow_interest_indicating" saved={saved} />
<SwitchSetting
testId="settings-private-interest-signal-toggle"
checked={allowInterestIndicating}
onChange={handleInterestIndicatingChange}
disabled={isUpdating}
colorMode={'primary'}
/>
</Row>
</SettingsRow>
</>
)
}
/**
* These toggles write straight through with no confirmation of any kind — the success toast has been
* commented out for a while — so flipping one and being told nothing is indistinguishable from
* flipping one and having it fail silently. A checkmark that fades after two seconds is the smallest
* thing that closes that loop without a toast covering the page on every tap.
*/
function SavedFlash(props: {field: string; saved: {field: string; at: number} | null}) {
const {field, saved} = props
const [visible, setVisible] = useState(false)
const t = useT()
useEffect(() => {
if (saved?.field !== field) return
setVisible(true)
const timer = setTimeout(() => setVisible(false), 2000)
return () => clearTimeout(timer)
}, [saved, field])
return (
<span
aria-hidden={!visible}
className={clsx(
'text-ink-500 flex items-center gap-1 text-xs transition-opacity duration-300',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<CheckIcon className="h-3.5 w-3.5" />
{t('settings.saved', 'Saved')}
</span>
)
}

View File

@@ -24,7 +24,7 @@ export function FontPicker(props: {className?: string} = {}) {
value={font}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFont(e.target.value as FontOption)}
className={clsx(
'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none',
className,
)}
>

View File

@@ -15,7 +15,7 @@ export function LanguagePicker(props: {className?: string} = {}) {
value={locale}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setLocale(e.target.value)}
className={clsx(
'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none',
className,
)}
>

View File

@@ -168,8 +168,18 @@ export function ControlledTabs(props: TabProps & {activeIndex: number}) {
)
}
export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) {
const {defaultIndex, onClick, ...rest} = props
export function UncontrolledTabs(
props: TabProps & {
defaultIndex?: number
/**
* Fires with the active index, including the one restored from cache on mount — `onClick` only
* ever reports a change the user just made, which is not enough for a caller that has to render
* something else alongside the active tab (the settings page's section index).
*/
onActiveIndexChange?: (index: number) => void
},
) {
const {defaultIndex, onClick, onActiveIndexChange, ...rest} = props
const [activeIndex, setActiveIndex] = usePersistentInMemoryState(
defaultIndex ?? 0,
`tab-${props.trackingName}-${props.name ?? props.tabs[0]?.title}`,
@@ -185,6 +195,10 @@ export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) {
}
}, [])
useEffect(() => {
onActiveIndexChange?.(activeIndex)
}, [activeIndex])
return (
<ControlledTabs
{...rest}

View File

@@ -11,6 +11,7 @@ import {useCallback} from 'react'
import {Col} from 'web/components/layout/col'
import {SwitchSetting} from 'web/components/switch-setting'
import {WithPrivateUser} from 'web/components/user/with-user'
import {surface} from 'web/components/widgets/surface'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
@@ -104,31 +105,33 @@ function LoadedNotificationSettings(props: {privateUser: PrivateUser}) {
return (
<Col className="mt-4 w-full gap-6">
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
<div className="border-canvas-300 border-b px-4 py-3">
<Col className={clsx(surface, 'overflow-hidden')}>
<div className="border-canvas-200/70 border-b px-4 py-3">
<p className="text-ink-900 font-medium">
{t('notifications.heading', 'Where do you want to be notified when someone')}
</p>
</div>
<DestinationHeader />
<Col className="divide-canvas-300 divide-y">
<Col className="divide-canvas-200/70 divide-y">
{socialNotificationTypes.map(renderOption)}
</Col>
</Col>
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
<div className="border-canvas-300 border-b px-4 py-3">
<Col className={clsx(surface, 'overflow-hidden')}>
<div className="border-canvas-200/70 border-b px-4 py-3">
<p className="text-ink-900 font-medium">
{t('notifications.section.other', 'Other updates')}
</p>
</div>
<DestinationHeader />
<Col className="divide-canvas-300 divide-y">{otherNotificationTypes.map(renderOption)}</Col>
<Col className="divide-canvas-200/70 divide-y">
{otherNotificationTypes.map(renderOption)}
</Col>
</Col>
{/* Master switch: its own card so it doesn't read as just another "other update". The toggles
still line up with the columns above, so it needs no repeated header. */}
<Col className="border-canvas-300 bg-canvas-50 overflow-hidden rounded-xl border">
<Col className={clsx(surface, 'overflow-hidden')}>
{renderOption({
type: 'opt_out_all',
question: t('notifications.question.opt_out_all', 'Opt out of all notifications?'),
@@ -144,7 +147,7 @@ const OPTION_GRID = 'grid grid-cols-[minmax(0,1fr)_4.5rem_4.5rem] items-center g
const DestinationHeader = () => {
const t = useT()
return (
<div className={clsx(OPTION_GRID, 'border-canvas-300 border-b px-4 py-2')}>
<div className={clsx(OPTION_GRID, 'border-canvas-200/70 border-b px-4 py-2')}>
<span />
{SHOWN_NOTIFICATION_DESTINATION_TYPES.map((destinationType) => (
<span

View File

@@ -131,9 +131,12 @@ export function DeleteAccountSurveyModal() {
return (
<ConfirmationButton
openModalBtn={{
className: 'p-2',
className: 'p-2 w-full sm:w-fit',
label: t('delete_yourself.open_label', 'Delete account'),
color: 'red',
// Outline, not solid. On the settings page this was the only coloured button among a dozen
// grey ones, which made the irreversible action the most prominent thing on the page. The
// modal's own confirm button is still solid red — the emphasis belongs where the decision is.
color: 'red-outline',
}}
cancelBtn={
showStayPitch

View File

@@ -0,0 +1,404 @@
import {
ArrowDownTrayIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
PaintBrushIcon,
ShieldCheckIcon,
SparklesIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline'
import {CheckBadgeIcon} from '@heroicons/react/24/solid'
import {PrivateUser} from 'common/src/user'
import {updateEmail} from 'firebase/auth'
import {useState} from 'react'
import {useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {ConnectionPreferencesSettings} from 'web/components/connection-preferences-settings'
import {EmailVerificationButton} from 'web/components/email-verification-button'
import {FontPicker} from 'web/components/font-picker'
import {LanguagePicker} from 'web/components/language/language-picker'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import MeasurementSystemToggle from 'web/components/measurement-system-toggle'
import {DeleteAccountSurveyModal} from 'web/components/profile/delete-account-survey-modal'
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
import {SettingsCard, SettingsRow} from 'web/components/settings/settings-card'
import {ThemePicker} from 'web/components/settings/theme-picker'
import {WithPrivateUser} from 'web/components/user/with-user'
import {Input} from 'web/components/widgets/input'
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {sendPasswordReset} from 'web/lib/firebase/password'
import {useT} from 'web/lib/locale'
import {isNativeMobile} from 'web/lib/util/webview'
export const GeneralSettings = () => (
<WithPrivateUser>{(user) => <LoadedGeneralSettings privateUser={user} />}</WithPrivateUser>
)
/** Cards enter in sequence rather than all at once, so the eye is led down the column. */
const enter = (index: number) => ({animationDelay: `${index * 60}ms`})
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 t = useT()
const firebaseUser = useFirebaseUser()
if (!firebaseUser) return null
const changeUserEmail = async (newEmail: string) => {
if (!firebaseUser) return
try {
await updateEmail(firebaseUser, newEmail)
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'))
}
}
const onSubmitEmailChange = (data: {newEmail: string}) => {
if (!firebaseUser) return
if (data.newEmail === firebaseUser.email) {
toast.error(t('settings.email.same_as_current', 'New email is the same as current email'))
return
}
changeUserEmail(data.newEmail)
}
const isEmailVerified = firebaseUser.emailVerified
return (
<>
<Col className="mt-4 w-full gap-6" data-settings-sections>
<SettingsCard
id="settings-appearance"
icon={<PaintBrushIcon />}
title={t('settings.section.appearance', 'Appearance & language')}
description={t(
'settings.section.appearance_description',
'How Compass looks and reads on this device.',
)}
style={enter(0)}
>
<SettingsRow
htmlFor="locale-picker"
label={t('settings.general.language', 'Language')}
description={t(
'settings.general.language_description',
'The language of the interface.',
)}
>
<LanguagePicker className={'w-full min-w-[160px] !bg-canvas-0 sm:w-fit'} />
</SettingsRow>
<SettingsRow
label={t('settings.general.theme', 'Theme')}
description={t(
'settings.general.theme_description',
'Auto follows your devices light or dark setting.',
)}
>
<ThemePicker />
</SettingsRow>
<SettingsRow
htmlFor="font-picker"
label={t('settings.general.font', 'Font')}
description={t(
'settings.general.font_description',
'Atkinson Hyperlegible is designed for easier reading.',
)}
>
<FontPicker className={'w-full min-w-[200px] !bg-canvas-0 sm:w-fit'} />
</SettingsRow>
<SettingsRow
label={t('settings.general.measurement', 'Measurement System')}
description={t(
'settings.general.measurement_description',
'Used for heights and distances.',
)}
>
<MeasurementSystemToggle />
</SettingsRow>
</SettingsCard>
<SettingsCard
id="settings-privacy"
icon={<ShieldCheckIcon />}
title={t('settings.data_privacy.title', 'Data & Privacy')}
description={t(
'settings.data_privacy.card_description',
'Everything Compass holds about you is yours to take.',
)}
style={enter(1)}
>
<DataPrivacySettings />
</SettingsCard>
<SettingsCard
id="settings-people"
icon={<EyeSlashIcon />}
title={t('settings.general.people', 'People')}
description={t(
'settings.people.card_description',
'Profiles you have chosen not to see.',
)}
style={enter(2)}
>
<SettingsRow
label={t('settings.hidden_profiles.title', 'Hidden profiles')}
description={t(
'settings.hidden_profiles.description',
'Hidden profiles stay out of your browsing and search results.',
)}
>
<Button
color={'gray-outline'}
onClick={() => setShowHiddenProfiles(true)}
className="w-full sm:w-fit"
>
{t('settings.hidden_profiles.manage', 'Manage hidden profiles')}
</Button>
</SettingsRow>
</SettingsCard>
<SettingsCard
id="settings-connections"
icon={<SparklesIcon />}
title={t('settings.connection_preferences.title', 'Connection Preferences')}
description={t(
'settings.connection_preferences.description',
'Control how others can connect with you.',
)}
style={enter(3)}
>
<ConnectionPreferencesSettings />
</SettingsCard>
<SettingsCard
id="settings-account"
icon={<UserCircleIcon />}
title={t('settings.general.account', 'Account')}
description={t(
'settings.account.card_description',
'How you sign in and how we reach you.',
)}
style={enter(4)}
>
{/* The address itself, not just buttons that act on it. The old layout offered "Change
email address" without ever saying what the current one was. */}
<SettingsRow
stacked={isChangingEmail}
label={t('settings.general.email', 'Email')}
description={
<Row className="flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-ink-700 break-all">{privateUser?.email}</span>
{isEmailVerified ? (
<span className="text-ink-500 inline-flex items-center gap-1 text-xs">
<CheckBadgeIcon className="h-4 w-4 text-teal-500" aria-hidden />
{t('settings.email.verified_short', 'Verified')}
</span>
) : (
<span className="text-red-500 text-xs">
{t('settings.email.unverified_short', 'Not verified')}
</span>
)}
</Row>
}
>
{!isChangingEmail ? (
<Button
color={'gray-outline'}
onClick={() => setIsChangingEmail(true)}
className="w-full sm:w-fit"
>
{t('settings.email.change', 'Change email address')}
</Button>
) : (
<form
onSubmit={handleSubmit(onSubmitEmailChange)}
className="flex w-full flex-col gap-2 sm:max-w-sm"
>
<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={!firebaseUser}
/>
{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>
)}
</SettingsRow>
{/* Only worth a row while there is something to do about it. */}
{!isEmailVerified && (
<SettingsRow
label={t('settings.email.verify', 'Verify your email')}
description={t(
'settings.email.verify_description',
'Some features stay locked until your address is confirmed.',
)}
>
<EmailVerificationButton />
</SettingsRow>
)}
<SettingsRow
label={t('settings.general.password', 'Password')}
description={t(
'settings.password.description',
'Well email you a link to choose a new one.',
)}
>
<Button
onClick={() => sendPasswordReset(privateUser?.email)}
className="w-full sm:w-fit"
color={'gray-outline'}
>
{t('settings.password.send_reset', 'Send password reset email')}
</Button>
</SettingsRow>
</SettingsCard>
<SettingsCard
id="settings-danger"
icon={<ExclamationTriangleIcon />}
tone="danger"
title={t('settings.danger_zone', 'Danger Zone')}
description={t(
'settings.danger_zone.description',
'Deleting your account removes your profile, messages and answers. This cannot be undone.',
)}
style={enter(5)}
>
<SettingsRow
label={t('settings.delete_account', 'Delete account')}
description={t(
'settings.delete_account.description',
'Youll be asked why before anything is removed.',
)}
>
<DeleteAccountSurveyModal />
</SettingsRow>
</SettingsCard>
</Col>
{/* Hidden profiles modal */}
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles} />
</>
)
}
const DataPrivacySettings = () => {
const t = useT()
const user = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (isDownloading) return
try {
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) {
window.AndroidBridge.downloadFile(filename, jsonString)
} else {
const blob = new Blob([jsonString], {type: 'application/json'})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
toast.success(
t('settings.data_privacy.download.success', 'Your data export has been downloaded.'),
)
} catch (e) {
console.error('Error downloading data export', e)
toast.error(
t(
'settings.data_privacy.download.error',
'Failed to download your data. Please try again.',
),
)
} finally {
setIsDownloading(false)
}
}
return (
<SettingsRow
label={t('settings.data_privacy.export', 'Export your data')}
description={t(
'settings.data_privacy.description',
'Download a JSON file containing all your information: profile, account, messages, compatibility answers, starred profiles, votes, endorsements, search bookmarks, etc.',
)}
>
<Button
color="gray-outline"
onClick={handleDownload}
className="w-full sm:w-fit"
disabled={isDownloading}
loading={isDownloading}
>
<ArrowDownTrayIcon className="mr-2 h-4 w-4" aria-hidden />
{isDownloading
? t('settings.data_privacy.downloading', 'Downloading...')
: t('settings.data_privacy.download', 'Download all my data')}
</Button>
</SettingsRow>
)
}

View File

@@ -0,0 +1,123 @@
import clsx from 'clsx'
import {CSSProperties, ReactNode} from 'react'
import {Col} from 'web/components/layout/col'
import {surface} from 'web/components/widgets/surface'
/**
* The settings page's two building blocks.
*
* The General tab used to be a flat run of `<h3>` + control in a single `gap-2` column: eight
* sections at identical visual weight, so nothing could read as more or less important, and the
* container's `max-w-fit` collapsed the whole thing to ~400px against a full-width page. The
* Notifications tab, meanwhile, already grouped its rows into bordered cards — the same page
* disagreeing with itself about what a settings section looks like.
*
* These two components are that card treatment, generalised. `SettingsCard` inherits `surface` from
* `widgets/surface` — the vocabulary `/about` and `/home` already use — rather than inventing a
* third elevation.
*/
/**
* One group of related settings: icon, title, one-line description, then a divided list of rows.
*
* `data-form-section` is what the section index reads to build itself (see `ProfileFormNav`), so the
* markup stays the source of truth for what sections exist and in what order.
*/
export function SettingsCard(props: {
id: string
title: string
description?: string
icon: ReactNode
/** `danger` tints the ring and the icon chip red. Reserved for irreversible actions. */
tone?: 'default' | 'danger'
className?: string
style?: CSSProperties
children: ReactNode
}) {
const {id, title, description, icon, tone = 'default', className, style, children} = props
const isDanger = tone === 'danger'
return (
<section
id={id}
data-form-section={title}
style={style}
className={clsx(
surface,
'animate-fade-up overflow-hidden',
// `red`, not `scarlet` — the latter is this theme's grey "no" token, not a red.
isDanger && '!ring-red-500/30',
className,
)}
>
{/* The icon is centred on the title line, not on the title-plus-description block: aligned to
the block it floats between the two lines, level with neither. The description then hangs
at `pl-12` (chip + gap) so it still starts on the title's left edge. */}
<header className="border-canvas-200/70 border-b px-4 py-4 sm:px-5">
<div className="flex items-center gap-3">
<span
aria-hidden
className={clsx(
'flex h-9 w-9 flex-none items-center justify-center rounded-xl [&>svg]:h-5 [&>svg]:w-5',
isDanger
? 'bg-red-500/10 text-red-500'
: 'bg-primary-500/10 text-primary-600 dark:text-primary-500',
)}
>
{icon}
</span>
<h2 className="text-ink-1000 min-w-0 text-base font-semibold leading-tight">{title}</h2>
</div>
{description && (
<p className="text-ink-500 mt-2 pl-12 text-sm leading-snug">{description}</p>
)}
</header>
<Col className="divide-canvas-200/70 divide-y">{children}</Col>
</section>
)
}
/**
* One setting: what it is on the left, the control that changes it on the right.
*
* The pattern is lifted from `ConnectionPreferencesSettings`, which was already the only part of the
* General tab that explained its own options. Applying it to language/theme/font/units turns four
* orphaned headings into four rows of one card.
*
* Below `sm` the control drops under the label rather than squeezing beside it — a `<select>` and a
* three-way pill group have no useful narrow form.
*/
export function SettingsRow(props: {
label: ReactNode
description?: ReactNode
/** Renders the label as a `<label for=...>` when the control is a real form element. */
htmlFor?: string
/** Stacks the control under the label at every width. For controls that need the room. */
stacked?: boolean
className?: string
children: ReactNode
}) {
const {label, description, htmlFor, stacked, className, children} = props
const Label = htmlFor ? 'label' : 'div'
return (
<div
className={clsx(
'flex flex-col gap-3 px-4 py-4 sm:px-5',
!stacked && 'sm:flex-row sm:items-center sm:justify-between sm:gap-6',
className,
)}
>
<div className="min-w-0">
<Label
htmlFor={htmlFor}
className={clsx('text-ink-1000 block text-sm font-medium', htmlFor && 'cursor-pointer')}
>
{label}
</Label>
{description && <p className="text-ink-500 mt-1 text-sm leading-snug">{description}</p>}
</div>
<div className={clsx('min-w-0', !stacked && 'sm:flex-none')}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import {ComputerDesktopIcon, MoonIcon, SunIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {pillBase, pillColors} from 'web/components/widgets/choices-toggle-group'
import {useTheme} from 'web/hooks/use-theme'
import {useT} from 'web/lib/locale'
type ThemeOption = 'light' | 'dark' | 'auto'
/**
* Light / Dark / Auto as three pills.
*
* The previous control was a single button that cycled and labelled itself with the *current* state
* ("Dark (auto)"). A control that only ever shows where you are hides both that a third option
* exists and how many clicks away it is — you had to press it and watch to find out. Three pills say
* the whole state space at a glance and cost one click to reach any of it.
*
* Built on `pillBase`/`pillColors` rather than `ChoicesToggleGroup` because each option carries an
* icon and its own test id; the pill spec itself is shared so the two read as one control type.
*/
export function ThemePicker(props: {className?: string}) {
const {className} = props
const {theme, setTheme} = useTheme()
const t = useT()
const options: {value: ThemeOption; label: string; icon: typeof SunIcon}[] = [
{value: 'light', label: t('theme.light', 'Light'), icon: SunIcon},
{value: 'dark', label: t('theme.dark', 'Dark'), icon: MoonIcon},
{value: 'auto', label: t('theme.auto', 'Auto'), icon: ComputerDesktopIcon},
]
return (
<div
role="radiogroup"
aria-label={t('settings.general.theme', 'Theme')}
data-testid="settings-dark-light-toggle"
className={clsx('flex flex-row flex-wrap gap-2', className)}
>
{options.map(({value, label, icon: Icon}) => (
<button
key={value}
type="button"
role="radio"
aria-checked={theme === value}
data-testid={`settings-theme-${value}`}
onClick={() => setTheme(value)}
className={clsx(pillBase, pillColors['indigo-dark'], 'gap-1.5')}
>
<Icon className="h-4 w-4" aria-hidden />
{label}
</button>
))}
</div>
)
}

View File

@@ -10,6 +10,17 @@ const SELECTED_CTA =
const UNSELECTED =
'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700'
/**
* The pill's shape, motion and focus ring, split out so other single-choice controls that are not
* built on `RadioGroup` — the theme picker in settings — can be the same object rather than a
* lookalike. Pair it with `pillColors[...]`, which keys off `aria-checked`, so any control using
* these must set that attribute.
*/
export const pillBase = clsx(
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
)
const colorClasses = {
'indigo-dark': `${UNSELECTED} ${SELECTED_CTA}`,
indigo: `${UNSELECTED} aria-checked:border-primary-400 aria-checked:bg-primary-100 aria-checked:text-primary-900`,
@@ -17,6 +28,8 @@ const colorClasses = {
red: `${UNSELECTED} aria-checked:border-scarlet-500 aria-checked:bg-scarlet-500 aria-checked:text-ink-0`,
}
export const pillColors = colorClasses
export type ColorType = keyof typeof colorClasses
export function ChoicesToggleGroup<T extends Record<string, string | number | boolean>>(props: {
@@ -84,8 +97,7 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
}
className={({disabled}) =>
clsx(
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
pillBase,
disabled
? 'border-canvas-200 bg-canvas-100 text-ink-300 cursor-not-allowed'
: colorClasses[color],

View File

@@ -1,272 +1,65 @@
import {PrivateUser} from 'common/src/user'
import {updateEmail} from 'firebase/auth'
import {useState} from 'react'
import {useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
import {AboutSettings} from 'web/components/about-settings'
import {Button} from 'web/components/buttons/button'
import {ConnectionPreferencesSettings} from 'web/components/connection-preferences-settings'
import {EmailVerificationButton} from 'web/components/email-verification-button'
import {FontPicker} from 'web/components/font-picker'
import {LanguagePicker} from 'web/components/language/language-picker'
import {Col} from 'web/components/layout/col'
import {UncontrolledTabs} from 'web/components/layout/tabs'
import MeasurementSystemToggle from 'web/components/measurement-system-toggle'
import {NoSEO} from 'web/components/NoSEO'
import {NotificationSettings} from 'web/components/notifications'
import {PageBase} from 'web/components/page-base'
import {DeleteAccountSurveyModal} from 'web/components/profile/delete-account-survey-modal'
import HiddenProfilesModal from 'web/components/settings/hidden-profiles-modal'
import ThemeIcon from 'web/components/theme-icon'
import {WithPrivateUser} from 'web/components/user/with-user'
import {Input} from 'web/components/widgets/input'
import {ProfileFormNav} from 'web/components/profile-form-nav'
import {GeneralSettings} from 'web/components/settings/general-settings'
import {Title} from 'web/components/widgets/title'
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {sendPasswordReset} from 'web/lib/firebase/password'
import {useT} from 'web/lib/locale'
import {isNativeMobile} from 'web/lib/util/webview'
/** The container the section index reads. See `SettingsCard`. */
export const SETTINGS_SECTIONS_ROOT = '[data-settings-sections]'
export default function SettingsPage() {
const t = useT()
useRedirectIfSignedOut()
// Inactive tabs stay mounted (merely `hidden`), so the General tab's sections remain queryable
// from every tab. Without this the index would sit beside Notifications listing sections that are
// not on screen, and scroll to nothing when clicked.
const [activeIndex, setActiveIndex] = useState(0)
// `col-span-10` rather than the default 8: at 8 the page reserved two grid columns of empty space
// on the right and then squeezed the section index out of the content column's width.
return (
<PageBase trackPageView={'settings page'} className={'mx-4 mb-4'}>
<PageBase trackPageView={'settings page'} className={'col-span-10 mb-8'}>
<NoSEO title={t('settings.title', 'Settings')} />
<Title>{t('settings.title', 'Settings')}</Title>
<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 />,
},
]}
trackingName={'settings page'}
/>
{/* Same shell as the profile form: a content column capped at a readable width with the
section index in the gutter from `xl` up, rather than the old `max-w-fit` column that
collapsed to ~400px and left the rest of the page empty. */}
<div className="mx-auto flex w-full max-w-5xl gap-10 px-4 py-2 sm:px-6">
<Col className="w-full min-w-0 max-w-2xl">
<Title>{t('settings.title', 'Settings')}</Title>
<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 />,
},
]}
trackingName={'settings page'}
onActiveIndexChange={setActiveIndex}
/>
</Col>
{/* `mt-36` drops the index level with the top of the first card, past the title and tab row. */}
{activeIndex === 0 && (
<ProfileFormNav
root={SETTINGS_SECTIONS_ROOT}
className="sticky top-8 mt-36 hidden h-fit w-48 shrink-0 xl:flex"
/>
)}
</div>
</PageBase>
)
}
export const GeneralSettings = () => (
<WithPrivateUser>{(user) => <LoadedGeneralSettings privateUser={user} />}</WithPrivateUser>
)
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 t = useT()
const firebaseUser = useFirebaseUser()
if (!firebaseUser) return null
const changeUserEmail = async (newEmail: string) => {
if (!firebaseUser) return
try {
await updateEmail(firebaseUser, newEmail)
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'))
}
}
const onSubmitEmailChange = (data: {newEmail: string}) => {
if (!firebaseUser) return
if (data.newEmail === firebaseUser.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.language', 'Language')}</h3>
<LanguagePicker className={'w-fit min-w-[120px]'} />
<h3>{t('settings.general.measurement', 'Measurement System')}</h3>
<MeasurementSystemToggle />
<h3>{t('settings.general.theme', 'Theme')}</h3>
<ThemeIcon className="h-6 w-6" />
<h3>{t('settings.general.font', 'Font')}</h3>
<FontPicker className={'w-fit min-w-[180px]'} />
<h3>{t('settings.data_privacy.title', 'Data & Privacy')}</h3>
<DataPrivacySettings />
<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.connection_preferences.title', 'Connection Preferences')}</h3>
<ConnectionPreferencesSettings />
<h3>{t('settings.general.account', 'Account')}</h3>
<h5>{t('settings.general.email', 'Email')}</h5>
<EmailVerificationButton />
{!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={!firebaseUser}
/>
{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>
<div className={'w-fit'}>
<DeleteAccountSurveyModal />
</div>
</div>
{/* Hidden profiles modal */}
<HiddenProfilesModal open={showHiddenProfiles} setOpen={setShowHiddenProfiles} />
</>
)
}
const DataPrivacySettings = () => {
const t = useT()
const user = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (isDownloading) return
try {
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) {
window.AndroidBridge.downloadFile(filename, jsonString)
} else {
const blob = new Blob([jsonString], {type: 'application/json'})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
toast.success(
t('settings.data_privacy.download.success', 'Your data export has been downloaded.'),
)
} catch (e) {
console.error('Error downloading data export', e)
toast.error(
t(
'settings.data_privacy.download.error',
'Failed to download your data. Please try again.',
),
)
} finally {
setIsDownloading(false)
}
}
return (
<div className="flex flex-col gap-4 max-w-xl">
<p className="text-sm guidance">
{t(
'settings.data_privacy.description',
'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')}
</Button>
</div>
)
}