feat: add additional details change from user settings

This commit is contained in:
swve 2025-03-29 17:59:12 +01:00
parent dc5ac3039f
commit 5a2732258f
10 changed files with 667 additions and 281 deletions

View file

@ -1,22 +1,17 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs"; export default function GlobalError({
import NextError from "next/error"; error,
import { useEffect } from "react"; reset,
}: {
export default function GlobalError({ error }: { error: Error & { digest?: string } }) { error: Error & { digest?: string };
useEffect(() => { reset: () => void;
Sentry.captureException(error); }) {
}, [error]);
return ( return (
<html> <html>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type <h2>Something went wrong!</h2>
definition requires a `statusCode` prop. However, since the App Router <button onClick={() => reset()}>Try again</button>
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body> </body>
</html> </html>
); );

View file

@ -75,8 +75,8 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
}, [params.subpage, params]) }, [params.subpage, params])
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow flex-shrink-0">
<BreadCrumbs type="org"></BreadCrumbs> <BreadCrumbs type="org"></BreadCrumbs>
<div className="my-2 py-2"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
))} ))}
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6 flex-shrink-0"></div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
> >
{params.subpage == 'general' ? <OrgEditGeneral /> : ''} {params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''} {params.subpage == 'previews' ? <OrgEditImages /> : ''}

View file

@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] flex flex-col"> <div className="h-screen w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow flex-shrink-0">
<BreadCrumbs type="payments" /> <BreadCrumbs type="payments" />
<div className="my-2 py-2"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
/> />
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6 flex-shrink-0"></div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}

View file

@ -92,8 +92,8 @@ function SettingsPage({ params }: { params: Promise<SettingsParams> }) {
const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component; const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component;
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow flex-shrink-0">
<BreadCrumbs <BreadCrumbs
type="user" type="user"
last_breadcrumb={session?.user?.username} last_breadcrumb={session?.user?.username}
@ -109,13 +109,13 @@ function SettingsPage({ params }: { params: Promise<SettingsParams> }) {
orgslug={orgslug} orgslug={orgslug}
/> />
</div> </div>
<div className="h-6" /> <div className="h-6 flex-shrink-0" />
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="flex-1 overflow-y-auto"
> >
{CurrentComponent && <CurrentComponent />} {CurrentComponent && <CurrentComponent />}
</motion.div> </motion.div>

View file

@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="flex-1 overflow-y-auto"
> >
{params.subpage == 'users' ? <OrgUsers /> : ''} {params.subpage == 'users' ? <OrgUsers /> : ''}
{params.subpage == 'signups' ? <OrgAccess /> : ''} {params.subpage == 'signups' ? <OrgAccess /> : ''}

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { updateProfile } from '@services/settings/profile' 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 { Formik, Form } from 'formik'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { import {
@ -10,7 +11,19 @@ import {
Info, Info,
UploadCloud, UploadCloud,
AlertTriangle, AlertTriangle,
LogOut LogOut,
Briefcase,
GraduationCap,
MapPin,
Building2,
Globe,
Laptop2,
Award,
BookOpen,
Link,
Users,
Calendar,
Lightbulb
} from 'lucide-react' } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import { updateUserAvatar } from '@services/users/users' import { updateUserAvatar } from '@services/users/users'
@ -20,19 +33,48 @@ import { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea" import { Textarea } from "@components/ui/textarea"
import { Button } from "@components/ui/button" import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label" import { Label } from "@components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/ui/select"
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config'; import { getUriWithoutOrg } from '@services/config/config';
import { useDebounce } from '@/hooks/useDebounce';
const SUPPORTED_FILES = constructAcceptValue(['image']) const SUPPORTED_FILES = constructAcceptValue(['image'])
const validationSchema = Yup.object().shape({ const AVAILABLE_ICONS = [
email: Yup.string().email('Invalid email').required('Email is required'), { name: 'briefcase', label: 'Briefcase', component: Briefcase },
username: Yup.string().required('Username is required'), { name: 'graduation-cap', label: 'Education', component: GraduationCap },
first_name: Yup.string().required('First name is required'), { name: 'map-pin', label: 'Location', component: MapPin },
last_name: Yup.string().required('Last name is required'), { name: 'building-2', label: 'Organization', component: Building2 },
bio: Yup.string().max(400, 'Bio must be 400 characters or less'), { 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 <IconElement className="w-4 h-4" />;
};
interface DetailItem {
id: string;
label: string;
icon: string;
text: string;
}
interface FormValues { interface FormValues {
username: string; username: string;
@ -40,87 +82,214 @@ interface FormValues {
last_name: string; last_name: string;
email: string; email: string;
bio: string; bio: string;
details: {
[key: string]: DetailItem;
};
} }
function UserEditGeneral() { const DETAIL_TEMPLATES = {
const session = useLHSession() as any; general: [
const access_token = session?.data?.tokens?.access_token; { id: 'title', label: 'Title', icon: 'briefcase', text: '' },
const [localAvatar, setLocalAvatar] = React.useState(null) as any { id: 'affiliation', label: 'Affiliation', icon: 'building-2', text: '' },
const [isLoading, setIsLoading] = React.useState(false) as any { id: 'location', label: 'Location', icon: 'map-pin', text: '' },
const [error, setError] = React.useState() as any { id: 'website', label: 'Website', icon: 'globe', text: '' },
const [success, setSuccess] = React.useState('') as any { 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 handleFileChange = async (event: any) => { const validationSchema = Yup.object().shape({
const file = event.target.files[0] email: Yup.string().email('Invalid email').required('Email is required'),
setLocalAvatar(file) username: Yup.string().required('Username is required'),
setIsLoading(true) first_name: Yup.string().required('First name is required'),
const res = await updateUserAvatar(session.data.user_uuid, file, access_token) last_name: Yup.string().required('Last name is required'),
// wait for 1 second to show loading animation bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
await new Promise((r) => setTimeout(r, 1500)) details: Yup.object().shape({})
if (res.success === false) { });
setError(res.HTTPmessage)
} else { // Memoized detail card component for better performance
setIsLoading(false) const DetailCard = React.memo(({
setError('') id,
setSuccess('Avatar Updated') 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);
const handleEmailChange = async (newEmail: string) => { // Memoize handlers to prevent unnecessary re-renders
toast.success('Profile Updated Successfully', { duration: 4000 }) const handleLabelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newLabel = e.target.value;
setLocalLabel(newLabel);
debouncedLabelChange(newLabel);
}, [debouncedLabelChange]);
// Show message about logging in with new email const handleIconChange = useCallback((value: string) => {
toast((t: any) => ( onUpdate(id, 'icon', value);
<div className="flex items-center gap-2"> }, [id, onUpdate]);
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
await new Promise(resolve => setTimeout(resolve, 4000)) onUpdate(id, 'text', e.target.value);
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') }) }, [id, onUpdate]);
}
useEffect(() => { }, [session, session.data]) const handleRemove = useCallback(() => {
onRemove(id);
}, [id, onRemove]);
// Update local label when prop changes
useEffect(() => {
setLocalLabel(detail.label);
}, [detail.label]);
return ( return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow"> <div className="space-y-2 p-4 border rounded-lg bg-white shadow-sm">
{session.data.user && ( <div className="flex justify-between items-center mb-3">
<Formik<FormValues> <Input
enableReinitialize value={localLabel}
initialValues={{ onChange={handleLabelChange}
username: session.data.user.username, placeholder="Enter label (e.g., Title, Location)"
first_name: session.data.user.first_name, className="max-w-[200px]"
last_name: session.data.user.last_name, />
email: session.data.user.email, <Button
bio: session.data.user.bio || '', type="button"
}} variant="ghost"
validationSchema={validationSchema} size="sm"
onSubmit={(values, { setSubmitting }) => { className="text-red-500 hover:text-red-700"
const isEmailChanged = values.email !== session.data.user.email onClick={handleRemove}
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => {
setSubmitting(false)
updateProfile(values, session.data.user.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400)
}}
> >
{({ isSubmitting, values, handleChange, errors, touched }) => ( Remove
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Icon</Label>
<Select
value={detail.icon}
onValueChange={handleIconChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select icon">
{detail.icon && (
<div className="flex items-center gap-2">
<IconComponent iconName={detail.icon} />
<span>
{AVAILABLE_ICONS.find(i => i.name === detail.icon)?.label}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{AVAILABLE_ICONS.map((icon) => (
<SelectItem key={icon.name} value={icon.name}>
<div className="flex items-center gap-2">
<icon.component className="w-4 h-4" />
<span>{icon.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Text</Label>
<Input
value={detail.text}
onChange={handleTextChange}
placeholder="Enter detail text"
/>
</div>
</div>
</div>
);
});
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<any>) => void;
errors: any;
touched: any;
isSubmitting: boolean;
profilePicture: {
error: string | undefined;
success: string;
isLoading: boolean;
localAvatar: File | null;
handleFileChange: (event: any) => Promise<void>;
};
}) => {
// 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<string, () => 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 (
<Form> <Form>
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
@ -148,7 +317,7 @@ function UserEditGeneral() {
{touched.email && errors.email && ( {touched.email && errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p> <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)} )}
{values.email !== session.data.user.email && ( {values.email !== values.email && (
<div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md"> <div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<AlertTriangle size={16} /> <AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your email</span> <span className="text-sm">You will be logged out after changing your email</span>
@ -218,6 +387,99 @@ function UserEditGeneral() {
<p className="text-red-500 text-sm mt-1">{errors.bio}</p> <p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)} )}
</div> </div>
<div className="space-y-4">
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<Label>Additional Details</Label>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setFieldValue('details', {});
}}
>
Clear All
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newDetails = { ...values.details };
const id = `detail-${Date.now()}`;
newDetails[id] = {
id,
label: 'New Detail',
icon: '',
text: ''
};
setFieldValue('details', newDetails);
}}
>
Add Detail
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(DETAIL_TEMPLATES).map(([key, template]) => (
<Button
key={key}
type="button"
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => {
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);
}}
>
{key === 'general' && <Briefcase className="w-4 h-4" />}
{key === 'academic' && <GraduationCap className="w-4 h-4" />}
{key === 'professional' && <Building2 className="w-4 h-4" />}
Add {key.charAt(0).toUpperCase() + key.slice(1)}
</Button>
))}
</div>
</div>
<div className="space-y-3">
{Object.entries(values.details).map(([id, detail]) => (
<DetailCard
key={id}
id={id}
detail={detail}
onUpdate={(id, field, value) => {
const newDetails = { ...values.details };
newDetails[id] = { ...newDetails[id], [field]: value };
setFieldValue('details', newDetails);
}}
onRemove={(id) => {
const newDetails = { ...values.details };
delete newDetails[id];
setFieldValue('details', newDetails);
}}
onLabelChange={(id, newLabel) => {
const newDetails = { ...values.details };
newDetails[id] = { ...newDetails[id], label: newLabel };
setFieldValue('details', newDetails);
}}
/>
))}
</div>
</div>
</div> </div>
{/* Profile Picture Section */} {/* Profile Picture Section */}
@ -225,28 +487,28 @@ function UserEditGeneral() {
<div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full"> <div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full">
<div className="flex flex-col items-center space-y-6"> <div className="flex flex-col items-center space-y-6">
<Label className="font-bold">Profile Picture</Label> <Label className="font-bold">Profile Picture</Label>
{error && ( {profilePicture.error && (
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm"> <div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm">
<FileWarning size={16} className="mr-2" /> <FileWarning size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{error}</span> <span className="font-semibold first-letter:uppercase">{profilePicture.error}</span>
</div> </div>
)} )}
{success && ( {profilePicture.success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm"> <div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" /> <Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span> <span className="font-semibold first-letter:uppercase">{profilePicture.success}</span>
</div> </div>
)} )}
{localAvatar ? ( {profilePicture.localAvatar ? (
<UserAvatar <UserAvatar
border="border-8" border="border-8"
width={120} width={120}
avatar_url={URL.createObjectURL(localAvatar)} avatar_url={URL.createObjectURL(profilePicture.localAvatar)}
/> />
) : ( ) : (
<UserAvatar border="border-8" width={120} /> <UserAvatar border="border-8" width={120} />
)} )}
{isLoading ? ( {profilePicture.isLoading ? (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center"> <div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2" /> <ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span> <span>Uploading</span>
@ -258,7 +520,7 @@ function UserEditGeneral() {
id="fileInput" id="fileInput"
accept={SUPPORTED_FILES} accept={SUPPORTED_FILES}
className="hidden" className="hidden"
onChange={handleFileChange} onChange={profilePicture.handleFileChange}
/> />
<Button <Button
type="button" type="button"
@ -291,11 +553,129 @@ function UserEditGeneral() {
</div> </div>
</div> </div>
</Form> </Form>
);
};
function UserEditGeneral() {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const [localAvatar, setLocalAvatar] = React.useState(null) as any
const [isLoading, setIsLoading] = React.useState(false) as any
const [error, setError] = React.useState() as any
const [success, setSuccess] = React.useState('') as any
const [userData, setUserData] = useState<any>(null);
useEffect(() => {
const fetchUserData = async () => {
if (session?.data?.user?.id) {
try {
const data = await getUser(session.data.user.id, access_token);
setUserData(data);
} catch (error) {
console.error('Error fetching user data:', error);
setError('Failed to load user data');
}
}
};
fetchUserData();
}, [session?.data?.user?.id]);
const handleFileChange = async (event: any) => {
const file = event.target.files[0]
setLocalAvatar(file)
setIsLoading(true)
const res = await updateUserAvatar(session.data.user_uuid, file, access_token)
// wait for 1 second to show loading animation
await new Promise((r) => setTimeout(r, 1500))
if (res.success === false) {
setError(res.HTTPmessage)
} else {
setIsLoading(false)
setError('')
setSuccess('Avatar Updated')
}
}
const handleEmailChange = async (newEmail: string) => {
toast.success('Profile Updated Successfully', { duration: 4000 })
// Show message about logging in with new email
toast((t: any) => (
<div className="flex items-center gap-2">
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
}
if (!userData) {
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow p-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
</div>
);
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<Formik<FormValues>
enableReinitialize
initialValues={{
username: userData.username,
first_name: userData.first_name,
last_name: userData.last_name,
email: userData.email,
bio: userData.bio || '',
details: userData.details || {},
}}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
const isEmailChanged = values.email !== userData.email
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => {
setSubmitting(false)
updateProfile(values, userData.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
// Refresh user data after successful update
getUser(userData.id, access_token).then(setUserData);
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400)
}}
>
{(formikProps) => (
<UserEditForm
{...formikProps}
profilePicture={{
error,
success,
isLoading,
localAvatar,
handleFileChange
}}
/>
)} )}
</Formik> </Formik>
)}
</div> </div>
) );
} }
export default UserEditGeneral export default UserEditGeneral

View file

@ -35,7 +35,30 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const session = useLHSession() as any; const session = useLHSession() as any;
const debouncedSearch = useDebounce(searchQuery, 300);
const debouncedSearchFunction = useDebounce(async (query: string) => {
if (query.trim().length === 0) {
setCourses([]);
return;
}
setIsLoading(true);
try {
const results = await searchOrgCourses(
orgslug,
query,
1,
5,
null,
session?.data?.tokens?.access_token
);
setCourses(results);
} catch (error) {
console.error('Error searching courses:', error);
setCourses([]);
}
setIsLoading(false);
}, 300);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -49,31 +72,8 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
}, []); }, []);
useEffect(() => { useEffect(() => {
const fetchCourses = async () => { debouncedSearchFunction(searchQuery);
if (debouncedSearch.trim().length === 0) { }, [searchQuery, debouncedSearchFunction]);
setCourses([]);
return;
}
setIsLoading(true);
try {
const results = await searchOrgCourses(
orgslug,
debouncedSearch,
1,
5,
null,
session?.data?.tokens?.access_token
);
setCourses(results);
} catch (error) {
console.error('Error searching courses:', error);
setCourses([]);
}
setIsLoading(false);
};
fetchCourses();
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
const handleSearchFocus = () => { const handleSearchFocus = () => {
if (searchQuery.trim().length > 0) { if (searchQuery.trim().length > 0) {

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none hover:cursor-pointer cursor-pointer [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {

View file

@ -1,17 +1,26 @@
import { useState, useEffect } from 'react'; import { useEffect, useRef } from 'react';
export function useDebounce<T>(value: T, delay: number): T { export function useDebounce<T extends (...args: any[]) => any>(
const [debouncedValue, setDebouncedValue] = useState<T>(value); callback: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => { return () => {
clearTimeout(handler); if (timeoutRef.current) {
}; clearTimeout(timeoutRef.current);
}, [value, delay]); }
};
return debouncedValue; }, []);
return ((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}) as T;
} }

View file

@ -2,14 +2,15 @@ import { getAPIUrl } from '@services/config/config'
import { import {
RequestBody, RequestBody,
RequestBodyFormWithAuthHeader, RequestBodyFormWithAuthHeader,
RequestBodyWithAuthHeader,
errorHandling, errorHandling,
getResponseMetadata, getResponseMetadata,
} from '@services/utils/ts/requests' } from '@services/utils/ts/requests'
export async function getUser(user_id: string) { export async function getUser(user_id: string, access_token: string) {
const result = await fetch( const result = await fetch(
`${getAPIUrl()}users/user_id/${user_id}`, `${getAPIUrl()}users/id/${user_id}`,
RequestBody('GET', null, null) RequestBodyWithAuthHeader('GET', null, null, access_token)
) )
const res = await errorHandling(result) const res = await errorHandling(result)
return res return res