Add settings page

This commit is contained in:
MartinBraquet
2025-10-29 16:25:03 +01:00
parent 4ca3f3c8ee
commit 6470319fd6
6 changed files with 185 additions and 254 deletions

View File

@@ -1,61 +1,50 @@
import {
LogoutIcon,
MoonIcon,
SunIcon,
LoginIcon,
} from '@heroicons/react/outline'
import {LoginIcon, LogoutIcon,} from '@heroicons/react/outline'
import clsx from 'clsx'
import { buildArray } from 'common/util/array'
import Router, { useRouter } from 'next/router'
import { useUser } from 'web/hooks/use-user'
import { firebaseLogin, firebaseLogout } from 'web/lib/firebase/users'
import { withTracking } from 'web/lib/service/analytics'
import { ProfileSummary } from './profile-summary'
import { Item, SidebarItem } from './sidebar-item'
import {buildArray} from 'common/util/array'
import Router, {useRouter} from 'next/router'
import {useUser} from 'web/hooks/use-user'
import {firebaseLogout} from 'web/lib/firebase/users'
import {withTracking} from 'web/lib/service/analytics'
import {ProfileSummary} from './profile-summary'
import {Item, SidebarItem} from './sidebar-item'
import SiteLogo from '../site-logo'
import { Button, ColorType, SizeType } from 'web/components/buttons/button'
import {Button, ColorType, SizeType} from 'web/components/buttons/button'
import {signupRedirect} from 'web/lib/util/signup'
import { useProfile } from 'web/hooks/use-profile'
import { useTheme } from 'web/hooks/use-theme'
import {useProfile} from 'web/hooks/use-profile'
export default function Sidebar(props: {
className?: string
isMobile?: boolean
navigationOptions: Item[]
}) {
const { className, isMobile } = props
const {className, isMobile} = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const profile = useProfile()
const { theme, setTheme } = useTheme()
const toggleTheme = () => {
setTheme(theme === 'auto' ? 'dark' : theme === 'dark' ? 'light' : 'auto')
}
const navOptions = props.navigationOptions
const bottomNavOptions = bottomNav(!!user, theme, toggleTheme)
const bottomNavOptions = bottomNav(!!user)
return (
<nav
aria-label="Sidebar"
className={clsx('flex h-screen flex-col h-full max-h-screen overflow-y-auto', className)}
>
<SiteLogo />
<SiteLogo/>
{user === undefined && <div className="h-[56px]" />}
{user === undefined && <div className="h-[56px]"/>}
{user && !isMobile && <ProfileSummary user={user} className="mb-3" />}
{user && !isMobile && <ProfileSummary user={user} className="mb-3"/>}
<div className="mb-4 flex flex-col gap-1">
{navOptions.map((item) => (
<SidebarItem key={item.name} item={item} currentPage={currentPage} />
<SidebarItem key={item.name} item={item} currentPage={currentPage}/>
))}
{user === null && <SignUpButton className="mt-4" text="Sign up" />}
{user === null && <SignUpButton className="mt-4" text="Sign up"/>}
{/*{user === null && <SignUpAsMatchmaker className="mt-2" />}*/}
{user && profile === null && (
@@ -66,7 +55,7 @@ export default function Sidebar(props: {
</div>
<div className="mb-6 mt-auto flex flex-col gap-1">
{bottomNavOptions.map((item) => (
<SidebarItem key={item.name} item={item} currentPage={currentPage} />
<SidebarItem key={item.name} item={item} currentPage={currentPage}/>
))}
</div>
</nav>
@@ -82,39 +71,10 @@ const logout = async () => {
const bottomNav = (
loggedIn: boolean,
theme: 'light' | 'dark' | 'auto' | 'loading',
toggleTheme: () => void
) =>
buildArray<Item>(
{
name: theme ?? 'auto',
children:
theme === 'light' ? (
'Light'
) : theme === 'dark' ? (
'Dark'
) : (
<>
<span className="hidden dark:inline">Dark</span>
<span className="inline dark:hidden">Light</span> (auto)
</>
),
icon: ({ className, ...props }) => (
<>
<MoonIcon
className={clsx(className, 'hidden dark:block')}
{...props}
/>
<SunIcon
className={clsx(className, 'block dark:hidden')}
{...props}
/>
</>
),
onClick: toggleTheme,
},
!loggedIn && { name: 'Sign in', icon: LoginIcon, href: '/signin' },
loggedIn && { name: 'Sign out', icon: LogoutIcon, onClick: logout }
!loggedIn && {name: 'Sign in', icon: LoginIcon, href: '/signin'},
loggedIn && {name: 'Sign out', icon: LogoutIcon, onClick: logout}
)
export const SignUpButton = (props: {
@@ -123,7 +83,7 @@ export const SignUpButton = (props: {
color?: ColorType
size?: SizeType
}) => {
const { className, text, color, size } = props
const {className, text, color, size} = props
return (
<Button
@@ -137,20 +97,20 @@ export const SignUpButton = (props: {
)
}
export const SignUpAsMatchmaker = (props: {
className?: string
size?: SizeType
}) => {
const { className, size } = props
return (
<Button
color={'indigo-outline'}
size={size ?? 'md'}
onClick={firebaseLogin}
className={clsx('w-full', className)}
>
Sign up as matchmaker
</Button>
)
}
// export const SignUpAsMatchmaker = (props: {
// className?: string
// size?: SizeType
// }) => {
// const {className, size} = props
//
// return (
// <Button
// color={'indigo-outline'}
// size={size ?? 'md'}
// onClick={firebaseLogin}
// className={clsx('w-full', className)}
// >
// Sign up as matchmaker
// </Button>
// )
// }

View File

@@ -1,5 +1,6 @@
import {HomeIcon, NewspaperIcon, QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {
CogIcon,
GlobeAltIcon,
HomeIcon as SolidHomeIcon,
LinkIcon,
@@ -118,6 +119,7 @@ const Organization = {name: 'Organization', href: '/organization', icon: GlobeAl
const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp};
const Contact = {name: 'Contact', href: '/contact', icon: FaEnvelope};
const News = {name: "What's new", href: '/news', icon: NewspaperIcon};
const Settings = {name: "Settings", href: '/settings', icon: CogIcon};
const base = [
About,
@@ -144,7 +146,7 @@ function getBottomNavigation(user: User, profile: Profile | null | undefined) {
icon: (props) => (
<PrivateMessagesIcon bubbleClassName={'-mr-5'} solid {...props} />
),
}
},
)
}
@@ -160,6 +162,7 @@ const getDesktopNavigation = (user: User | null | undefined) => {
ProfilesHome,
Notifs,
Messages,
Settings,
...base,
)
@@ -170,6 +173,7 @@ const getDesktopNavigation = (user: User | null | undefined) => {
const getMobileSidebar = (_toggleModal: () => void) => {
return buildArray(
Settings,
...base,
)
}

View File

@@ -1,7 +1,6 @@
import {DotsHorizontalIcon, EyeIcon, LockClosedIcon, PencilIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import Router from 'next/router'
import router from 'next/router'
import Link from 'next/link'
import {User, UserActivity} from 'common/user'
import {Button} from 'web/components/buttons/button'
@@ -20,7 +19,6 @@ import {linkClass} from 'web/components/widgets/site-link'
import {updateProfile} from 'web/lib/api'
import {useState} from 'react'
import {VisibilityConfirmationModal} from './visibility-confirmation-modal'
import {deleteAccount} from "web/lib/util/delete";
import toast from "react-hot-toast";
import {StarButton} from "web/components/widgets/star-button";
import {disableProfile} from "web/lib/util/disable";
@@ -57,7 +55,8 @@ export default function ProfileHeader(props: {
<Row className={clsx('flex-wrap justify-between gap-2 py-1')}>
<Row className="items-center gap-1">
<Col className="gap-1">
{currentUser && isCurrentUser && disabled && <div className="text-red-500">You disabled your profile, so no one else can access it.</div>}
{currentUser && isCurrentUser && disabled &&
<div className="text-red-500">You disabled your profile, so no one else can access it.</div>}
<Row className="items-center gap-1 text-xl">
{!isCurrentUser && <OnlineIcon last_online_time={userActivity?.last_online_time}/>}
<span>
@@ -110,34 +109,6 @@ export default function ProfileHeader(props: {
),
onClick: () => setShowVisibilityModal(true),
},
{
name: 'Delete profile',
icon: null,
onClick: async () => {
const confirmed = confirm(
'Are you sure you want to delete your profile? This cannot be undone.'
)
if (confirmed) {
toast
.promise(deleteAccount(user.username), {
loading: 'Deleting account...',
success: () => {
router.push('/')
return 'Your account has been deleted.'
},
error: () => {
return 'Failed to delete account.'
},
})
.then(() => {
// return true
})
.catch(() => {
// return false
})
}
},
},
{
name: disabled ? 'Enable profile' : 'Disable profile',
icon: null,

View File

@@ -0,0 +1,38 @@
import {MoonIcon, SunIcon} from "@heroicons/react/outline"
import clsx from "clsx"
import {useTheme} from "web/hooks/use-theme"
import {Row} from "web/components/layout/row";
export default function ThemeIcon(props: {
className?: string
}) {
const {className} = props
const {theme, setTheme} = useTheme()
const toggleTheme = () => {
setTheme(theme === 'auto' ? 'dark' : theme === 'dark' ? 'light' : 'auto')
}
const children = theme === 'light' ? (
'Light'
) : theme === 'dark' ? (
'Dark'
) : (
<>
<span className="hidden dark:inline">Dark</span>
<span className="inline dark:hidden">Light</span> (auto)
</>
)
const icon = <>
<MoonIcon className={clsx(className, 'hidden dark:block')}/>
<SunIcon className={clsx(className, 'block dark:hidden')}/>
</>
return <button onClick={toggleTheme}>
<Row className="items-center gap-1 border-2 border-gray-500 rounded-full p-1 max-w-fit mx-2">
{icon}
{children}
</Row>
</button>
}

View File

@@ -13,6 +13,7 @@ import {useGroupedNotifications} from 'web/hooks/use-notifications'
import {usePrivateUser, useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useRedirectIfSignedOut} from "web/hooks/use-redirect-if-signed-out";
import {NotificationSettings} from "web/components/notifications";
export default function NotificationsPage() {
useRedirectIfSignedOut()
@@ -23,6 +24,7 @@ export default function NotificationsPage() {
<UncontrolledTabs
tabs={[
{title: 'Notifications', content: <NotificationsContent/>},
{title: 'Settings', content: <NotificationSettings/>},
]}
trackingName={'notifications page'}
/>

View File

@@ -1,33 +1,32 @@
import {PrivateUser} from 'common/src/user'
import {
notification_destination_types,
notification_preference,
notification_preferences,
} from 'common/user-notification-preferences'
import {useCallback} from 'react'
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 {usePrivateUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {MultiSelectAnswers} from 'web/components/answers/answer-compatibility-question-content'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {debounce} from 'lodash'
import {useRedirectIfSignedOut} from "web/hooks/use-redirect-if-signed-out";
import toast from "react-hot-toast";
import {deleteAccount} from "web/lib/util/delete";
import router from "next/router";
import {Button} from "web/components/buttons/button";
import {getAuth, sendEmailVerification, sendPasswordResetEmail, User} from 'firebase/auth';
import {auth} from "web/lib/firebase/users";
import {NotificationSettings} from "web/components/notifications";
import ThemeIcon from "web/components/theme-icon";
export default function NotificationsPage() {
useRedirectIfSignedOut()
const privateUser = usePrivateUser()
if (!privateUser) return null
const user = auth.currentUser
if (!privateUser || !user) return null
return (
<PageBase trackPageView={'settings page'} className={'mx-4'}>
<NoSEO/>
<Title>Settings</Title>
<UncontrolledTabs
tabs={[
{title: 'Account', content: <AccountSettings privateUser={privateUser}/>},
{title: 'Notifications', content: <NotificationSettings privateUser={privateUser}/>},
{title: 'General', content: <GeneralSettings privateUser={privateUser} user={user}/>},
{title: 'Notifications', content: <NotificationSettings/>},
]}
trackingName={'settings page'}
/>
@@ -35,140 +34,97 @@ export default function NotificationsPage() {
)
}
const AccountSettings = (props: { privateUser: PrivateUser }) => {
const {privateUser} = props
return <></>
}
const NotificationSettings = (props: { privateUser: PrivateUser }) => {
const {privateUser} = props
const [prefs, setPrefs] =
usePersistentInMemoryState<notification_preferences>(
privateUser.notificationPreferences,
'notification-preferences'
)
const notificationTypes: {
type: notification_preference
question: string
}[] = [
{
type: 'new_match',
question:
'Where do you want to be notified when someone ... matches with you?',
},
{
type: 'new_message',
question: '... sends you a new message?',
},
{
type: 'new_profile_like',
question: '... likes your profile?',
},
{
type: 'new_endorsement',
question: '... endorses you?',
},
{
type: 'new_profile_ship',
question: '... ships you?',
},
{
type: 'tagged_user',
question: '... mentions you?',
},
{
type: 'on_new_follow',
question: '... follows you?',
},
{
type: 'new_search_alerts',
question: 'Alerts from bookmarked searches?',
},
{
type: 'opt_out_all',
question:
'Do you want to opt out of all notifications? (You can always change this later)?',
},
]
return (
<div className="mx-auto max-w-2xl">
<div className="flex flex-col gap-8 p-4">
{notificationTypes.map(({type, question}) => (
<NotificationOption
key={type}
type={type}
question={question}
selected={prefs[type]}
onUpdate={(selected) => {
setPrefs((prevPrefs) => ({...prevPrefs, [type]: selected}))
}}
/>
))}
</div>
</div>
)
}
const NotificationOption = (props: {
type: notification_preference
question: string
selected: notification_destination_types[]
onUpdate: (selected: notification_destination_types[]) => void
const GeneralSettings = (props: {
privateUser: PrivateUser,
user: User,
}) => {
const {type, question, selected, onUpdate} = props
const {privateUser, user} = props
const getSelectedValues = (destinations: string[]) => {
const values: number[] = []
if ((destinations ?? []).includes('email')) values.push(0)
if ((destinations ?? []).includes('browser')) values.push(1)
return values
}
const setValue = async (value: number[]) => {
const newDestinations: notification_destination_types[] = []
if (value.includes(0)) newDestinations.push('email')
if (value.includes(1)) newDestinations.push('browser')
onUpdate(newDestinations)
save(selected, newDestinations)
}
const save = useCallback(
debounce(
(
oldDestinations: notification_destination_types[],
newDestinations: notification_destination_types[]
) => {
// for each medium, if it changed, trigger a save
const mediums = ['email', 'browser'] as const
mediums.forEach((medium) => {
const wasEnabled = oldDestinations.includes(medium)
const isEnabled = newDestinations.includes(medium)
if (wasEnabled !== isEnabled) {
api('update-notif-settings', {
type,
medium,
enabled: isEnabled,
})
}
const handleDeleteAccount = async () => {
const confirmed = confirm(
'Are you sure you want to delete your profile? This cannot be undone.'
)
if (confirmed) {
toast
.promise(deleteAccount(), {
loading: 'Deleting account...',
success: () => {
router.push('/')
return 'Your account has been deleted.'
},
error: () => {
return 'Failed to delete account.'
},
})
},
500
),
[]
)
.catch(() => {
console.log("Failed to delete account")
})
}
}
return (
<div className="flex flex-col gap-2">
<div className="text-ink-700 font-medium">{question}</div>
<MultiSelectAnswers
options={['By email', 'On notifications page']}
values={getSelectedValues(selected)}
setValue={setValue}
/>
const sendPasswordReset = async () => {
if (!privateUser?.email) {
toast.error('No email found on your account.')
return
}
const auth = getAuth()
toast.promise(
sendPasswordResetEmail(auth, privateUser.email),
{
loading: 'Sending password reset email...',
success: 'Password reset email sent — check your inbox and spam.',
error: 'Failed to send password reset email.',
}
)
.catch(() => {
console.log("Failed to send password reset email")
})
}
const sendVerificationEmail = async () => {
if (!privateUser?.email) {
toast.error('No email found on your account.')
return
}
if (!user) {
toast.error('You must be signed in to send a verification email.')
return
}
toast
.promise(sendEmailVerification(user), {
loading: 'Sending verification email...',
success: 'Verification email sent — check your inbox and spam.',
error: 'Failed to send verification email.',
})
.catch(() => {
console.log("Failed to send verification email")
})
}
const isEmailVerified = user.emailVerified
return <>
<div className="flex flex-col gap-2 max-w-fit">
<h3>Theme</h3>
<ThemeIcon className="h-6 w-6"/>
<h3>Account</h3>
<h5>Credentials</h5>
<Button
onClick={sendPasswordReset}
>
Send password reset email
</Button>
<h5>Verification</h5>
<Button onClick={sendVerificationEmail} disabled={!privateUser?.email || isEmailVerified}>
{isEmailVerified ? 'Email Verified' : 'Send verification email'}
</Button>
<h5>Dangerous</h5>
<Button color="red" onClick={handleDeleteAccount}>
Delete Account
</Button>
</div>
)
</>
}