From aabb4d190ceadfbf33b69efe171a8d15d1ec60ce Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 14 Jul 2025 17:07:15 +0200 Subject: [PATCH 01/13] fix: select issue on EditCourseGeneral --- .../Course/EditCourseGeneral/CustomSelect.tsx | 158 ++++++++++++++++++ .../EditCourseGeneral/EditCourseGeneral.tsx | 34 ++-- apps/web/package.json | 2 +- apps/web/pnpm-lock.yaml | 102 +++++------ 4 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx new file mode 100644 index 00000000..2d14c6b7 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/CustomSelect.tsx @@ -0,0 +1,158 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface CustomSelectProps { + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + className?: string; + children: React.ReactNode; +} + +interface CustomSelectItemProps { + value: string; + children: React.ReactNode; + className?: string; +} + +interface CustomSelectTriggerProps { + children: React.ReactNode; + className?: string; +} + +interface CustomSelectContentProps { + children: React.ReactNode; + className?: string; +} + +const CustomSelectContext = React.createContext<{ + isOpen: boolean; + setIsOpen: (open: boolean) => void; + selectedValue: string; + setSelectedValue: (value: string) => void; + onValueChange: (value: string) => void; +} | null>(null); + +export const CustomSelect: React.FC = ({ + value, + onValueChange, + placeholder, + className = '', + children +}) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(value); + + useEffect(() => { + setSelectedValue(value); + }, [value]); + + const handleValueChange = (newValue: string) => { + setSelectedValue(newValue); + onValueChange(newValue); + setIsOpen(false); + }; + + return ( + +
+ {children} +
+
+ ); +}; + +export const CustomSelectTrigger: React.FC = ({ + children, + className = '' +}) => { + const context = React.useContext(CustomSelectContext); + if (!context) { + throw new Error('CustomSelectTrigger must be used within CustomSelect'); + } + + const { isOpen, setIsOpen } = context; + + return ( + + ); +}; + +export const CustomSelectContent: React.FC = ({ + children, + className = '' +}) => { + const context = React.useContext(CustomSelectContext); + if (!context) { + throw new Error('CustomSelectContent must be used within CustomSelect'); + } + + const { isOpen } = context; + + if (!isOpen) return null; + + return ( +
+
+ {children} +
+
+ ); +}; + +export const CustomSelectItem: React.FC = ({ + value, + children, + className = '' +}) => { + const context = React.useContext(CustomSelectContext); + if (!context) { + throw new Error('CustomSelectItem must be used within CustomSelect'); + } + + const { selectedValue, onValueChange } = context; + + return ( +
onValueChange(value)} + > + {children} + {selectedValue === value && ( + + + + + + )} +
+ ); +}; + +export const CustomSelectValue: React.FC<{ children?: React.ReactNode }> = ({ + children +}) => { + const context = React.useContext(CustomSelectContext); + if (!context) { + throw new Error('CustomSelectValue must be used within CustomSelect'); + } + + const { selectedValue } = context; + + return {children || selectedValue}; +}; \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx index 622d9721..31fd87bf 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral.tsx @@ -13,12 +13,12 @@ import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext import FormTagInput from '@components/Objects/StyledElements/Form/TagInput'; import LearningItemsList from './LearningItemsList'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@components/ui/select"; + CustomSelect, + CustomSelectContent, + CustomSelectItem, + CustomSelectTrigger, + CustomSelectValue, +} from "./CustomSelect"; type EditCourseStructureProps = { orgslug: string @@ -245,26 +245,26 @@ function EditCourseGeneral(props: EditCourseStructureProps) { - + + + + Image + Video + Both + + diff --git a/apps/web/package.json b/apps/web/package.json index 51b30a2d..2b5c10db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,7 +65,7 @@ "katex": "^0.16.21", "lowlight": "^3.3.0", "lucide-react": "^0.453.0", - "next": "15.3.3", + "next": "15.3.5", "next-auth": "^4.24.11", "nextjs-toploader": "^1.6.12", "plyr": "^3.7.8", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index d0034bf3..432b5e0d 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -175,14 +175,14 @@ importers: specifier: ^0.453.0 version: 0.453.0(react@19.0.0) next: - specifier: 15.3.3 - version: 15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: 15.3.5 + version: 15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-auth: specifier: ^4.24.11 - version: 4.24.11(next@15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 4.24.11(next@15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) nextjs-toploader: specifier: ^1.6.12 - version: 1.6.12(next@15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 1.6.12(next@15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) plyr: specifier: ^3.7.8 version: 3.7.8 @@ -636,56 +636,56 @@ packages: '@napi-rs/wasm-runtime@0.2.8': resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==} - '@next/env@15.3.3': - resolution: {integrity: sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==} + '@next/env@15.3.5': + resolution: {integrity: sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==} '@next/eslint-plugin-next@15.2.1': resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==} - '@next/swc-darwin-arm64@15.3.3': - resolution: {integrity: sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==} + '@next/swc-darwin-arm64@15.3.5': + resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.3.3': - resolution: {integrity: sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==} + '@next/swc-darwin-x64@15.3.5': + resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.3.3': - resolution: {integrity: sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==} + '@next/swc-linux-arm64-gnu@15.3.5': + resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.3.3': - resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} + '@next/swc-linux-arm64-musl@15.3.5': + resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.3.3': - resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} + '@next/swc-linux-x64-gnu@15.3.5': + resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.3.3': - resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} + '@next/swc-linux-x64-musl@15.3.5': + resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.3.3': - resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} + '@next/swc-win32-arm64-msvc@15.3.5': + resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.3.3': - resolution: {integrity: sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==} + '@next/swc-win32-x64-msvc@15.3.5': + resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3013,8 +3013,8 @@ packages: nodemailer: optional: true - next@15.3.3: - resolution: {integrity: sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==} + next@15.3.5: + resolution: {integrity: sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -3587,8 +3587,8 @@ packages: tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} - tailwind-merge@3.3.0: - resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} @@ -4075,34 +4075,34 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true - '@next/env@15.3.3': {} + '@next/env@15.3.5': {} '@next/eslint-plugin-next@15.2.1': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.3.3': + '@next/swc-darwin-arm64@15.3.5': optional: true - '@next/swc-darwin-x64@15.3.3': + '@next/swc-darwin-x64@15.3.5': optional: true - '@next/swc-linux-arm64-gnu@15.3.3': + '@next/swc-linux-arm64-gnu@15.3.5': optional: true - '@next/swc-linux-arm64-musl@15.3.3': + '@next/swc-linux-arm64-musl@15.3.5': optional: true - '@next/swc-linux-x64-gnu@15.3.3': + '@next/swc-linux-x64-gnu@15.3.5': optional: true - '@next/swc-linux-x64-musl@15.3.3': + '@next/swc-linux-x64-musl@15.3.5': optional: true - '@next/swc-win32-arm64-msvc@15.3.3': + '@next/swc-win32-arm64-msvc@15.3.5': optional: true - '@next/swc-win32-x64-msvc@15.3.3': + '@next/swc-win32-x64-msvc@15.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5742,7 +5742,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - tailwind-merge: 3.3.0 + tailwind-merge: 3.3.1 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -6556,13 +6556,13 @@ snapshots: natural-compare@1.4.0: {} - next-auth@4.24.11(next@15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next-auth@4.24.11(next@15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@babel/runtime': 7.27.0 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.26.5 @@ -6571,9 +6571,9 @@ snapshots: react-dom: 19.0.0(react@19.0.0) uuid: 8.3.2 - next@15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - '@next/env': 15.3.3 + '@next/env': 15.3.5 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 @@ -6583,23 +6583,23 @@ snapshots: react-dom: 19.0.0(react@19.0.0) styled-jsx: 5.1.6(react@19.0.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.3.3 - '@next/swc-darwin-x64': 15.3.3 - '@next/swc-linux-arm64-gnu': 15.3.3 - '@next/swc-linux-arm64-musl': 15.3.3 - '@next/swc-linux-x64-gnu': 15.3.3 - '@next/swc-linux-x64-musl': 15.3.3 - '@next/swc-win32-arm64-msvc': 15.3.3 - '@next/swc-win32-x64-msvc': 15.3.3 + '@next/swc-darwin-arm64': 15.3.5 + '@next/swc-darwin-x64': 15.3.5 + '@next/swc-linux-arm64-gnu': 15.3.5 + '@next/swc-linux-arm64-musl': 15.3.5 + '@next/swc-linux-x64-gnu': 15.3.5 + '@next/swc-linux-x64-musl': 15.3.5 + '@next/swc-win32-arm64-msvc': 15.3.5 + '@next/swc-win32-x64-msvc': 15.3.5 '@opentelemetry/api': 1.9.0 sharp: 0.34.1 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - nextjs-toploader@1.6.12(next@15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + nextjs-toploader@1.6.12(next@15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - next: 15.3.3(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.3.5(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) nprogress: 0.2.0 prop-types: 15.8.1 react: 19.0.0 @@ -7282,7 +7282,7 @@ snapshots: tailwind-merge@2.6.0: {} - tailwind-merge@3.3.0: {} + tailwind-merge@3.3.1: {} tailwindcss-animate@1.0.7(tailwindcss@4.1.3): dependencies: From 86f7a80eb7447b858743e00c4cb6b345272f1807 Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 14 Jul 2025 21:45:58 +0200 Subject: [PATCH 02/13] feat(wip): initial ui and functionality for certifications --- .../course/[courseuuid]/[subpage]/page.tsx | 23 +- .../CertificatePreview.tsx | 566 ++++++++++++++++++ .../EditCourseCertification.tsx | 366 +++++++++++ apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 199 ++++++ 5 files changed, 1155 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 0124cd5f..bb59b5b6 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -5,11 +5,12 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour import Link from 'next/link' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { motion } from 'framer-motion' -import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users } from 'lucide-react' +import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award } from 'lucide-react' import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' +import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification' export type CourseOverviewParams = { orgslug: string courseuuid: string @@ -102,6 +103,24 @@ function CourseOverviewPage(props: { params: Promise }) { + +
+
+ +
Certification
+
+
+ @@ -117,6 +136,8 @@ function CourseOverviewPage(props: { params: Promise }) { {params.subpage == 'general' ? () : ('')} {params.subpage == 'access' ? () : ('')} {params.subpage == 'contributors' ? () : ('')} + {params.subpage == 'certification' ? () : ('')} + diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx new file mode 100644 index 00000000..4148f4e1 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview.tsx @@ -0,0 +1,566 @@ +import React, { useEffect, useState } from 'react'; +import { Award, CheckCircle, QrCode, Building, User, Calendar, Hash } from 'lucide-react'; +import QRCode from 'qrcode'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { getOrgLogoMediaDirectory } from '@services/media/media'; + +interface CertificatePreviewProps { + certificationName: string; + certificationDescription: string; + certificationType: string; + certificatePattern: string; + certificateInstructor?: string; +} + +const CertificatePreview: React.FC = ({ + certificationName, + certificationDescription, + certificationType, + certificatePattern, + certificateInstructor +}) => { + const [qrCodeUrl, setQrCodeUrl] = useState(''); + const org = useOrg() as any; + + // Generate QR code + useEffect(() => { + const generateQRCode = async () => { + try { + const certificateData = `https://learnhouse.app/verify/LH-2024-001`; + const qrUrl = await QRCode.toDataURL(certificateData, { + width: 185, + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M', + type: 'image/png' + }); + setQrCodeUrl(qrUrl); + } catch (error) { + console.error('Error generating QR code:', error); + } + }; + + generateQRCode(); + }, []); + // Function to get theme colors for each pattern + const getPatternTheme = (pattern: string) => { + switch (pattern) { + case 'royal': + return { + primary: 'text-amber-700', + secondary: 'text-amber-600', + icon: 'text-amber-600', + badge: 'bg-amber-50 text-amber-700 border-amber-200' + }; + case 'tech': + return { + primary: 'text-cyan-700', + secondary: 'text-cyan-600', + icon: 'text-cyan-600', + badge: 'bg-cyan-50 text-cyan-700 border-cyan-200' + }; + case 'nature': + return { + primary: 'text-green-700', + secondary: 'text-green-600', + icon: 'text-green-600', + badge: 'bg-green-50 text-green-700 border-green-200' + }; + case 'geometric': + return { + primary: 'text-purple-700', + secondary: 'text-purple-600', + icon: 'text-purple-600', + badge: 'bg-purple-50 text-purple-700 border-purple-200' + }; + case 'vintage': + return { + primary: 'text-orange-700', + secondary: 'text-orange-600', + icon: 'text-orange-600', + badge: 'bg-orange-50 text-orange-700 border-orange-200' + }; + case 'waves': + return { + primary: 'text-blue-700', + secondary: 'text-blue-600', + icon: 'text-blue-600', + badge: 'bg-blue-50 text-blue-700 border-blue-200' + }; + case 'minimal': + return { + primary: 'text-gray-700', + secondary: 'text-gray-600', + icon: 'text-gray-600', + badge: 'bg-gray-50 text-gray-700 border-gray-200' + }; + case 'professional': + return { + primary: 'text-slate-700', + secondary: 'text-slate-600', + icon: 'text-slate-600', + badge: 'bg-slate-50 text-slate-700 border-slate-200' + }; + case 'academic': + return { + primary: 'text-indigo-700', + secondary: 'text-indigo-600', + icon: 'text-indigo-600', + badge: 'bg-indigo-50 text-indigo-700 border-indigo-200' + }; + case 'modern': + return { + primary: 'text-blue-700', + secondary: 'text-blue-600', + icon: 'text-blue-600', + badge: 'bg-blue-50 text-blue-700 border-blue-200' + }; + default: + return { + primary: 'text-gray-700', + secondary: 'text-gray-600', + icon: 'text-gray-600', + badge: 'bg-gray-50 text-gray-700 border-gray-200' + }; + } + }; + + // Function to render different certificate patterns + const renderCertificatePattern = (pattern: string) => { + switch (pattern) { + case 'royal': + return ( + <> + {/* Royal ornate border with crown elements */} +
+
+ + {/* Crown-like decorations in corners */} +
+
+
+
+
+
+ + {/* Royal background pattern */} +
+
+
+ + ); + + case 'tech': + return ( + <> + {/* Tech circuit board borders */} +
+ + {/* Circuit-like corner elements */} +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + {/* Tech grid background */} +
+
+
+ + ); + + case 'nature': + return ( + <> + {/* Nature organic border */} +
+ + {/* Leaf-like decorations */} +
+
+ +
+
+ +
+
+ +
+
+ + {/* Organic background pattern */} +
+
+
+ + ); + + case 'geometric': + return ( + <> + {/* Geometric angular borders */} +
+ + {/* Geometric corner elements */} +
+
+
+
+ + {/* Abstract geometric shapes */} +
+
+
+
+ + {/* Geometric background */} +
+
+
+ + ); + + case 'vintage': + return ( + <> + {/* Art deco style borders */} +
+
+ + {/* Art deco corner decorations */} +
+
+
+
+ + {/* Art deco sunburst pattern */} +
+
+
+ + ); + + case 'waves': + return ( + <> + {/* Flowing wave borders */} +
+ + {/* Wave decorations */} +
+
+ + {/* Side wave patterns */} +
+
+ + {/* Wave background */} +
+
+
+ + ); + + case 'minimal': + return ( + <> + {/* Minimal clean border */} +
+ + {/* Subtle corner accents */} +
+
+
+
+ + ); + + case 'professional': + return ( + <> + {/* Professional double border */} +
+
+ + {/* Professional corner brackets */} +
+
+
+
+ + {/* Subtle professional background */} +
+
+
+ + ); + + case 'academic': + return ( + <> + {/* Academic traditional border */} +
+
+ + {/* Academic shield-like corners */} +
+
+
+
+ + {/* Academic laurel-like decorations */} +
+
+
+
+
+
+ + {/* Academic background pattern */} +
+
+
+ + ); + + case 'modern': + return ( + <> + {/* Modern clean asymmetric border */} +
+ + {/* Modern accent lines */} +
+
+ +
+
+ + {/* Modern dot accents */} +
+
+ + {/* Modern subtle background */} +
+
+
+ + ); + + default: + return null; + } + }; + + const theme = getPatternTheme(certificatePattern); + + return ( +
+
+ {/* Dynamic Certificate Pattern */} + {renderCertificatePattern(certificatePattern)} + + {/* Certificate ID - Top Left */} +
+
+ + ID: LH-2024-001 +
+
+ + {/* QR Code Box - Top Right */} +
+
+ {qrCodeUrl ? ( + Certificate QR Code + ) : ( +
+ +
+ )} +
+
+ + {/* Main Content */} +
+ {/* Header with decorative line */} +
+
+
Certificate
+
+
+ + {/* Award Icon with decorative elements */} +
+
+ + {/* Decorative rays */} +
+
+
+
+
+
+
+
+ + {/* Certificate Content */} +
+

+ {certificationName || 'Certification Name'} +

+

+ {certificationDescription || 'Certification description will appear here...'} +

+
+ + {/* Decorative divider */} +
+
+
+
+
+ + {/* Certification Type Badge */} +
+ + + {certificationType === 'completion' ? 'Course Completion' : + certificationType === 'achievement' ? 'Achievement Based' : + certificationType === 'assessment' ? 'Assessment Based' : + certificationType === 'participation' ? 'Participation' : + certificationType === 'mastery' ? 'Skill Mastery' : + certificationType === 'professional' ? 'Professional Development' : + certificationType === 'continuing' ? 'Continuing Education' : + certificationType === 'workshop' ? 'Workshop Attendance' : + certificationType === 'specialization' ? 'Specialization' : 'Course Completion'} + +
+
+ + {/* Bottom Section */} +
+
+ {/* Left: Teacher/Organization Signature */} +
+
+ + Instructor +
+
+ {certificateInstructor || 'Dr. Jane Smith'} +
+
+
+ + {/* Center: Logo */} +
+
+ {org?.logo_image ? ( + Organization Logo + ) : ( +
+ +
+ )} +
+
+ {org?.name || 'LearnHouse'} +
+
+ + {/* Right: Award Date */} +
+
+ + Awarded +
+
+ Dec 15, 2024 +
+
+
+
+
+
+ ); +}; + +export default CertificatePreview; \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx new file mode 100644 index 00000000..4e1b4722 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification.tsx @@ -0,0 +1,366 @@ +import FormLayout, { + FormField, + FormLabelAndMessage, + Input, + Textarea, +} from '@components/Objects/StyledElements/Form/Form'; +import { useFormik } from 'formik'; +import { AlertTriangle, Award, CheckCircle, 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 { + CustomSelect, + CustomSelectContent, + CustomSelectItem, + CustomSelectTrigger, + CustomSelectValue, +} from "../EditCourseGeneral/CustomSelect"; + +type EditCourseCertificationProps = { + orgslug: string + course_uuid?: string +} + +const validate = (values: any) => { + const errors = {} as any; + + if (values.enable_certification && !values.certification_name) { + errors.certification_name = 'Required when certification is enabled'; + } else if (values.certification_name && values.certification_name.length > 100) { + errors.certification_name = 'Must be 100 characters or less'; + } + + if (values.enable_certification && !values.certification_description) { + errors.certification_description = 'Required when certification is enabled'; + } else if (values.certification_description && values.certification_description.length > 500) { + errors.certification_description = 'Must be 500 characters or less'; + } + + return errors; +}; + +function EditCourseCertification(props: EditCourseCertificationProps) { + const [error, setError] = useState(''); + const course = useCourse(); + const dispatchCourse = useCourseDispatch() as any; + const { isLoading, courseStructure } = course as any; + + // Create initial values object + const getInitialValues = () => { + // Helper function to get instructor name from authors + const getInstructorName = () => { + if (courseStructure?.authors && courseStructure.authors.length > 0) { + const author = courseStructure.authors[0]; + const firstName = author.first_name || ''; + const lastName = author.last_name || ''; + + // Only return if at least one name exists + if (firstName || lastName) { + return `${firstName} ${lastName}`.trim(); + } + } + return ''; + }; + + 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(), + }; + }; + + const formik = useFormik({ + initialValues: getInitialValues(), + validate, + onSubmit: async values => { + try { + // Add your submission logic here + dispatchCourse({ type: 'setIsSaved' }); + } catch (e) { + setError('Failed to save certification settings.'); + } + }, + enableReinitialize: true, + }) as any; + + // Reset form when courseStructure changes + useEffect(() => { + if (courseStructure && !isLoading) { + const newValues = getInitialValues(); + formik.resetForm({ values: newValues }); + } + }, [courseStructure, isLoading]); + + useEffect(() => { + if (!isLoading) { + const formikValues = formik.values as any; + const initialValues = formik.initialValues as any; + const valuesChanged = Object.keys(formikValues).some( + key => formikValues[key] !== initialValues[key] + ); + + if (valuesChanged) { + dispatchCourse({ type: 'setIsNotSaved' }); + const updatedCourse = { + ...courseStructure, + ...formikValues, + }; + dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse }); + } + } + }, [formik.values, isLoading]); + + if (isLoading || !courseStructure) { + return
Loading...
; + } + + return ( +
+ {courseStructure && ( +
+
+
+ {/* Header Section */} +
+
+

Course Certification

+

+ Enable and configure certificates for students who complete this course +

+
+
+ +
+
+ + {error && ( +
+ +
{error}
+
+ )} + + {/* Certification Configuration */} + {formik.values.enable_certification && ( +
+ {/* Form Section */} +
+ +
+ {/* 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 */} + + + +