mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: working UI with the database
This commit is contained in:
parent
b0d492a116
commit
306230174e
4 changed files with 417 additions and 173 deletions
|
|
@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { mutate } from 'swr'
|
import { mutate } from 'swr'
|
||||||
import { updateCourse } from '@services/courses/courses'
|
import { updateCourse } from '@services/courses/courses'
|
||||||
|
import { updateCertification } from '@services/courses/certifications'
|
||||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
|
||||||
function SaveState(props: { orgslug: string }) {
|
function SaveState(props: { orgslug: string }) {
|
||||||
|
|
@ -32,6 +33,8 @@ function SaveState(props: { orgslug: string }) {
|
||||||
// Course metadata
|
// Course metadata
|
||||||
await changeMetadataBackend()
|
await changeMetadataBackend()
|
||||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||||
|
// Certification data (if present)
|
||||||
|
await saveCertificationData()
|
||||||
await revalidateTags(['courses'], props.orgslug)
|
await revalidateTags(['courses'], props.orgslug)
|
||||||
dispatchCourse({ type: 'setIsSaved' })
|
dispatchCourse({ type: 'setIsSaved' })
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -66,6 +69,24 @@ function SaveState(props: { orgslug: string }) {
|
||||||
dispatchCourse({ type: 'setIsSaved' })
|
dispatchCourse({ type: 'setIsSaved' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certification data
|
||||||
|
const saveCertificationData = async () => {
|
||||||
|
if (course.courseStructure._certificationData) {
|
||||||
|
const certData = course.courseStructure._certificationData;
|
||||||
|
try {
|
||||||
|
await updateCertification(
|
||||||
|
certData.certification_uuid,
|
||||||
|
certData.config,
|
||||||
|
session.data?.tokens?.access_token
|
||||||
|
);
|
||||||
|
console.log('Certification data saved successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save certification data:', error);
|
||||||
|
// Don't throw error to prevent breaking the main save flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCourseOrder = (course_structure: any) => {
|
const handleCourseOrder = (course_structure: any) => {
|
||||||
const chapters = course_structure.chapters
|
const chapters = course_structure.chapters
|
||||||
const chapter_order_by_ids = chapters.map((chapter: any) => {
|
const chapter_order_by_ids = chapters.map((chapter: any) => {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import FormLayout, {
|
import {
|
||||||
FormField,
|
FormField,
|
||||||
FormLabelAndMessage,
|
FormLabelAndMessage,
|
||||||
Input,
|
Input,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@components/Objects/StyledElements/Form/Form';
|
} from '@components/Objects/StyledElements/Form/Form';
|
||||||
import { useFormik } from 'formik';
|
import { useFormik } from 'formik';
|
||||||
import { AlertTriangle, Award, CheckCircle, FileText, Settings } from 'lucide-react';
|
import { AlertTriangle, Award, FileText, Settings } from 'lucide-react';
|
||||||
import CertificatePreview from './CertificatePreview';
|
import CertificatePreview from './CertificatePreview';
|
||||||
import * as Form from '@radix-ui/react-form';
|
import * as Form from '@radix-ui/react-form';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
|
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||||
|
import {
|
||||||
|
createCertification,
|
||||||
|
updateCertification,
|
||||||
|
deleteCertification
|
||||||
|
} from '@services/courses/certifications';
|
||||||
import {
|
import {
|
||||||
CustomSelect,
|
CustomSelect,
|
||||||
CustomSelectContent,
|
CustomSelectContent,
|
||||||
|
|
@ -17,6 +23,9 @@ import {
|
||||||
CustomSelectTrigger,
|
CustomSelectTrigger,
|
||||||
CustomSelectValue,
|
CustomSelectValue,
|
||||||
} from "../EditCourseGeneral/CustomSelect";
|
} from "../EditCourseGeneral/CustomSelect";
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { getAPIUrl } from '@services/config/config';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
type EditCourseCertificationProps = {
|
type EditCourseCertificationProps = {
|
||||||
orgslug: string
|
orgslug: string
|
||||||
|
|
@ -43,9 +52,56 @@ const validate = (values: any) => {
|
||||||
|
|
||||||
function EditCourseCertification(props: EditCourseCertificationProps) {
|
function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const course = useCourse();
|
const course = useCourse();
|
||||||
const dispatchCourse = useCourseDispatch() as any;
|
const dispatchCourse = useCourseDispatch() as any;
|
||||||
const { isLoading, courseStructure } = course as any;
|
const { isLoading, courseStructure } = course as any;
|
||||||
|
const session = useLHSession() as any;
|
||||||
|
const access_token = session?.data?.tokens?.access_token;
|
||||||
|
|
||||||
|
// Fetch existing certifications
|
||||||
|
const { data: certifications, error: certificationsError, mutate: mutateCertifications } = useSWR(
|
||||||
|
courseStructure?.course_uuid && access_token ?
|
||||||
|
`certifications/course/${courseStructure.course_uuid}` : null,
|
||||||
|
async () => {
|
||||||
|
if (!courseStructure?.course_uuid || !access_token) return null;
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/course/${courseStructure.course_uuid}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${access_token}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const response = await result.json();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (result.status === 200) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
status: result.status,
|
||||||
|
HTTPmessage: result.statusText,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: response,
|
||||||
|
status: result.status,
|
||||||
|
HTTPmessage: result.statusText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingCertification = certifications?.data?.[0]; // Assuming one certification per course
|
||||||
|
const hasExistingCertification = !!existingCertification;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Create initial values object
|
// Create initial values object
|
||||||
const getInitialValues = () => {
|
const getInitialValues = () => {
|
||||||
|
|
@ -64,13 +120,16 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use existing certification data if available, otherwise fall back to course data
|
||||||
|
const config = existingCertification?.config || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enable_certification: courseStructure?.enable_certification || false,
|
enable_certification: hasExistingCertification,
|
||||||
certification_name: courseStructure?.certification_name || courseStructure?.name || '',
|
certification_name: config.certification_name || courseStructure?.name || '',
|
||||||
certification_description: courseStructure?.certification_description || courseStructure?.description || '',
|
certification_description: config.certification_description || courseStructure?.description || '',
|
||||||
certification_type: courseStructure?.certification_type || 'completion',
|
certification_type: config.certification_type || 'completion',
|
||||||
certificate_pattern: courseStructure?.certificate_pattern || 'professional',
|
certificate_pattern: config.certificate_pattern || 'professional',
|
||||||
certificate_instructor: courseStructure?.certificate_instructor || getInstructorName(),
|
certificate_instructor: config.certificate_instructor || getInstructorName(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -78,26 +137,85 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
initialValues: getInitialValues(),
|
initialValues: getInitialValues(),
|
||||||
validate,
|
validate,
|
||||||
onSubmit: async values => {
|
onSubmit: async values => {
|
||||||
try {
|
// This is no longer used - saving is handled by the main Save button
|
||||||
// Add your submission logic here
|
|
||||||
dispatchCourse({ type: 'setIsSaved' });
|
|
||||||
} catch (e) {
|
|
||||||
setError('Failed to save certification settings.');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
enableReinitialize: true,
|
enableReinitialize: true,
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
// Reset form when courseStructure changes
|
// Handle enabling/disabling certification
|
||||||
|
const handleCertificationToggle = async (enabled: boolean) => {
|
||||||
|
if (enabled && !hasExistingCertification) {
|
||||||
|
// Create new certification
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
certification_name: formik.values.certification_name || courseStructure?.name || '',
|
||||||
|
certification_description: formik.values.certification_description || courseStructure?.description || '',
|
||||||
|
certification_type: formik.values.certification_type || 'completion',
|
||||||
|
certificate_pattern: formik.values.certificate_pattern || 'professional',
|
||||||
|
certificate_instructor: formik.values.certificate_instructor || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createCertification(
|
||||||
|
courseStructure.id,
|
||||||
|
config,
|
||||||
|
access_token
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// createCertification uses errorHandling which returns JSON directly on success
|
||||||
|
if (result) {
|
||||||
|
toast.success('Certification created successfully');
|
||||||
|
mutateCertifications();
|
||||||
|
formik.setFieldValue('enable_certification', true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create certification');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to create certification.');
|
||||||
|
toast.error('Failed to create certification');
|
||||||
|
formik.setFieldValue('enable_certification', false);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
} else if (!enabled && hasExistingCertification) {
|
||||||
|
// Delete existing certification
|
||||||
|
try {
|
||||||
|
const result = await deleteCertification(
|
||||||
|
existingCertification.certification_uuid,
|
||||||
|
access_token
|
||||||
|
);
|
||||||
|
|
||||||
|
// deleteCertification uses errorHandling which returns JSON directly on success
|
||||||
|
if (result) {
|
||||||
|
toast.success('Certification removed successfully');
|
||||||
|
mutateCertifications();
|
||||||
|
formik.setFieldValue('enable_certification', false);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete certification');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to remove certification.');
|
||||||
|
toast.error('Failed to remove certification');
|
||||||
|
formik.setFieldValue('enable_certification', true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formik.setFieldValue('enable_certification', enabled);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset form when certifications data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (courseStructure && !isLoading) {
|
if (certifications && !isLoading) {
|
||||||
const newValues = getInitialValues();
|
const newValues = getInitialValues();
|
||||||
formik.resetForm({ values: newValues });
|
formik.resetForm({ values: newValues });
|
||||||
}
|
}
|
||||||
}, [courseStructure, isLoading]);
|
}, [certifications, isLoading]);
|
||||||
|
|
||||||
|
// Handle form changes - update course context with certification data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) {
|
if (!isLoading && hasExistingCertification) {
|
||||||
const formikValues = formik.values as any;
|
const formikValues = formik.values as any;
|
||||||
const initialValues = formik.initialValues as any;
|
const initialValues = formik.initialValues as any;
|
||||||
const valuesChanged = Object.keys(formikValues).some(
|
const valuesChanged = Object.keys(formikValues).some(
|
||||||
|
|
@ -106,19 +224,35 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
|
|
||||||
if (valuesChanged) {
|
if (valuesChanged) {
|
||||||
dispatchCourse({ type: 'setIsNotSaved' });
|
dispatchCourse({ type: 'setIsNotSaved' });
|
||||||
|
|
||||||
|
// Store certification data in course context so it gets saved with the main save button
|
||||||
const updatedCourse = {
|
const updatedCourse = {
|
||||||
...courseStructure,
|
...courseStructure,
|
||||||
...formikValues,
|
// Store certification data for the main save functionality
|
||||||
|
_certificationData: {
|
||||||
|
certification_uuid: existingCertification.certification_uuid,
|
||||||
|
config: {
|
||||||
|
certification_name: formikValues.certification_name,
|
||||||
|
certification_description: formikValues.certification_description,
|
||||||
|
certification_type: formikValues.certification_type,
|
||||||
|
certificate_pattern: formikValues.certificate_pattern,
|
||||||
|
certificate_instructor: formikValues.certificate_instructor,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse });
|
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [formik.values, isLoading]);
|
}, [formik.values, isLoading, hasExistingCertification, existingCertification]);
|
||||||
|
|
||||||
if (isLoading || !courseStructure) {
|
if (isLoading || !courseStructure || (courseStructure.course_uuid && access_token && certifications === undefined)) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (certificationsError) {
|
||||||
|
return <div>Error loading certifications</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{courseStructure && (
|
{courseStructure && (
|
||||||
|
|
@ -139,10 +273,16 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
checked={formik.values.enable_certification}
|
checked={formik.values.enable_certification}
|
||||||
onChange={(e) => formik.setFieldValue('enable_certification', e.target.checked)}
|
onChange={(e) => handleCertificationToggle(e.target.checked)}
|
||||||
|
disabled={isCreating}
|
||||||
/>
|
/>
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||||
</label>
|
</label>
|
||||||
|
{isCreating && (
|
||||||
|
<div className="animate-spin">
|
||||||
|
<Settings size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -153,162 +293,160 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Certification Configuration */}
|
{/* Certification Configuration - Only show if enabled and has existing certification */}
|
||||||
{formik.values.enable_certification && (
|
{formik.values.enable_certification && hasExistingCertification && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
{/* Form Section */}
|
{/* Form Section */}
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<FormLayout onSubmit={formik.handleSubmit}>
|
<Form.Root className="space-y-6">
|
||||||
<div className="space-y-6">
|
{/* Basic Information Section */}
|
||||||
{/* Basic Information Section */}
|
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||||
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
|
||||||
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
|
<FileText size={16} />
|
||||||
<FileText size={16} />
|
Basic Information
|
||||||
Basic Information
|
</h3>
|
||||||
</h3>
|
<p className="text-gray-500 text-xs sm:text-sm">
|
||||||
<p className="text-gray-500 text-xs sm:text-sm">
|
Configure the basic details of your certification
|
||||||
Configure the basic details of your certification
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Certification Name */}
|
{/* Certification Name */}
|
||||||
<FormField name="certification_name">
|
<FormField name="certification_name">
|
||||||
<FormLabelAndMessage
|
|
||||||
label="Certification Name"
|
|
||||||
message={formik.errors.certification_name}
|
|
||||||
/>
|
|
||||||
<Form.Control asChild>
|
|
||||||
<Input
|
|
||||||
style={{ backgroundColor: 'white' }}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
value={formik.values.certification_name}
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., Advanced JavaScript Certification"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* Certification Type */}
|
|
||||||
<FormField name="certification_type">
|
|
||||||
<FormLabelAndMessage label="Certification Type" />
|
|
||||||
<Form.Control asChild>
|
|
||||||
<CustomSelect
|
|
||||||
value={formik.values.certification_type}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
formik.setFieldValue('certification_type', value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CustomSelectTrigger className="w-full bg-white">
|
|
||||||
<CustomSelectValue>
|
|
||||||
{formik.values.certification_type === 'completion' ? 'Course Completion' :
|
|
||||||
formik.values.certification_type === 'achievement' ? 'Achievement Based' :
|
|
||||||
formik.values.certification_type === 'assessment' ? 'Assessment Based' :
|
|
||||||
formik.values.certification_type === 'participation' ? 'Participation' :
|
|
||||||
formik.values.certification_type === 'mastery' ? 'Skill Mastery' :
|
|
||||||
formik.values.certification_type === 'professional' ? 'Professional Development' :
|
|
||||||
formik.values.certification_type === 'continuing' ? 'Continuing Education' :
|
|
||||||
formik.values.certification_type === 'workshop' ? 'Workshop Attendance' :
|
|
||||||
formik.values.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}
|
|
||||||
</CustomSelectValue>
|
|
||||||
</CustomSelectTrigger>
|
|
||||||
<CustomSelectContent>
|
|
||||||
<CustomSelectItem value="completion">Course Completion</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="achievement">Achievement Based</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="assessment">Assessment Based</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="participation">Participation</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="mastery">Skill Mastery</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="professional">Professional Development</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="continuing">Continuing Education</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="workshop">Workshop Attendance</CustomSelectItem>
|
|
||||||
<CustomSelectItem value="specialization">Specialization</CustomSelectItem>
|
|
||||||
</CustomSelectContent>
|
|
||||||
</CustomSelect>
|
|
||||||
</Form.Control>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Certification Description */}
|
|
||||||
<FormField name="certification_description">
|
|
||||||
<FormLabelAndMessage
|
<FormLabelAndMessage
|
||||||
label="Certification Description"
|
label="Certification Name"
|
||||||
message={formik.errors.certification_description}
|
message={formik.errors.certification_name}
|
||||||
/>
|
/>
|
||||||
<Form.Control asChild>
|
<Form.Control asChild>
|
||||||
<Textarea
|
<Input
|
||||||
style={{ backgroundColor: 'white', height: '120px', minHeight: '120px' }}
|
style={{ backgroundColor: 'white' }}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
value={formik.values.certification_description}
|
value={formik.values.certification_name}
|
||||||
placeholder="Describe what this certification represents and its value..."
|
type="text"
|
||||||
|
placeholder="e.g., Advanced JavaScript Certification"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* Certificate Design Section */}
|
{/* Certification Type */}
|
||||||
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
<FormField name="certification_type">
|
||||||
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
|
<FormLabelAndMessage label="Certification Type" />
|
||||||
<Award size={16} />
|
|
||||||
Certificate Design
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 text-xs sm:text-sm">
|
|
||||||
Choose a decorative pattern for your certificate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pattern Selection */}
|
|
||||||
<FormField name="certificate_pattern">
|
|
||||||
<FormLabelAndMessage label="Certificate Pattern" />
|
|
||||||
<Form.Control asChild>
|
<Form.Control asChild>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
<CustomSelect
|
||||||
{[
|
value={formik.values.certification_type}
|
||||||
{ value: 'royal', name: 'Royal', description: 'Ornate with crown motifs' },
|
onValueChange={(value) => {
|
||||||
{ value: 'tech', name: 'Tech', description: 'Circuit-inspired patterns' },
|
if (!value) return;
|
||||||
{ value: 'nature', name: 'Nature', description: 'Organic leaf patterns' },
|
formik.setFieldValue('certification_type', value);
|
||||||
{ value: 'geometric', name: 'Geometric', description: 'Abstract shapes & lines' },
|
}}
|
||||||
{ value: 'vintage', name: 'Vintage', description: 'Art deco styling' },
|
>
|
||||||
{ value: 'waves', name: 'Waves', description: 'Flowing water patterns' },
|
<CustomSelectTrigger className="w-full bg-white">
|
||||||
{ value: 'minimal', name: 'Minimal', description: 'Clean and simple' },
|
<CustomSelectValue>
|
||||||
{ value: 'professional', name: 'Professional', description: 'Business-ready design' },
|
{formik.values.certification_type === 'completion' ? 'Course Completion' :
|
||||||
{ value: 'academic', name: 'Academic', description: 'Traditional university style' },
|
formik.values.certification_type === 'achievement' ? 'Achievement Based' :
|
||||||
{ value: 'modern', name: 'Modern', description: 'Contemporary clean lines' }
|
formik.values.certification_type === 'assessment' ? 'Assessment Based' :
|
||||||
].map((pattern) => (
|
formik.values.certification_type === 'participation' ? 'Participation' :
|
||||||
<div
|
formik.values.certification_type === 'mastery' ? 'Skill Mastery' :
|
||||||
key={pattern.value}
|
formik.values.certification_type === 'professional' ? 'Professional Development' :
|
||||||
className={`p-3 border-2 rounded-lg cursor-pointer transition-all ${
|
formik.values.certification_type === 'continuing' ? 'Continuing Education' :
|
||||||
formik.values.certificate_pattern === pattern.value
|
formik.values.certification_type === 'workshop' ? 'Workshop Attendance' :
|
||||||
? 'border-blue-500 bg-blue-50'
|
formik.values.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
</CustomSelectValue>
|
||||||
}`}
|
</CustomSelectTrigger>
|
||||||
onClick={() => formik.setFieldValue('certificate_pattern', pattern.value)}
|
<CustomSelectContent>
|
||||||
>
|
<CustomSelectItem value="completion">Course Completion</CustomSelectItem>
|
||||||
<div className="text-center">
|
<CustomSelectItem value="achievement">Achievement Based</CustomSelectItem>
|
||||||
<div className="text-sm font-medium text-gray-900">{pattern.name}</div>
|
<CustomSelectItem value="assessment">Assessment Based</CustomSelectItem>
|
||||||
<div className="text-xs text-gray-500 mt-1">{pattern.description}</div>
|
<CustomSelectItem value="participation">Participation</CustomSelectItem>
|
||||||
</div>
|
<CustomSelectItem value="mastery">Skill Mastery</CustomSelectItem>
|
||||||
</div>
|
<CustomSelectItem value="professional">Professional Development</CustomSelectItem>
|
||||||
))}
|
<CustomSelectItem value="continuing">Continuing Education</CustomSelectItem>
|
||||||
</div>
|
<CustomSelectItem value="workshop">Workshop Attendance</CustomSelectItem>
|
||||||
</Form.Control>
|
<CustomSelectItem value="specialization">Specialization</CustomSelectItem>
|
||||||
</FormField>
|
</CustomSelectContent>
|
||||||
|
</CustomSelect>
|
||||||
{/* Custom Instructor */}
|
|
||||||
<FormField name="certificate_instructor">
|
|
||||||
<FormLabelAndMessage label="Instructor Name (Optional)" />
|
|
||||||
<Form.Control asChild>
|
|
||||||
<Input
|
|
||||||
style={{ backgroundColor: 'white' }}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
value={formik.values.certificate_instructor}
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g., Dr. Jane Smith"
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</FormLayout>
|
|
||||||
|
{/* Certification Description */}
|
||||||
|
<FormField name="certification_description">
|
||||||
|
<FormLabelAndMessage
|
||||||
|
label="Certification Description"
|
||||||
|
message={formik.errors.certification_description}
|
||||||
|
/>
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Textarea
|
||||||
|
style={{ backgroundColor: 'white', height: '120px', minHeight: '120px' }}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.certification_description}
|
||||||
|
placeholder="Describe what this certification represents and its value..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{/* Certificate Design Section */}
|
||||||
|
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||||
|
<h3 className="font-bold text-md text-gray-800 flex items-center gap-2">
|
||||||
|
<Award size={16} />
|
||||||
|
Certificate Design
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 text-xs sm:text-sm">
|
||||||
|
Choose a decorative pattern for your certificate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern Selection */}
|
||||||
|
<FormField name="certificate_pattern">
|
||||||
|
<FormLabelAndMessage label="Certificate Pattern" />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
|
{[
|
||||||
|
{ value: 'royal', name: 'Royal', description: 'Ornate with crown motifs' },
|
||||||
|
{ value: 'tech', name: 'Tech', description: 'Circuit-inspired patterns' },
|
||||||
|
{ value: 'nature', name: 'Nature', description: 'Organic leaf patterns' },
|
||||||
|
{ value: 'geometric', name: 'Geometric', description: 'Abstract shapes & lines' },
|
||||||
|
{ value: 'vintage', name: 'Vintage', description: 'Art deco styling' },
|
||||||
|
{ value: 'waves', name: 'Waves', description: 'Flowing water patterns' },
|
||||||
|
{ value: 'minimal', name: 'Minimal', description: 'Clean and simple' },
|
||||||
|
{ value: 'professional', name: 'Professional', description: 'Business-ready design' },
|
||||||
|
{ value: 'academic', name: 'Academic', description: 'Traditional university style' },
|
||||||
|
{ value: 'modern', name: 'Modern', description: 'Contemporary clean lines' }
|
||||||
|
].map((pattern) => (
|
||||||
|
<div
|
||||||
|
key={pattern.value}
|
||||||
|
className={`p-3 border-2 rounded-lg cursor-pointer transition-all ${
|
||||||
|
formik.values.certificate_pattern === pattern.value
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => formik.setFieldValue('certificate_pattern', pattern.value)}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{pattern.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{pattern.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{/* Custom Instructor */}
|
||||||
|
<FormField name="certificate_instructor">
|
||||||
|
<FormLabelAndMessage label="Instructor Name (Optional)" />
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
style={{ backgroundColor: 'white' }}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.certificate_instructor}
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Dr. Jane Smith"
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
</Form.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Section */}
|
{/* Preview Section */}
|
||||||
|
|
@ -348,14 +486,28 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => formik.setFieldValue('enable_certification', true)}
|
onClick={() => handleCertificationToggle(true)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
disabled={isCreating}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Award size={16} />
|
<Award size={16} />
|
||||||
Enable Certification
|
{isCreating ? 'Creating...' : 'Enable Certification'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Creating State - when toggle is on but no certification exists yet */}
|
||||||
|
{formik.values.enable_certification && !hasExistingCertification && isCreating && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-8 text-center">
|
||||||
|
<div className="animate-spin mx-auto mb-4">
|
||||||
|
<Settings className="w-16 h-16 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium text-blue-700 mb-2">Creating Certification...</h3>
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
Please wait while we set up your course certification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface CustomSelectProps {
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ interface CustomSelectItemProps {
|
||||||
interface CustomSelectTriggerProps {
|
interface CustomSelectTriggerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomSelectContentProps {
|
interface CustomSelectContentProps {
|
||||||
|
|
@ -31,6 +33,7 @@ const CustomSelectContext = React.createContext<{
|
||||||
selectedValue: string;
|
selectedValue: string;
|
||||||
setSelectedValue: (value: string) => void;
|
setSelectedValue: (value: string) => void;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const CustomSelect: React.FC<CustomSelectProps> = ({
|
export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||||
|
|
@ -38,6 +41,7 @@ export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||||
onValueChange,
|
onValueChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
className = '',
|
className = '',
|
||||||
|
disabled = false,
|
||||||
children
|
children
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
@ -48,6 +52,7 @@ export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleValueChange = (newValue: string) => {
|
const handleValueChange = (newValue: string) => {
|
||||||
|
if (disabled) return;
|
||||||
setSelectedValue(newValue);
|
setSelectedValue(newValue);
|
||||||
onValueChange(newValue);
|
onValueChange(newValue);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
@ -60,7 +65,8 @@ export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
setSelectedValue,
|
setSelectedValue,
|
||||||
onValueChange: handleValueChange
|
onValueChange: handleValueChange,
|
||||||
|
disabled
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
|
|
@ -72,20 +78,23 @@ export const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||||
|
|
||||||
export const CustomSelectTrigger: React.FC<CustomSelectTriggerProps> = ({
|
export const CustomSelectTrigger: React.FC<CustomSelectTriggerProps> = ({
|
||||||
children,
|
children,
|
||||||
className = ''
|
className = '',
|
||||||
|
disabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const context = React.useContext(CustomSelectContext);
|
const context = React.useContext(CustomSelectContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('CustomSelectTrigger must be used within CustomSelect');
|
throw new Error('CustomSelectTrigger must be used within CustomSelect');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isOpen, setIsOpen } = context;
|
const { isOpen, setIsOpen, disabled: contextDisabled } = context;
|
||||||
|
const isDisabled = disabled || contextDisabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
className={`flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 ${className}`}
|
className={`flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 ${className}`}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => !isDisabled && setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDown className={`h-4 w-4 opacity-50 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`h-4 w-4 opacity-50 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
|
@ -102,9 +111,9 @@ export const CustomSelectContent: React.FC<CustomSelectContentProps> = ({
|
||||||
throw new Error('CustomSelectContent must be used within CustomSelect');
|
throw new Error('CustomSelectContent must be used within CustomSelect');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isOpen } = context;
|
const { isOpen, disabled } = context;
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen || disabled) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`absolute z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 ${className}`}>
|
<div className={`absolute z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 ${className}`}>
|
||||||
|
|
@ -125,12 +134,12 @@ export const CustomSelectItem: React.FC<CustomSelectItemProps> = ({
|
||||||
throw new Error('CustomSelectItem must be used within CustomSelect');
|
throw new Error('CustomSelectItem must be used within CustomSelect');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { selectedValue, onValueChange } = context;
|
const { selectedValue, onValueChange, disabled } = context;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground ${className}`}
|
className={`relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||||
onClick={() => onValueChange(value)}
|
onClick={() => !disabled && onValueChange(value)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{selectedValue === value && (
|
{selectedValue === value && (
|
||||||
|
|
|
||||||
62
apps/web/services/courses/certifications.ts
Normal file
62
apps/web/services/courses/certifications.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import {
|
||||||
|
RequestBodyWithAuthHeader,
|
||||||
|
errorHandling,
|
||||||
|
getResponseMetadata,
|
||||||
|
} from '@services/utils/ts/requests'
|
||||||
|
|
||||||
|
/*
|
||||||
|
This file includes certification-related API calls
|
||||||
|
GET requests are called from the frontend using SWR (https://swr.vercel.app/)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getCourseCertifications(
|
||||||
|
course_uuid: string,
|
||||||
|
next: any,
|
||||||
|
access_token: string
|
||||||
|
) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/course/${course_uuid}`,
|
||||||
|
RequestBodyWithAuthHeader('GET', null, next, access_token)
|
||||||
|
)
|
||||||
|
const res = await getResponseMetadata(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCertification(
|
||||||
|
course_id: number,
|
||||||
|
config: any,
|
||||||
|
access_token: string
|
||||||
|
) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/`,
|
||||||
|
RequestBodyWithAuthHeader('POST', { course_id, config }, null, access_token)
|
||||||
|
)
|
||||||
|
const res = await errorHandling(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCertification(
|
||||||
|
certification_uuid: string,
|
||||||
|
config: any,
|
||||||
|
access_token: string
|
||||||
|
) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/${certification_uuid}`,
|
||||||
|
RequestBodyWithAuthHeader('PUT', { config }, null, access_token)
|
||||||
|
)
|
||||||
|
const res = await errorHandling(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCertification(
|
||||||
|
certification_uuid: string,
|
||||||
|
access_token: string
|
||||||
|
) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}certifications/${certification_uuid}`,
|
||||||
|
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
|
||||||
|
)
|
||||||
|
const res = await errorHandling(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue