feat: implement persistent personalized certificates
- Modified encodeCertificateId to include userId parameter - Updated decodeCertificateId to extract and return userId - Added backward compatibility for old 2-part certificate IDs - Certificate page now fetches and displays certificate owner's name - Submission page passes current user's ID when generating certificate - Fixed certificate template to use certificate owner's name instead of team leader - Certificates now show the encoded user's name regardless of who views them 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
59c1bb0366
commit
1dbe704230
@@ -5,6 +5,8 @@ import { decodeCertificateId } from '../../../utils/certificate';
|
|||||||
import {
|
import {
|
||||||
useTeamById,
|
useTeamById,
|
||||||
useTeamSubmission,
|
useTeamSubmission,
|
||||||
|
useAuthStore,
|
||||||
|
useUserById,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
@@ -12,11 +14,13 @@ import html2canvas from 'html2canvas';
|
|||||||
interface DecodedCert {
|
interface DecodedCert {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CertificatePage: FC = (): ReactElement => {
|
const CertificatePage: FC = (): ReactElement => {
|
||||||
const { certId } = useParams<{ certId: string }>();
|
const { certId } = useParams<{ certId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
@@ -65,11 +69,21 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||||
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
||||||
|
|
||||||
|
// Fetch the certificate owner's user data
|
||||||
|
const { data: certificateUserData, isLoading: isLoadingCertUser } = useUserById(
|
||||||
|
decodedInfo?.userId || '',
|
||||||
|
!!decodedInfo?.userId
|
||||||
|
);
|
||||||
|
|
||||||
const team = teamData?.data;
|
const team = teamData?.data;
|
||||||
const submission = submissionData?.data;
|
const submission = submissionData?.data;
|
||||||
|
const certificateUser = certificateUserData?.data;
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission || isLoadingCertUser;
|
||||||
|
|
||||||
|
// Certificate name: Use the user from the certificate ID, fallback to team leader
|
||||||
|
const certificateName = certificateUser?.fullname || team?.leader?.fullname;
|
||||||
|
|
||||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +112,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [team?.name, team?.leader?.fullname]);
|
}, [team?.name, certificateName]);
|
||||||
|
|
||||||
// Generate certificate canvas screenshot
|
// Generate certificate canvas screenshot
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -335,7 +349,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{team?.leader?.fullname || 'N/A'}
|
{certificateName || 'N/A'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement } from 'react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
|
||||||
import { encodeCertificateId } from '../../../../utils/certificate';
|
import { encodeCertificateId } from '../../../../utils/certificate';
|
||||||
|
|
||||||
const SubmissionViewPage: FC = (): ReactElement => {
|
const SubmissionViewPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
|
||||||
const { data: teamData } = useTeamById(teamId || '');
|
const { data: teamData } = useTeamById(teamId || '');
|
||||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
||||||
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
View Your Certificate
|
View Your Certificate
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
Congratulations! Your certificate is ready to download and share.
|
Congratulations! Your personalized certificate is ready to download and share.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const certId = await encodeCertificateId(teamId || '', submission.id);
|
const certId = await encodeCertificateId(
|
||||||
|
teamId || '',
|
||||||
|
submission.id,
|
||||||
|
session?.user?.id || ''
|
||||||
|
);
|
||||||
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
||||||
}}
|
}}
|
||||||
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||||
|
|||||||
@@ -3,27 +3,38 @@ import { decryptText, encryptText } from "./aesclient";
|
|||||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode teamId and submissionId into a certificate ID
|
* Encode teamId, submissionId, and userId into a certificate ID
|
||||||
* Uses base64 encoding for simple obfuscation
|
* Uses AES encryption for secure encoding
|
||||||
* @param teamId - The team ID
|
* @param teamId - The team ID
|
||||||
* @param submissionId - The submission ID
|
* @param submissionId - The submission ID
|
||||||
|
* @param userId - The user ID (team member)
|
||||||
* @returns Encoded certificate ID
|
* @returns Encoded certificate ID
|
||||||
*/
|
*/
|
||||||
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
|
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||||
const combined = `${teamId}::${submissionId}`;
|
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||||
return encryptText(combined, SECRET_KEY);
|
return encryptText(combined, SECRET_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode certificate ID back to teamId and submissionId
|
* Decode certificate ID back to teamId, submissionId, and userId
|
||||||
* @param certId - The encoded certificate ID
|
* @param certId - The encoded certificate ID
|
||||||
* @returns Object containing teamId and submissionId
|
* @returns Object containing teamId, submissionId, and userId
|
||||||
*/
|
*/
|
||||||
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
|
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
|
||||||
try {
|
try {
|
||||||
const decoded = await decryptText(certId, SECRET_KEY);
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
const [teamId, submissionId] = decoded.split('::');
|
const parts = decoded.split('::');
|
||||||
return { teamId, submissionId };
|
|
||||||
|
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [teamId, submissionId] = parts;
|
||||||
|
return { teamId, submissionId, userId: '' };
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
const [teamId, submissionId, userId] = parts;
|
||||||
|
return { teamId, submissionId, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid certificate format');
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid certificate ID');
|
throw new Error('Invalid certificate ID');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user