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";
|
||||
|
||||
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 (
|
||||
<html>
|
||||
<body>
|
||||
{/* `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. */}
|
||||
<NextError statusCode={0} />
|
||||
<h2>Something went wrong!</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
|
|||
}, [params.subpage, params])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#f8f8f8]">
|
||||
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow">
|
||||
<div className="h-full w-full bg-[#f8f8f8] flex flex-col">
|
||||
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow flex-shrink-0">
|
||||
<BreadCrumbs type="org"></BreadCrumbs>
|
||||
<div className="my-2 py-2">
|
||||
<div className="w-100 flex flex-col space-y-1">
|
||||
|
|
@ -99,12 +99,13 @@ function OrgPage(props: { params: Promise<OrgParams> }) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6"></div>
|
||||
<div className="h-6 flex-shrink-0"></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{params.subpage == 'general' ? <OrgEditGeneral /> : ''}
|
||||
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
|
|||
|
||||
return (
|
||||
<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" />
|
||||
<div className="my-2 py-2">
|
||||
<div className="w-100 flex flex-col space-y-1">
|
||||
|
|
@ -102,7 +102,7 @@ function PaymentsPage(props: { params: Promise<PaymentsParams> }) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6"></div>
|
||||
<div className="h-6 flex-shrink-0"></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ function SettingsPage({ params }: { params: Promise<SettingsParams> }) {
|
|||
const CurrentComponent = navigationItems.find(item => item.id === subpage)?.component;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#f8f8f8]">
|
||||
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
|
||||
<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 flex-shrink-0">
|
||||
<BreadCrumbs
|
||||
type="user"
|
||||
last_breadcrumb={session?.user?.username}
|
||||
|
|
@ -109,13 +109,13 @@ function SettingsPage({ params }: { params: Promise<SettingsParams> }) {
|
|||
orgslug={orgslug}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-6" />
|
||||
<div className="h-6 flex-shrink-0" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
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"
|
||||
>
|
||||
{CurrentComponent && <CurrentComponent />}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ function UsersSettingsPage(props: { params: Promise<SettingsParams> }) {
|
|||
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' ? <OrgUsers /> : ''}
|
||||
{params.subpage == 'signups' ? <OrgAccess /> : ''}
|
||||
|
|
|
|||
|
|
@ -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 <IconElement className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
interface DetailItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
username: string;
|
||||
|
|
@ -40,87 +82,214 @@ interface FormValues {
|
|||
last_name: string;
|
||||
email: string;
|
||||
bio: string;
|
||||
details: {
|
||||
[key: string]: DetailItem;
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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 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);
|
||||
|
||||
const handleEmailChange = async (newEmail: string) => {
|
||||
toast.success('Profile Updated Successfully', { duration: 4000 })
|
||||
// Memoize handlers to prevent unnecessary re-renders
|
||||
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
|
||||
toast((t: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Please login again with your new email: {newEmail}</span>
|
||||
</div>
|
||||
), {
|
||||
duration: 4000,
|
||||
icon: '📧'
|
||||
})
|
||||
const handleIconChange = useCallback((value: string) => {
|
||||
onUpdate(id, 'icon', value);
|
||||
}, [id, onUpdate]);
|
||||
|
||||
// Wait for 4 seconds before signing out
|
||||
await new Promise(resolve => setTimeout(resolve, 4000))
|
||||
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
|
||||
}
|
||||
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onUpdate(id, 'text', e.target.value);
|
||||
}, [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 (
|
||||
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
|
||||
{session.data.user && (
|
||||
<Formik<FormValues>
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
username: session.data.user.username,
|
||||
first_name: session.data.user.first_name,
|
||||
last_name: session.data.user.last_name,
|
||||
email: session.data.user.email,
|
||||
bio: session.data.user.bio || '',
|
||||
}}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values, { setSubmitting }) => {
|
||||
const isEmailChanged = values.email !== session.data.user.email
|
||||
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)
|
||||
}}
|
||||
<div className="space-y-2 p-4 border rounded-lg bg-white shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Input
|
||||
value={localLabel}
|
||||
onChange={handleLabelChange}
|
||||
placeholder="Enter label (e.g., Title, Location)"
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{({ 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>
|
||||
<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">
|
||||
|
|
@ -148,7 +317,7 @@ function UserEditGeneral() {
|
|||
{touched.email && errors.email && (
|
||||
<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">
|
||||
<AlertTriangle size={16} />
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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="flex flex-col items-center space-y-6">
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
{success && (
|
||||
{profilePicture.success && (
|
||||
<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" />
|
||||
<span className="font-semibold first-letter:uppercase">{success}</span>
|
||||
<span className="font-semibold first-letter:uppercase">{profilePicture.success}</span>
|
||||
</div>
|
||||
)}
|
||||
{localAvatar ? (
|
||||
{profilePicture.localAvatar ? (
|
||||
<UserAvatar
|
||||
border="border-8"
|
||||
width={120}
|
||||
avatar_url={URL.createObjectURL(localAvatar)}
|
||||
avatar_url={URL.createObjectURL(profilePicture.localAvatar)}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
<ArrowBigUpDash size={16} className="mr-2" />
|
||||
<span>Uploading</span>
|
||||
|
|
@ -258,7 +520,7 @@ function UserEditGeneral() {
|
|||
id="fileInput"
|
||||
accept={SUPPORTED_FILES}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
onChange={profilePicture.handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -291,11 +553,129 @@ function UserEditGeneral() {
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default UserEditGeneral
|
||||
|
|
|
|||
|
|
@ -35,7 +35,30 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
|
|||
const [showResults, setShowResults] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
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(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -49,31 +72,8 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
if (debouncedSearch.trim().length === 0) {
|
||||
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]);
|
||||
debouncedSearchFunction(searchQuery);
|
||||
}, [searchQuery, debouncedSearchFunction]);
|
||||
|
||||
const handleSearchFocus = () => {
|
||||
if (searchQuery.trim().length > 0) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
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 {
|
||||
RequestBody,
|
||||
RequestBodyFormWithAuthHeader,
|
||||
RequestBodyWithAuthHeader,
|
||||
errorHandling,
|
||||
getResponseMetadata,
|
||||
} 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(
|
||||
`${getAPIUrl()}users/user_id/${user_id}`,
|
||||
RequestBody('GET', null, null)
|
||||
`${getAPIUrl()}users/id/${user_id}`,
|
||||
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||
)
|
||||
const res = await errorHandling(result)
|
||||
return res
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue