diff --git a/apps/web/components/Dashboard/Misc/SaveState.tsx b/apps/web/components/Dashboard/Misc/SaveState.tsx
index 672fd36c..a362d9e3 100644
--- a/apps/web/components/Dashboard/Misc/SaveState.tsx
+++ b/apps/web/components/Dashboard/Misc/SaveState.tsx
@@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation'
import React, { useEffect, useState } from 'react'
import { mutate } from 'swr'
import { updateCourse } from '@services/courses/courses'
+import { updateCertification } from '@services/courses/certifications'
import { useLHSession } from '@components/Contexts/LHSessionContext'
function SaveState(props: { orgslug: string }) {
@@ -32,6 +33,8 @@ function SaveState(props: { orgslug: string }) {
// Course metadata
await changeMetadataBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
+ // Certification data (if present)
+ await saveCertificationData()
await revalidateTags(['courses'], props.orgslug)
dispatchCourse({ type: 'setIsSaved' })
} finally {
@@ -66,6 +69,24 @@ function SaveState(props: { orgslug: string }) {
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 chapters = course_structure.chapters
const chapter_order_by_ids = chapters.map((chapter: any) => {
diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx
index 4e1b4722..48107b92 100644
--- a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx
+++ b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx
@@ -1,15 +1,21 @@
-import FormLayout, {
+import {
FormField,
FormLabelAndMessage,
Input,
Textarea,
} from '@components/Objects/StyledElements/Form/Form';
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 * as Form from '@radix-ui/react-form';
import React, { useEffect, useState } from 'react';
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
+import { useLHSession } from '@components/Contexts/LHSessionContext';
+import {
+ createCertification,
+ updateCertification,
+ deleteCertification
+} from '@services/courses/certifications';
import {
CustomSelect,
CustomSelectContent,
@@ -17,6 +23,9 @@ import {
CustomSelectTrigger,
CustomSelectValue,
} from "../EditCourseGeneral/CustomSelect";
+import useSWR, { mutate } from 'swr';
+import { getAPIUrl } from '@services/config/config';
+import toast from 'react-hot-toast';
type EditCourseCertificationProps = {
orgslug: string
@@ -43,9 +52,56 @@ const validate = (values: any) => {
function EditCourseCertification(props: EditCourseCertificationProps) {
const [error, setError] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
const course = useCourse();
const dispatchCourse = useCourseDispatch() 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
const getInitialValues = () => {
@@ -64,13 +120,16 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
return '';
};
+ // Use existing certification data if available, otherwise fall back to course data
+ const config = existingCertification?.config || {};
+
return {
- enable_certification: courseStructure?.enable_certification || false,
- certification_name: courseStructure?.certification_name || courseStructure?.name || '',
- certification_description: courseStructure?.certification_description || courseStructure?.description || '',
- certification_type: courseStructure?.certification_type || 'completion',
- certificate_pattern: courseStructure?.certificate_pattern || 'professional',
- certificate_instructor: courseStructure?.certificate_instructor || getInstructorName(),
+ enable_certification: hasExistingCertification,
+ certification_name: config.certification_name || courseStructure?.name || '',
+ certification_description: config.certification_description || courseStructure?.description || '',
+ certification_type: config.certification_type || 'completion',
+ certificate_pattern: config.certificate_pattern || 'professional',
+ certificate_instructor: config.certificate_instructor || getInstructorName(),
};
};
@@ -78,26 +137,85 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
initialValues: getInitialValues(),
validate,
onSubmit: async values => {
- try {
- // Add your submission logic here
- dispatchCourse({ type: 'setIsSaved' });
- } catch (e) {
- setError('Failed to save certification settings.');
- }
+ // This is no longer used - saving is handled by the main Save button
},
enableReinitialize: true,
}) 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(() => {
- if (courseStructure && !isLoading) {
+ if (certifications && !isLoading) {
const newValues = getInitialValues();
formik.resetForm({ values: newValues });
}
- }, [courseStructure, isLoading]);
+ }, [certifications, isLoading]);
+ // Handle form changes - update course context with certification data
useEffect(() => {
- if (!isLoading) {
+ if (!isLoading && hasExistingCertification) {
const formikValues = formik.values as any;
const initialValues = formik.initialValues as any;
const valuesChanged = Object.keys(formikValues).some(
@@ -106,19 +224,35 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
if (valuesChanged) {
dispatchCourse({ type: 'setIsNotSaved' });
+
+ // Store certification data in course context so it gets saved with the main save button
const updatedCourse = {
...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 });
}
}
- }, [formik.values, isLoading]);
+ }, [formik.values, isLoading, hasExistingCertification, existingCertification]);
- if (isLoading || !courseStructure) {
+ if (isLoading || !courseStructure || (courseStructure.course_uuid && access_token && certifications === undefined)) {
return
Loading...
;
}
+ if (certificationsError) {
+ return Error loading certifications
;
+ }
+
return (
{courseStructure && (
@@ -139,10 +273,16 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
type="checkbox"
className="sr-only peer"
checked={formik.values.enable_certification}
- onChange={(e) => formik.setFieldValue('enable_certification', e.target.checked)}
+ onChange={(e) => handleCertificationToggle(e.target.checked)}
+ disabled={isCreating}
/>
+ {isCreating && (
+
+
+
+ )}
@@ -153,162 +293,160 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
)}
- {/* Certification Configuration */}
- {formik.values.enable_certification && (
+ {/* Certification Configuration - Only show if enabled and has existing certification */}
+ {formik.values.enable_certification && hasExistingCertification && (
{/* Form Section */}
-
-
- {/* Basic Information Section */}
-
-
-
- Basic Information
-
-
- Configure the basic details of your certification
-
-
+
+ {/* Basic Information Section */}
+
+
+
+ Basic Information
+
+
+ Configure the basic details of your certification
+
+
-
- {/* Certification Name */}
-
-
-
-
-
-
-
- {/* Certification Type */}
-
-
-
- {
- if (!value) return;
- formik.setFieldValue('certification_type', value);
- }}
- >
-
-
- {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'}
-
-
-
- Course Completion
- Achievement Based
- Assessment Based
- Participation
- Skill Mastery
- Professional Development
- Continuing Education
- Workshop Attendance
- Specialization
-
-
-
-
-
-
- {/* Certification Description */}
-
+
+ {/* Certification Name */}
+
-
- {/* Certificate Design Section */}
-
-
-
- Certificate Design
-
-
- Choose a decorative pattern for your certificate
-
-
-
- {/* Pattern Selection */}
-
-
+ {/* Certification Type */}
+
+
-
- {[
- { 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) => (
-
formik.setFieldValue('certificate_pattern', pattern.value)}
- >
-
-
{pattern.name}
-
{pattern.description}
-
-
- ))}
-
-
-
-
- {/* Custom Instructor */}
-
-
-
-
+ {
+ if (!value) return;
+ formik.setFieldValue('certification_type', value);
+ }}
+ >
+
+
+ {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'}
+
+
+
+ Course Completion
+ Achievement Based
+ Assessment Based
+ Participation
+ Skill Mastery
+ Professional Development
+ Continuing Education
+ Workshop Attendance
+ Specialization
+
+
-
+
+ {/* Certification Description */}
+
+
+
+
+
+
+
+ {/* Certificate Design Section */}
+
+
+
+ Certificate Design
+
+
+ Choose a decorative pattern for your certificate
+
+
+
+ {/* Pattern Selection */}
+
+
+
+
+ {[
+ { 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) => (
+
formik.setFieldValue('certificate_pattern', pattern.value)}
+ >
+
+
{pattern.name}
+
{pattern.description}
+
+
+ ))}
+
+
+
+
+ {/* Custom Instructor */}
+
+
+
+
+
+
+
{/* Preview Section */}
@@ -348,14 +486,28 @@ function EditCourseCertification(props: EditCourseCertificationProps) {
formik.setFieldValue('enable_certification', 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"
+ onClick={() => handleCertificationToggle(true)}
+ 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"
>
- Enable Certification
+ {isCreating ? 'Creating...' : 'Enable Certification'}
)}
+
+ {/* Creating State - when toggle is on but no certification exists yet */}
+ {formik.values.enable_certification && !hasExistingCertification && isCreating && (
+
+
+
+
+
Creating Certification...
+
+ Please wait while we set up your course certification.
+
+
+ )}
)}
diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx
index 2d14c6b7..02975494 100644
--- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx
+++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx
@@ -6,6 +6,7 @@ interface CustomSelectProps {
onValueChange: (value: string) => void;
placeholder?: string;
className?: string;
+ disabled?: boolean;
children: React.ReactNode;
}
@@ -18,6 +19,7 @@ interface CustomSelectItemProps {
interface CustomSelectTriggerProps {
children: React.ReactNode;
className?: string;
+ disabled?: boolean;
}
interface CustomSelectContentProps {
@@ -31,6 +33,7 @@ const CustomSelectContext = React.createContext<{
selectedValue: string;
setSelectedValue: (value: string) => void;
onValueChange: (value: string) => void;
+ disabled?: boolean;
} | null>(null);
export const CustomSelect: React.FC = ({
@@ -38,6 +41,7 @@ export const CustomSelect: React.FC = ({
onValueChange,
placeholder,
className = '',
+ disabled = false,
children
}) => {
const [isOpen, setIsOpen] = useState(false);
@@ -48,6 +52,7 @@ export const CustomSelect: React.FC = ({
}, [value]);
const handleValueChange = (newValue: string) => {
+ if (disabled) return;
setSelectedValue(newValue);
onValueChange(newValue);
setIsOpen(false);
@@ -60,7 +65,8 @@ export const CustomSelect: React.FC = ({
setIsOpen,
selectedValue,
setSelectedValue,
- onValueChange: handleValueChange
+ onValueChange: handleValueChange,
+ disabled
}}
>
@@ -72,20 +78,23 @@ export const CustomSelect: React.FC
= ({
export const CustomSelectTrigger: React.FC = ({
children,
- className = ''
+ className = '',
+ disabled = false
}) => {
const context = React.useContext(CustomSelectContext);
if (!context) {
throw new Error('CustomSelectTrigger must be used within CustomSelect');
}
- const { isOpen, setIsOpen } = context;
+ const { isOpen, setIsOpen, disabled: contextDisabled } = context;
+ const isDisabled = disabled || contextDisabled;
return (
span]:line-clamp-1 ${className}`}
- onClick={() => setIsOpen(!isOpen)}
+ onClick={() => !isDisabled && setIsOpen(!isOpen)}
>
{children}
@@ -102,9 +111,9 @@ export const CustomSelectContent: React.FC = ({
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 (
@@ -125,12 +134,12 @@ export const CustomSelectItem: React.FC
= ({
throw new Error('CustomSelectItem must be used within CustomSelect');
}
- const { selectedValue, onValueChange } = context;
+ const { selectedValue, onValueChange, disabled } = context;
return (
onValueChange(value)}
+ 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={() => !disabled && onValueChange(value)}
>
{children}
{selectedValue === value && (
diff --git a/apps/web/services/courses/certifications.ts b/apps/web/services/courses/certifications.ts
new file mode 100644
index 00000000..822db702
--- /dev/null
+++ b/apps/web/services/courses/certifications.ts
@@ -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
+}
\ No newline at end of file