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 (
-
-
-
-

-
-
-
- {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 (
+
+
+
+
+
+
+
+ );
+}
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 (
-
-
-
-
+ {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}
-
-
+ )}
);
}