mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add additional details change from user settings
This commit is contained in:
parent
dc5ac3039f
commit
5a2732258f
10 changed files with 667 additions and 281 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 /> : ''}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 /> : ''}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue