From 5a2732258f87c2092236ba27829d15e9f964efd1 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 29 Mar 2025 17:59:12 +0100 Subject: [PATCH] feat: add additional details change from user settings --- apps/web/app/global-error.tsx | 23 +- .../dash/org/settings/[subpage]/page.tsx | 7 +- .../dash/payments/[subpage]/page.tsx | 4 +- .../user-account/settings/[subpage]/page.tsx | 8 +- .../dash/users/settings/[subpage]/page.tsx | 2 +- .../UserEditGeneral/UserEditGeneral.tsx | 814 +++++++++++++----- .../components/Objects/Search/SearchBar.tsx | 52 +- apps/web/components/ui/button.tsx | 2 +- apps/web/hooks/useDebounce.ts | 29 +- apps/web/services/users/users.ts | 7 +- 10 files changed, 667 insertions(+), 281 deletions(-) diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index 9bda5fee..85b80af9 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -1,22 +1,17 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; -import NextError from "next/error"; -import { useEffect } from "react"; - -export default function GlobalError({ error }: { error: Error & { digest?: string } }) { - useEffect(() => { - Sentry.captureException(error); - }, [error]); - +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { return ( - {/* `NextError` is the default Next.js error page component. Its type - definition requires a `statusCode` prop. However, since the App Router - does not expose status codes for errors, we simply pass 0 to render a - generic error message. */} - +

Something went wrong!

+ ); diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index b3bda556..fc83647c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -75,8 +75,8 @@ function OrgPage(props: { params: Promise }) { }, [params.subpage, params]) return ( -
-
+
+
@@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise }) { ))}
-
+
{params.subpage == 'general' ? : ''} {params.subpage == 'previews' ? : ''} diff --git a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx index d98bc0a5..b72d733c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx @@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise }) { return (
-
+
@@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise }) { />
-
+
}) { const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component; return ( -
-
+
+
}) { orgslug={orgslug} />
-
+
{CurrentComponent && } diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx index 79001a41..71131cf8 100644 --- a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise }) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} - className="h-full overflow-y-auto" + className="flex-1 overflow-y-auto" > {params.subpage == 'users' ? : ''} {params.subpage == 'signups' ? : ''} diff --git a/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx b/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx index d5301384..8d08bf07 100644 --- a/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral.tsx @@ -1,6 +1,7 @@ 'use client'; import { updateProfile } from '@services/settings/profile' -import React, { useEffect } from 'react' +import { getUser } from '@services/users/users' +import React, { useEffect, useState, useCallback, useMemo } from 'react' import { Formik, Form } from 'formik' import { useLHSession } from '@components/Contexts/LHSessionContext' import { @@ -10,7 +11,19 @@ import { Info, UploadCloud, AlertTriangle, - LogOut + LogOut, + Briefcase, + GraduationCap, + MapPin, + Building2, + Globe, + Laptop2, + Award, + BookOpen, + Link, + Users, + Calendar, + Lightbulb } from 'lucide-react' import UserAvatar from '@components/Objects/UserAvatar' import { updateUserAvatar } from '@services/users/users' @@ -20,19 +33,48 @@ import { Input } from "@components/ui/input" import { Textarea } from "@components/ui/textarea" import { Button } from "@components/ui/button" import { Label } from "@components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/ui/select" import { toast } from 'react-hot-toast' import { signOut } from 'next-auth/react' import { getUriWithoutOrg } from '@services/config/config'; +import { useDebounce } from '@/hooks/useDebounce'; const SUPPORTED_FILES = constructAcceptValue(['image']) -const validationSchema = Yup.object().shape({ - email: Yup.string().email('Invalid email').required('Email is required'), - username: Yup.string().required('Username is required'), - first_name: Yup.string().required('First name is required'), - last_name: Yup.string().required('Last name is required'), - bio: Yup.string().max(400, 'Bio must be 400 characters or less'), -}) +const AVAILABLE_ICONS = [ + { name: 'briefcase', label: 'Briefcase', component: Briefcase }, + { name: 'graduation-cap', label: 'Education', component: GraduationCap }, + { name: 'map-pin', label: 'Location', component: MapPin }, + { name: 'building-2', label: 'Organization', component: Building2 }, + { name: 'speciality', label: 'Speciality', component: Lightbulb }, + { name: 'globe', label: 'Website', component: Globe }, + { name: 'laptop-2', label: 'Tech', component: Laptop2 }, + { name: 'award', label: 'Achievement', component: Award }, + { name: 'book-open', label: 'Book', component: BookOpen }, + { name: 'link', label: 'Link', component: Link }, + { name: 'users', label: 'Community', component: Users }, + { name: 'calendar', label: 'Calendar', component: Calendar }, +] as const; + +const IconComponent = ({ iconName }: { iconName: string }) => { + const iconConfig = AVAILABLE_ICONS.find(i => i.name === iconName); + if (!iconConfig) return null; + const IconElement = iconConfig.component; + return ; +}; + +interface DetailItem { + id: string; + label: string; + icon: string; + text: string; +} interface FormValues { username: string; @@ -40,8 +82,480 @@ interface FormValues { last_name: string; email: string; bio: string; + details: { + [key: string]: DetailItem; + }; } +const DETAIL_TEMPLATES = { + general: [ + { id: 'title', label: 'Title', icon: 'briefcase', text: '' }, + { id: 'affiliation', label: 'Affiliation', icon: 'building-2', text: '' }, + { id: 'location', label: 'Location', icon: 'map-pin', text: '' }, + { id: 'website', label: 'Website', icon: 'globe', text: '' }, + { id: 'linkedin', label: 'LinkedIn', icon: 'link', text: '' } + ], + academic: [ + { id: 'institution', label: 'Institution', icon: 'building-2', text: '' }, + { id: 'department', label: 'Department', icon: 'graduation-cap', text: '' }, + { id: 'research', label: 'Research Area', icon: 'book-open', text: '' }, + { id: 'academic-title', label: 'Academic Title', icon: 'award', text: '' } + ], + professional: [ + { id: 'company', label: 'Company', icon: 'building-2', text: '' }, + { id: 'industry', label: 'Industry', icon: 'briefcase', text: '' }, + { id: 'expertise', label: 'Expertise', icon: 'laptop-2', text: '' }, + { id: 'community', label: 'Community', icon: 'users', text: '' } + ] +} as const; + +const validationSchema = Yup.object().shape({ + email: Yup.string().email('Invalid email').required('Email is required'), + username: Yup.string().required('Username is required'), + first_name: Yup.string().required('First name is required'), + last_name: Yup.string().required('Last name is required'), + bio: Yup.string().max(400, 'Bio must be 400 characters or less'), + details: Yup.object().shape({}) +}); + +// Memoized detail card component for better performance +const DetailCard = React.memo(({ + id, + detail, + onUpdate, + onRemove, + onLabelChange +}: { + id: string; + detail: DetailItem; + onUpdate: (id: string, field: keyof DetailItem, value: string) => void; + onRemove: (id: string) => void; + onLabelChange: (id: string, newLabel: string) => void; +}) => { + // Add local state for label input + const [localLabel, setLocalLabel] = useState(detail.label); + + // Debounce the label change handler + const debouncedLabelChange = useDebounce((newLabel: string) => { + if (newLabel !== detail.label) { + onLabelChange(id, newLabel); + } + }, 500); + + // Memoize handlers to prevent unnecessary re-renders + const handleLabelChange = useCallback((e: React.ChangeEvent) => { + const newLabel = e.target.value; + setLocalLabel(newLabel); + debouncedLabelChange(newLabel); + }, [debouncedLabelChange]); + + const handleIconChange = useCallback((value: string) => { + onUpdate(id, 'icon', value); + }, [id, onUpdate]); + + const handleTextChange = useCallback((e: React.ChangeEvent) => { + onUpdate(id, 'text', e.target.value); + }, [id, onUpdate]); + + const handleRemove = useCallback(() => { + onRemove(id); + }, [id, onRemove]); + + // Update local label when prop changes + useEffect(() => { + setLocalLabel(detail.label); + }, [detail.label]); + + return ( +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ ); +}); + +DetailCard.displayName = 'DetailCard'; + +// Form component to handle the details section +const UserEditForm = ({ + values, + setFieldValue, + handleChange, + errors, + touched, + isSubmitting, + profilePicture +}: { + values: FormValues; + setFieldValue: (field: string, value: any) => void; + handleChange: (e: React.ChangeEvent) => void; + errors: any; + touched: any; + isSubmitting: boolean; + profilePicture: { + error: string | undefined; + success: string; + isLoading: boolean; + localAvatar: File | null; + handleFileChange: (event: any) => Promise; + }; +}) => { + // Memoize template handlers + const templateHandlers = useMemo(() => + Object.entries(DETAIL_TEMPLATES).reduce((acc, [key, template]) => ({ + ...acc, + [key]: () => { + const currentIds = new Set(Object.keys(values.details)); + const newDetails = { ...values.details }; + + template.forEach((item) => { + if (!currentIds.has(item.id)) { + newDetails[item.id] = { ...item }; + } + }); + + setFieldValue('details', newDetails); + } + }), {} as Record void>) + , [values.details, setFieldValue]); + + // Memoize detail handlers + const detailHandlers = useMemo(() => ({ + handleDetailUpdate: (id: string, field: keyof DetailItem, value: string) => { + const newDetails = { ...values.details }; + newDetails[id] = { ...newDetails[id], [field]: value }; + setFieldValue('details', newDetails); + }, + handleDetailRemove: (id: string) => { + const newDetails = { ...values.details }; + delete newDetails[id]; + setFieldValue('details', newDetails); + } + }), [values.details, setFieldValue]); + + return ( +
+
+
+

+ Account Settings +

+

+ Manage your personal information and preferences +

+
+ +
+ {/* Profile Information Section */} +
+
+ + + {touched.email && errors.email && ( +

{errors.email}

+ )} + {values.email !== values.email && ( +
+ + You will be logged out after changing your email +
+ )} +
+ +
+ + + {touched.username && errors.username && ( +

{errors.username}

+ )} +
+ +
+ + + {touched.first_name && errors.first_name && ( +

{errors.first_name}

+ )} +
+ +
+ + + {touched.last_name && errors.last_name && ( +

{errors.last_name}

+ )} +
+ +
+ +