diff --git a/apps/db/supabase/migrations/20250612040712_add_certificate_templates.sql b/apps/db/supabase/migrations/20250612040712_add_certificate_templates.sql new file mode 100644 index 0000000000..bd0b46d192 --- /dev/null +++ b/apps/db/supabase/migrations/20250612040712_add_certificate_templates.sql @@ -0,0 +1,5 @@ +create type "public"."certificate_templates" as enum ('original', 'modern', 'elegant'); + +alter table "public"."workspace_courses" add column "cert_template" certificate_templates not null default 'original'::certificate_templates; + + diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 22dde07c70..083682bd56 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3539,6 +3539,8 @@ "create_description": "Create a new course", "description": "Manage courses in your workspace that include markdown content, slides, quizzes, and flashcards.", "course_description": "Course description", + "certificate_template": "Certificate Design", + "select_certificate_template": "Choose a certificate template", "edit": "Edit course", "name": "Course name", "edit_description": "Edit an existing course", @@ -3546,7 +3548,11 @@ "card_view": "Card View", "table_view": "Table View", "no_courses_found": "No courses found", - "no_description_provided": "No description provided" + "no_description_provided": "No description provided", + "image_preview_not_available": "Image Preview Not Available", + "image_preview_not_available_description": "A preview of the certificate template is not available.", + "hide_certificate_preview": "Hide Certificate Preview", + "show_certificate_preview": "Show Certificate Preview" }, "ws-crawlers": { "plural": "Crawlers", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index 4c45c0e5f9..6871fff7f6 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -3547,7 +3547,13 @@ "table_view": "Dạng bảng", "no_courses_found": "Chưa có khóa học nào", "no_description_provided": "Không có mô tả", - "delete_confirm_title": "Xác nhận xoá \"{name}\"" + "delete_confirm_title": "Xác nhận xóa \"{name}\"", + "certificate_template": "Mẫu chứng chỉ", + "select_certificate_template": "Chọn một mẫu chứng chỉ", + "image_preview_not_available": "Chưa có mẫu xem trước", + "image_preview_not_available_description": "Ảnh xem trước của mẫu chứng chỉ không có sẵn.", + "hide_certificate_preview": "Ẩn xem mẫu chứng chỉ", + "show_certificate_preview": "Hiển thị xem mẫu chứng chỉ" }, "ws-crawlers": { "plural": "Công cụ cào dữ liệu", diff --git a/apps/upskii/public/media/certificate-previews/en/elegant-preview.png b/apps/upskii/public/media/certificate-previews/en/elegant-preview.png new file mode 100644 index 0000000000..3585010bb4 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/en/elegant-preview.png differ diff --git a/apps/upskii/public/media/certificate-previews/en/modern-preview.png b/apps/upskii/public/media/certificate-previews/en/modern-preview.png new file mode 100644 index 0000000000..9fbf897122 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/en/modern-preview.png differ diff --git a/apps/upskii/public/media/certificate-previews/en/original-preview.png b/apps/upskii/public/media/certificate-previews/en/original-preview.png new file mode 100644 index 0000000000..2b6721e3a1 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/en/original-preview.png differ diff --git a/apps/upskii/public/media/certificate-previews/vi/elegant-preview.png b/apps/upskii/public/media/certificate-previews/vi/elegant-preview.png new file mode 100644 index 0000000000..73b9b1dd51 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/vi/elegant-preview.png differ diff --git a/apps/upskii/public/media/certificate-previews/vi/modern-preview.png b/apps/upskii/public/media/certificate-previews/vi/modern-preview.png new file mode 100644 index 0000000000..2e5c521762 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/vi/modern-preview.png differ diff --git a/apps/upskii/public/media/certificate-previews/vi/original-preview.png b/apps/upskii/public/media/certificate-previews/vi/original-preview.png new file mode 100644 index 0000000000..d6d2fdeb33 Binary files /dev/null and b/apps/upskii/public/media/certificate-previews/vi/original-preview.png differ diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/page.tsx index d84b786848..1d9347eb25 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/page.tsx @@ -1,19 +1,15 @@ -import Certificate from '../certificate-page'; +import { ElegantCertificateDocument } from '@/app/api/v1/certificates/templates/elegant-certificate'; +import { ModernCertificateDocument } from '@/app/api/v1/certificates/templates/modern-certificate'; +import { OGCertificateDocument } from '@/app/api/v1/certificates/templates/og-certificate'; import { getCertificateDetails } from '@/lib/certificate-helper'; +import { renderToBuffer } from '@react-pdf/renderer'; import { createClient } from '@tuturuuu/supabase/next/server'; +import type { CertificateTemplate } from '@tuturuuu/types/db'; +import { CertificateViewer } from '@tuturuuu/ui/custom/education/certificates/certificate-viewer'; +import type { CertificateData } from '@tuturuuu/ui/custom/education/certificates/types'; +import { getLocale, getTranslations } from 'next-intl/server'; import { notFound, redirect } from 'next/navigation'; -export type CertificateProps = { - certDetails: { - courseName: string; - studentName: string | null; - courseLecturer: string | null; - completionDate: string; - certificateId: string; - }; - wsId: string; -}; - interface PageProps { params: Promise<{ certificateId: string; @@ -24,6 +20,7 @@ interface PageProps { export default async function CertificatePage({ params }: PageProps) { const { certificateId, wsId } = await params; const supabase = await createClient(); + const locale = await getLocale(); // Get current user const { @@ -34,18 +31,57 @@ export default async function CertificatePage({ params }: PageProps) { } try { + // Validate that the user has access to this certificate and get details const certDetails = await getCertificateDetails( certificateId, user.id, wsId ); + + // Get translations for PDF generation + const t = await getTranslations({ locale, namespace: 'certificates' }); + + // Prepare certificate data + const data: CertificateData = { + certData: certDetails, + title: t('title'), + certifyText: t('certify_text'), + completionText: t('completion_text'), + offeredBy: t('offered_by'), + completionDateLabel: t('completion_date'), + certificateIdLabel: t('certificate_id'), + }; + + const certTemplate: CertificateTemplate = certDetails.certTemplate; + let pdfBuffer: Buffer; + switch (certTemplate) { + case 'elegant': + pdfBuffer = await renderToBuffer( + + ); + break; + case 'modern': + pdfBuffer = await renderToBuffer( + + ); + break; + default: + pdfBuffer = await renderToBuffer(); + break; + } + + // Convert buffer to base64 data URL + const pdfDataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`; + return ( - <> - - + ); } catch (error) { - console.error('Error fetching certificate details:', error); + console.error('Error generating certificate:', error); notFound(); } } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/certificate-page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/certificate-page.tsx deleted file mode 100644 index 9133f42006..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/certificate-page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client'; - -import { DownloadButtonPDF } from './[certificateId]/download-button-pdf'; -import { CertificateProps } from './[certificateId]/page'; -import { Button } from '@tuturuuu/ui/button'; -import { ImageIcon } from '@tuturuuu/ui/icons'; -import { Separator } from '@tuturuuu/ui/separator'; -import html2canvas from 'html2canvas'; -import { useTranslations } from 'next-intl'; -import { useCallback } from 'react'; - -export default function Certificate({ certDetails, wsId }: CertificateProps) { - const t = useTranslations('certificates'); - const { - courseName, - studentName, - completionDate, - certificateId, - courseLecturer, - } = certDetails; - - const handlePNG = useCallback(async () => { - const element = document.getElementById('certificate-area'); - if (!element) { - throw new Error('Certificate element not found'); - } - - try { - const canvas = await html2canvas(element, { - useCORS: true, - allowTaint: true, - backgroundColor: null, - scale: 2, - logging: false, - onclone: (clonedDoc: Document) => { - Array.from(clonedDoc.getElementsByTagName('link')).forEach( - (link: HTMLLinkElement) => { - link.removeAttribute('integrity'); - link.removeAttribute('crossorigin'); - } - ); - }, - }); - - const link = document.createElement('a'); - link.download = `certificate-${certificateId}.png`; - link.href = canvas.toDataURL('image/png', 1.0); - link.click(); - } catch (error) { - console.error('Error generating PNG:', error); - throw error; - } - }, [certificateId]); - - return ( -
-
-
- Certificate watermark - -
-

- {t('title')} -

- -
- -
-

- {t('certify_text')} -

-

- {studentName} -

-

{t('completion_text')}

-

- {courseName} -

-

{t('offered_by')}

-

- {courseLecturer} -

-
- -
-
-

- {t('completion_date')}: -

-

{completionDate}

-
-
-

- {t('certificate_id')}: -

-

- {certificateId} -

-
-
-
- -
- - -
-
-
- ); -} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/page.tsx index 2c18398959..4e74e5a973 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/page.tsx @@ -1,4 +1,3 @@ -import { DownloadButtonPDF } from './[certificateId]/download-button-pdf'; import { CertificatePagination } from './certificate-pagination'; import { getAllCertificatesForUser } from '@/lib/certificate-helper'; import { createClient } from '@tuturuuu/supabase/next/server'; @@ -12,6 +11,7 @@ import { CardHeader, CardTitle, } from '@tuturuuu/ui/card'; +import { DownloadButtonPDF } from '@tuturuuu/ui/custom/education/certificates/download-button-pdf'; import FeatureSummary from '@tuturuuu/ui/custom/feature-summary'; import { Award, Calendar, Eye } from '@tuturuuu/ui/icons'; import { Separator } from '@tuturuuu/ui/separator'; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/columns.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/columns.tsx index ae306dc57d..65ae7505e1 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/columns.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/columns.tsx @@ -91,6 +91,8 @@ export const getWorkspaceCourseColumns = ( }, { id: 'actions', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/page.tsx index f88bb86a7f..ed48740d7a 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/page.tsx @@ -57,7 +57,7 @@ export default async function WorkspaceCoursesPage({ description={t('ws-courses.description')} createTitle={t('ws-courses.create')} createDescription={t('ws-courses.create_description')} - form={} + form={} /> diff --git a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/route.tsx b/apps/upskii/src/app/api/v1/certificates/[certId]/generate/route.tsx index 38373c2433..0e173a78f4 100644 --- a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/route.tsx +++ b/apps/upskii/src/app/api/v1/certificates/[certId]/generate/route.tsx @@ -1,8 +1,11 @@ -import { CertificateDocument } from './certificate-document'; -import { CertificateData } from './types'; +import { ElegantCertificateDocument } from '../../templates/elegant-certificate'; +import { ModernCertificateDocument } from '../../templates/modern-certificate'; +import { OGCertificateDocument } from '../../templates/og-certificate'; import { getCertificateDetails } from '@/lib/certificate-helper'; import { renderToStream } from '@react-pdf/renderer'; import { createClient } from '@tuturuuu/supabase/next/server'; +import type { CertificateTemplate } from '@tuturuuu/types/db'; +import { CertificateData } from '@tuturuuu/ui/custom/education/certificates/types'; import { getTranslations } from 'next-intl/server'; import { NextRequest } from 'next/server'; @@ -44,7 +47,30 @@ export async function POST( certificateIdLabel: t('certificate_id'), }; - const stream = await renderToStream(); + const certTemplate: CertificateTemplate = certData.certTemplate; + + let stream; + + switch (certTemplate) { + case 'elegant': + stream = await renderToStream( + + ); + break; + case 'modern': + stream = await renderToStream( + + ); + break; + case 'original': + stream = await renderToStream(); + break; + default: + console.log('Unhandled template:', certTemplate); + console.log('Using original template as fallback'); + stream = await renderToStream(); + break; + } const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk)); diff --git a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/types.ts b/apps/upskii/src/app/api/v1/certificates/[certId]/generate/types.ts deleted file mode 100644 index 74c854f35b..0000000000 --- a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CertificateProps } from '@/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/page'; - -export interface CertificateData { - certData: CertificateProps['certDetails']; - title: string; - certifyText: string; - completionText: string; - offeredBy: string; - completionDateLabel: string; - certificateIdLabel: string; -} diff --git a/apps/upskii/src/app/api/v1/certificates/templates/elegant-certificate.tsx b/apps/upskii/src/app/api/v1/certificates/templates/elegant-certificate.tsx new file mode 100644 index 0000000000..4de3dd6b26 --- /dev/null +++ b/apps/upskii/src/app/api/v1/certificates/templates/elegant-certificate.tsx @@ -0,0 +1,312 @@ +import { BASE_URL } from '@/constants/common'; +import { registerRobotoFonts } from '@/lib/font-register-pdf'; +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; +import { CertificateData } from '@tuturuuu/ui/custom/education/certificates/types'; + +registerRobotoFonts(); + +// Elegant Certificate Styles +const styles = StyleSheet.create({ + // Layout + page: { + backgroundColor: '#ffffff', + padding: '16pt', + }, + container: { + backgroundColor: '#fefefe', + borderWidth: 3, + borderColor: '#2563eb', + borderStyle: 'solid', + padding: '32pt 24pt', + position: 'relative', + display: 'flex', + flexDirection: 'column', + minHeight: '100%', + }, + + // Decorative elements + topBorder: { + position: 'absolute', + top: '12pt', + left: '12pt', + right: '12pt', + height: '2pt', + backgroundColor: '#dc2626', + }, + bottomBorder: { + position: 'absolute', + bottom: '12pt', + left: '12pt', + right: '12pt', + height: '2pt', + backgroundColor: '#dc2626', + }, + leftBorder: { + position: 'absolute', + top: '12pt', + left: '12pt', + bottom: '12pt', + width: '2pt', + backgroundColor: '#dc2626', + }, + rightBorder: { + position: 'absolute', + top: '12pt', + right: '12pt', + bottom: '12pt', + width: '2pt', + backgroundColor: '#dc2626', + }, + + cornerDecoration: { + position: 'absolute', + width: '24pt', + height: '24pt', + backgroundColor: '#fbbf24', + }, + topLeftCorner: { + top: '6pt', + left: '6pt', + }, + topRightCorner: { + top: '6pt', + right: '6pt', + }, + bottomLeftCorner: { + bottom: '6pt', + left: '6pt', + }, + bottomRightCorner: { + bottom: '6pt', + right: '6pt', + }, + + watermark: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + opacity: 0.12, + zIndex: 0, + }, + + // Header + header: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginBottom: '24pt', + }, + title: { + fontSize: 36, + fontWeight: 'bold', + color: '#1e40af', + textAlign: 'center', + fontFamily: 'Roboto', + letterSpacing: '1.5pt', + textTransform: 'uppercase', + }, + titleUnderline: { + width: '120pt', + height: '3pt', + backgroundColor: '#fbbf24', + marginTop: '8pt', + }, + + // Body + body: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '25pt', + flex: 1, + justifyContent: 'center', + }, + + certifySection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '12pt', + }, + subtitle: { + fontSize: 20, + color: '#374151', + textAlign: 'center', + fontFamily: 'Roboto', + fontWeight: 'light', + }, + nameContainer: { + borderBottomWidth: 2, + borderBottomColor: '#2563eb', + borderBottomStyle: 'solid', + paddingBottom: '4pt', + paddingHorizontal: '16pt', + }, + name: { + fontSize: 30, + fontWeight: 'bold', + color: '#1f2937', + textAlign: 'center', + fontFamily: 'Roboto', + letterSpacing: '1pt', + }, + + courseSection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8pt', + backgroundColor: '#f8fafc', + padding: '12pt', + borderRadius: '8pt', + borderWidth: 1, + borderColor: '#e5e7eb', + borderStyle: 'solid', + }, + course: { + fontSize: 24, + fontWeight: 'bold', + color: '#1e40af', + textAlign: 'center', + fontFamily: 'Roboto', + }, + instructorLabel: { + fontSize: 20, + color: '#6b7280', + textAlign: 'center', + fontFamily: 'Roboto', + fontWeight: 'light', + }, + instructor: { + fontSize: 24, + fontWeight: 'bold', + color: '#374151', + textAlign: 'center', + fontFamily: 'Roboto', + }, + + // Footer + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + marginTop: '24pt', + paddingTop: '12pt', + borderTop: '1pt solid #d1d5db', + }, + footerBlock: { + display: 'flex', + flexDirection: 'column', + gap: '6pt', + }, + footerBlockRight: { + display: 'flex', + flexDirection: 'column', + gap: '6pt', + alignItems: 'flex-end', + }, + footerLabel: { + fontSize: 12, + color: '#6b7280', + fontFamily: 'Roboto', + fontWeight: 'light', + textTransform: 'uppercase', + letterSpacing: '0.5pt', + }, + footerValue: { + fontSize: 16, + fontWeight: 'bold', + fontFamily: 'Roboto', + color: '#1f2937', + }, + + // Signature line + signatureLine: { + width: '120pt', + height: '1pt', + backgroundColor: '#9ca3af', + marginTop: '8pt', + }, +}); + +// Components +const CertificateHeader = ({ title }: { title: string }) => ( + + {title} + + +); + +const CertificateBody = ({ data }: { data: CertificateData }) => ( + + + {data.certifyText} + + {data.certData.studentName} + + {data.completionText} + + + + {data.certData.courseName} + {data.offeredBy} + {data.certData.courseLecturer} + + +); + +const CertificateFooter = ({ data }: { data: CertificateData }) => ( + + + {data.completionDateLabel} + {data.certData.completionDate} + + + + {data.certificateIdLabel} + {data.certData.certificateId} + + + +); + +export const ElegantCertificateDocument: React.FC<{ + data: CertificateData; +}> = ({ data }) => ( + + + + {/* Decorative borders */} + + + + + + + + + + {/* Watermark */} + + + {/* Content */} + + + + + + +); diff --git a/apps/upskii/src/app/api/v1/certificates/templates/modern-certificate.tsx b/apps/upskii/src/app/api/v1/certificates/templates/modern-certificate.tsx new file mode 100644 index 0000000000..30b794bd72 --- /dev/null +++ b/apps/upskii/src/app/api/v1/certificates/templates/modern-certificate.tsx @@ -0,0 +1,291 @@ +import { BASE_URL } from '@/constants/common'; +import { registerRobotoFonts } from '@/lib/font-register-pdf'; +import { + Document, + Image, + Page, + StyleSheet, + Text, + View, +} from '@react-pdf/renderer'; +import type { CertificateData } from '@tuturuuu/ui/custom/education/certificates/types'; +import type React from 'react'; + +registerRobotoFonts(); + +// Modern Certificate Styles +const styles = StyleSheet.create({ + // Layout + page: { + backgroundColor: '#0f172a', + padding: '8pt', // Reduced from 12pt + }, + container: { + backgroundColor: '#ffffff', + padding: '20pt', // Reduced from 30pt + position: 'relative', + display: 'flex', + flexDirection: 'column', + minHeight: '100%', + overflow: 'hidden', + gap: '16pt', // Reduced from 24pt + }, + + // Header section with gradient-like effect + headerSection: { + backgroundColor: '#1e293b', + padding: '20pt 24pt 24pt 24pt', // Reduced padding + position: 'relative', + }, + headerAccent: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '8pt', + backgroundColor: '#06b6d4', + }, + + watermark: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + opacity: 0.12, + zIndex: 0, + }, + + // Header + header: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8pt', + }, + title: { + fontSize: 30, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center', + fontFamily: 'Roboto', + letterSpacing: '1.5pt', + }, + titleAccent: { + fontSize: 12, + color: '#06b6d4', + textAlign: 'center', + fontFamily: 'Roboto', + fontWeight: 'light', + letterSpacing: '1pt', + textTransform: 'uppercase', + }, + + // Body + bodySection: { + padding: '24pt 24pt', // Reduced from 30pt 28pt + flex: 1, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, + + body: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '14pt', // Reduced from 18pt + }, + + certifyText: { + fontSize: 16, + color: '#475569', + textAlign: 'center', + fontFamily: 'Roboto', + fontWeight: 'light', + lineHeight: 1.3, // Reduced from 1.4 + }, + + nameSection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '6pt', // Reduced from 8pt + backgroundColor: '#f8fafc', + padding: '12pt 18pt', // Reduced padding + borderLeft: '4pt solid #06b6d4', + }, + name: { + fontSize: 26, + fontWeight: 'bold', + color: '#0f172a', + textAlign: 'center', + fontFamily: 'Roboto', + letterSpacing: '1pt', + }, + + achievementSection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '12pt', // Reduced from 14pt + width: '100%', + }, + + courseContainer: { + backgroundColor: '#1e293b', + padding: '12pt', // Reduced from 14pt + borderRadius: '4pt', + width: '100%', + maxWidth: '320pt', // Increased from 280pt for better text fit + }, + course: { + fontSize: 20, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center', + fontFamily: 'Roboto', + lineHeight: 1.2, // Reduced from 1.3 + }, + + instructorSection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '5pt', + }, + offeredBy: { + fontSize: 12, + color: '#64748b', + textAlign: 'center', + fontFamily: 'Roboto', + fontWeight: 'light', + }, + instructor: { + fontSize: 18, + fontWeight: 'bold', + color: '#1e293b', + textAlign: 'center', + fontFamily: 'Roboto', + }, + + // Footer + footerSection: { + backgroundColor: '#f1f5f9', + padding: '14pt 24pt', // Reduced padding + borderTop: '1pt solid #e2e8f0', + }, + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + gap: '20pt', + }, + footerBlock: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '8pt', + }, + footerIcon: { + width: '12pt', + height: '12pt', + backgroundColor: '#06b6d4', + borderRadius: '9999pt', + }, + footerContent: { + display: 'flex', + flexDirection: 'column', + gap: '2pt', + }, + footerLabel: { + fontSize: 11, + color: '#64748b', + fontFamily: 'Roboto', + fontWeight: 'light', + textTransform: 'uppercase', + letterSpacing: '0.5pt', + }, + footerValue: { + fontSize: 12, + fontWeight: 'bold', + fontFamily: 'Roboto', + color: '#1e293b', + }, +}); + +// Components +const CertificateHeader = ({ title }: { title: string }) => ( + + + + Certificate of Achievement + {title} + + +); + +const CertificateBody = ({ data }: { data: CertificateData }) => ( + + + {data.certifyText} + + + {data.certData.studentName} + + + + {data.completionText} + + + {data.certData.courseName} + + + + {data.offeredBy} + {data.certData.courseLecturer} + + + + +); + +const CertificateFooter = ({ data }: { data: CertificateData }) => ( + + + + + + {data.completionDateLabel} + {data.certData.completionDate} + + + + + + {data.certificateIdLabel} + {data.certData.certificateId} + + + + + +); + +export const ModernCertificateDocument: React.FC<{ data: CertificateData }> = ({ + data, +}) => ( + + + + + + + + + + +); diff --git a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/certificate-document.tsx b/apps/upskii/src/app/api/v1/certificates/templates/og-certificate.tsx similarity index 89% rename from apps/upskii/src/app/api/v1/certificates/[certId]/generate/certificate-document.tsx rename to apps/upskii/src/app/api/v1/certificates/templates/og-certificate.tsx index 40b0520c08..2a6258b972 100644 --- a/apps/upskii/src/app/api/v1/certificates/[certId]/generate/certificate-document.tsx +++ b/apps/upskii/src/app/api/v1/certificates/templates/og-certificate.tsx @@ -1,25 +1,16 @@ -import { CertificateData } from './types'; import { BASE_URL } from '@/constants/common'; +import { registerRobotoFonts } from '@/lib/font-register-pdf'; import { Document, - Font, Image, Page, StyleSheet, Text, View, } from '@react-pdf/renderer'; +import { CertificateData } from '@tuturuuu/ui/custom/education/certificates/types'; -// Register fonts -Font.register({ - family: 'Roboto', - fonts: [ - { src: `${BASE_URL}/fonts/Roboto-Regular.ttf` }, - { src: `${BASE_URL}/fonts/Roboto-Medium.ttf`, fontWeight: 'medium' }, - { src: `${BASE_URL}/fonts/Roboto-Bold.ttf`, fontWeight: 'bold' }, - { src: `${BASE_URL}/fonts/Roboto-Light.ttf`, fontWeight: 'light' }, - ], -}); +registerRobotoFonts(); // Styles const styles = StyleSheet.create({ @@ -167,7 +158,7 @@ const CertificateFooter = ({ data }: { data: CertificateData }) => ( ); -export const CertificateDocument: React.FC<{ data: CertificateData }> = ({ +export const OGCertificateDocument: React.FC<{ data: CertificateData }> = ({ data, }) => ( diff --git a/apps/upskii/src/lib/certificate-helper.ts b/apps/upskii/src/lib/certificate-helper.ts index c1eb5c0fa5..2262015957 100644 --- a/apps/upskii/src/lib/certificate-helper.ts +++ b/apps/upskii/src/lib/certificate-helper.ts @@ -1,31 +1,21 @@ import { createClient } from '@tuturuuu/supabase/next/server'; -import { Database } from '@tuturuuu/types/supabase'; - -export type CertificateWithDetails = - Database['public']['Tables']['course_certificates']['Row'] & { - workspace_courses: Pick< - Database['public']['Tables']['workspace_courses']['Row'], - 'name' | 'ws_id' - > & { - workspaces: Pick< - Database['public']['Tables']['workspaces']['Row'], - 'name' - >; - }; - users: { - user_private_details: Pick< - Database['public']['Tables']['user_private_details']['Row'], - 'full_name' - >; - }; +import type { + CourseCertificate, + UserPrivateDetails, + Workspace, + WorkspaceCourse, +} from '@tuturuuu/types/db'; + +export type CertificateWithDetails = CourseCertificate & { + workspace_courses: Pick< + WorkspaceCourse, + 'name' | 'ws_id' | 'cert_template' + > & { + workspaces: Pick; + }; + users: { + user_private_details: Pick; }; - -export type CertificateDetails = { - courseName: string; - studentName: string; - courseLecturer: string; - completionDate: string; - certificateId: string; }; export type CertificateListItem = { @@ -53,6 +43,7 @@ export async function getCertificateDetails( workspace_courses!course_certificates_course_id_fkey ( name, ws_id, + cert_template, workspaces!workspace_courses_ws_id_fkey ( name ) @@ -70,6 +61,7 @@ export async function getCertificateDetails( .single()) as { data: CertificateWithDetails | null; error: any }; if (error) { + console.log(error); throw new Error('Failed to fetch certificate'); } @@ -83,6 +75,7 @@ export async function getCertificateDetails( courseLecturer: certificate.workspace_courses.workspaces.name, completionDate: certificate.completed_date, certificateId: certificate.id, + certTemplate: certificate.workspace_courses.cert_template, }; } @@ -129,15 +122,9 @@ export async function getAllCertificatesForUser( count, } = (await queryBuilder) as { data: - | (Database['public']['Tables']['course_certificates']['Row'] & { - workspace_courses: Pick< - Database['public']['Tables']['workspace_courses']['Row'], - 'name' | 'ws_id' - > & { - workspaces: Pick< - Database['public']['Tables']['workspaces']['Row'], - 'name' - >; + | (CourseCertificate & { + workspace_courses: Pick & { + workspaces: Pick; }; })[] | null; diff --git a/apps/upskii/src/lib/font-register-pdf.ts b/apps/upskii/src/lib/font-register-pdf.ts new file mode 100644 index 0000000000..639d01a959 --- /dev/null +++ b/apps/upskii/src/lib/font-register-pdf.ts @@ -0,0 +1,21 @@ +// apps/upskii/src/lib/pdf/register-roboto-fonts.ts +import { BASE_URL } from '@/constants/common'; +import { Font } from '@react-pdf/renderer'; + +let alreadyRegistered = false; + +export function registerRobotoFonts() { + const baseUrl = `${BASE_URL}/fonts`; + if (alreadyRegistered) return; // <- guard + alreadyRegistered = true; + + Font.register({ + family: 'Roboto', + fonts: [ + { src: `${baseUrl}/Roboto-Regular.ttf` }, // weight 400 (normal) + { src: `${baseUrl}/Roboto-Light.ttf`, fontWeight: 300 }, + { src: `${baseUrl}/Roboto-Medium.ttf`, fontWeight: 500 }, + { src: `${baseUrl}/Roboto-Bold.ttf`, fontWeight: 'bold' }, + ], + }); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 40930b846f..96462da3d7 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1995,7 +1995,13 @@ "no_courses_found": "No courses found", "no_courses_found_description": "No courses found in your workspace. Create a new course to get started.", "no_description_provided": "No description provided", - "delete_confirm_title": "Deleting \"{name}\"" + "delete_confirm_title": "Deleting \"{name}\"", + "certificate_template": "Certificate Design", + "select_certificate_template": "Choose a certificate template", + "image_preview_not_available": "Image Preview Not Available", + "image_preview_not_available_description": "A preview of the certificate template is not available.", + "hide_certificate_preview": "Hide Certificate Preview", + "show_certificate_preview": "Show Certificate Preview" }, "ws-course-modules": { "plural": "Course Modules", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 247b5be0a8..41888aac36 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -2006,7 +2006,13 @@ "table_view": "Dạng bảng", "no_courses_found": "Chưa có khóa học nào", "no_description_provided": "Không có mô tả", - "delete_confirm_title": "Xác nhận xoá \"{name}\"" + "delete_confirm_title": "Xác nhận xoá \"{name}\"", + "certificate_template": "Thiết kế bằng chứng chỉ", + "select_certificate_template": "Chọn một mẫu thiết kế chứng chỉ", + "image_preview_not_available": "Chưa có mẫu xem trước", + "image_preview_not_available_description": "Ảnh xem trước của mẫu chứng chỉ không có sẵn.", + "hide_certificate_preview": "Ẩn xem mẫu chứng chỉ", + "show_certificate_preview": "Hiển thị xem mẫu chứng chỉ" }, "ws-course-modules": { "plural": "Mô-đun khóa học", diff --git a/packages/types/src/db.ts b/packages/types/src/db.ts index c90505784e..6bf0d6fe83 100644 --- a/packages/types/src/db.ts +++ b/packages/types/src/db.ts @@ -138,3 +138,7 @@ export type NovaSubmissionData = NovaSubmissionWithScores & { email?: string | null; }; }; + +export type CourseCertificate = Tables<'course_certificates'>; +export type CertificateTemplate = + Database['public']['Enums']['certificate_templates']; diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index 74ad329eab..43d446ed39 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5041,6 +5041,7 @@ export type Database = { }; workspace_courses: { Row: { + cert_template: Database['public']['Enums']['certificate_templates']; created_at: string; description: string | null; id: string; @@ -5050,6 +5051,7 @@ export type Database = { ws_id: string; }; Insert: { + cert_template?: Database['public']['Enums']['certificate_templates']; created_at?: string; description?: string | null; id?: string; @@ -5059,6 +5061,7 @@ export type Database = { ws_id: string; }; Update: { + cert_template?: Database['public']['Enums']['certificate_templates']; created_at?: string; description?: string | null; id?: string; @@ -7828,6 +7831,7 @@ export type Database = { | 'paragraph_quiz' | 'flashcards'; calendar_hour_type: 'WORK' | 'PERSONAL' | 'MEETING'; + certificate_templates: 'original' | 'modern' | 'elegant'; chat_role: 'FUNCTION' | 'USER' | 'SYSTEM' | 'ASSISTANT'; dataset_type: 'excel' | 'csv' | 'html'; task_board_status: 'not_started' | 'active' | 'done' | 'closed'; @@ -7980,6 +7984,7 @@ export const Constants = { 'flashcards', ], calendar_hour_type: ['WORK', 'PERSONAL', 'MEETING'], + certificate_templates: ['original', 'modern', 'elegant'], chat_role: ['FUNCTION', 'USER', 'SYSTEM', 'ASSISTANT'], dataset_type: ['excel', 'csv', 'html'], task_board_status: ['not_started', 'active', 'done', 'closed'], diff --git a/packages/ui/package.json b/packages/ui/package.json index fb3e767782..8797677013 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -125,6 +125,10 @@ "types": "./src/components/ui/text-editor/types.ts", "import": "./src/components/ui/text-editor/types.ts" }, + "./custom/education/certificates/types": { + "types": "./src/components/ui/custom/education/certificates/types.ts", + "import": "./src/components/ui/custom/education/certificates/types.ts" + }, "./*": "./src/components/ui/*.tsx" } } diff --git a/packages/ui/src/components/ui/custom/education/certificates/certificate-viewer.tsx b/packages/ui/src/components/ui/custom/education/certificates/certificate-viewer.tsx new file mode 100644 index 0000000000..883d2ac1e8 --- /dev/null +++ b/packages/ui/src/components/ui/custom/education/certificates/certificate-viewer.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Button } from '@tuturuuu/ui/button'; +import { DownloadButtonPDF } from '@tuturuuu/ui/custom/education/certificates/download-button-pdf'; +import { ImageIcon } from '@tuturuuu/ui/icons'; +import html2canvas from 'html2canvas'; +import { useTranslations } from 'next-intl'; +import dynamic from 'next/dynamic'; +import { useCallback, useEffect } from 'react'; + +const PDFViewer = dynamic( + () => + import('@tuturuuu/ui/custom/education/modules/resources/pdf-viewer').then( + (mod) => mod.PDFViewer + ), + { ssr: false } +); + +interface CertificateViewerProps { + certificateId: string; + wsId: string; + pdfDataUrl: string; // Pre-generated PDF data URL from server +} + +export function CertificateViewer({ + certificateId, + pdfDataUrl, + wsId, +}: CertificateViewerProps) { + const t = useTranslations('certificates'); + const pdfUrl = pdfDataUrl; + + useEffect(() => { + // Cleanup blob URLs when component unmounts (but not data URLs) + return () => { + if (pdfDataUrl.startsWith('blob:')) { + URL.revokeObjectURL(pdfDataUrl); + } + }; + }, [pdfDataUrl]); + + const handlePNG = useCallback(async () => { + const element = document.getElementById('certificate-area'); + if (!element) { + throw new Error('Certificate element not found'); + } + + try { + const canvas = await html2canvas(element, { + useCORS: true, + allowTaint: true, + backgroundColor: null, + scale: 2, + logging: false, + onclone: (clonedDoc: Document) => { + Array.from(clonedDoc.getElementsByTagName('link')).forEach( + (link: HTMLLinkElement) => { + link.removeAttribute('integrity'); + link.removeAttribute('crossorigin'); + } + ); + }, + }); + + const link = document.createElement('a'); + link.download = `certificate-${certificateId}.png`; + link.href = canvas.toDataURL('image/png', 2.0); + link.click(); + } catch (error) { + console.error('Error generating PNG:', error); + throw error; + } + }, [certificateId]); + + return ( +
+
+ {pdfUrl && ( +
+ +
+ )} +
+
+ + +
+
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/download-button-pdf.tsx b/packages/ui/src/components/ui/custom/education/certificates/download-button-pdf.tsx similarity index 78% rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/download-button-pdf.tsx rename to packages/ui/src/components/ui/custom/education/certificates/download-button-pdf.tsx index f716aa59ed..e3480c02e9 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/certificates/[certificateId]/download-button-pdf.tsx +++ b/packages/ui/src/components/ui/custom/education/certificates/download-button-pdf.tsx @@ -1,6 +1,5 @@ 'use client'; -import { BASE_URL } from '@/constants/common'; import { Button } from '@tuturuuu/ui/button'; import { FileText } from '@tuturuuu/ui/icons'; import { useLocale, useTranslations } from 'next-intl'; @@ -11,19 +10,32 @@ export function DownloadButtonPDF({ wsId, className = '', variant = 'outline', + pdfDataUrl, }: { certificateId: string; wsId: string; className?: string; variant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'; + pdfDataUrl?: string; }) { const t = useTranslations('certificates'); const locale = useLocale(); const handleDownload = useCallback(async () => { try { + if (pdfDataUrl) { + // Use the data URL directly for download + const link = document.createElement('a'); + link.href = pdfDataUrl; + link.download = `${certificateId}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + return; + } + const res = await fetch( - `${BASE_URL}/api/v1/certificates/${certificateId}/generate`, + `/api/v1/certificates/${certificateId}/generate`, { method: 'POST', headers: { @@ -55,7 +67,7 @@ export function DownloadButtonPDF({ } catch (error) { console.error('Error downloading PDF:', error); } - }, [certificateId, wsId]); + }, [certificateId, wsId, pdfDataUrl, locale]); return ( - - +
+ {/* Form Section */} +
+
+ + ( + <> + + {t('name')} + + + + + + + + {t('course_description')} + + + + + + )} + /> + {enableCerts ? ( + <> + ( + + {t('certificate_template')} + + + + )} + /> + + {/* Preview Toggle Button */} + + + ) : ( + <> + )} + + + +
+ + {/* Preview Section */} + {enableCerts && showPreview && ( +
+ + + {imageError ? ( + + {t('image_preview_not_available')} + + {t('image_preview_not_available_description')} + + + ) : ( + {`${certTemplate} setImageError(true)} + src={`/media/certificate-previews/${locale}/${certTemplate}-preview.png`} + /> + )} + + +
+ )} +
); } diff --git a/packages/ui/src/components/ui/custom/education/courses/course-row-actions.tsx b/packages/ui/src/components/ui/custom/education/courses/course-row-actions.tsx index 617a1ab492..4b97a80c2b 100644 --- a/packages/ui/src/components/ui/custom/education/courses/course-row-actions.tsx +++ b/packages/ui/src/components/ui/custom/education/courses/course-row-actions.tsx @@ -20,10 +20,12 @@ import { useState } from 'react'; interface WorkspaceCourseRowActionsProps { row: Row; + enableCerts?: boolean; } export function WorkspaceCourseRowActions({ row, + enableCerts = false, }: WorkspaceCourseRowActionsProps) { const router = useRouter(); const t = useTranslations(); @@ -105,6 +107,7 @@ export function WorkspaceCourseRowActions({ } /> diff --git a/packages/ui/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx b/packages/ui/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx index aa3b906872..7eb6f05e44 100644 --- a/packages/ui/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +++ b/packages/ui/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx @@ -55,11 +55,11 @@ export function PDFViewer({ url }: { url: string }) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - className="relative aspect-video" + className="relative aspect-auto" style={{ width: pdfWidth }} >
} @@ -79,38 +79,41 @@ export function PDFViewer({ url }: { url: string }) { -
-
- -

+ {numPages > 1 && ( +

+
+ +

+ {t('common.page')} {pageNumber}{' '} + {t('common.of')}{' '} + {numPages || 1} +

+ +
+

{t('common.page')} {pageNumber}{' '} {t('common.of')} {numPages || 1}

-
-

- {t('common.page')} {pageNumber}{' '} - {t('common.of')} {numPages || 1} -

-
+ )}
); }