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 {
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
useAuthStore,
|
||||
useUserById,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
@@ -12,11 +14,13 @@ import html2canvas from 'html2canvas';
|
||||
interface DecodedCert {
|
||||
teamId: string;
|
||||
submissionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const CertificatePage: FC = (): ReactElement => {
|
||||
const { certId } = useParams<{ certId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||
@@ -65,11 +69,21 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||
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 submission = submissionData?.data;
|
||||
const certificateUser = certificateUserData?.data;
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -98,7 +112,7 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [team?.name, team?.leader?.fullname]);
|
||||
}, [team?.name, certificateName]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
@@ -335,7 +349,7 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{team?.leader?.fullname || 'N/A'}
|
||||
{certificateName || 'N/A'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
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';
|
||||
|
||||
const SubmissionViewPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
||||
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
View Your Certificate
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const certId = await encodeCertificateId(teamId || '', submission.id);
|
||||
const certId = await encodeCertificateId(
|
||||
teamId || '',
|
||||
submission.id,
|
||||
session?.user?.id || ''
|
||||
);
|
||||
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"
|
||||
|
||||
@@ -3,27 +3,38 @@ import { decryptText, encryptText } from "./aesclient";
|
||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||
|
||||
/**
|
||||
* Encode teamId and submissionId into a certificate ID
|
||||
* Uses base64 encoding for simple obfuscation
|
||||
* Encode teamId, submissionId, and userId into a certificate ID
|
||||
* Uses AES encryption for secure encoding
|
||||
* @param teamId - The team ID
|
||||
* @param submissionId - The submission ID
|
||||
* @param userId - The user ID (team member)
|
||||
* @returns Encoded certificate ID
|
||||
*/
|
||||
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
|
||||
const combined = `${teamId}::${submissionId}`;
|
||||
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||
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
|
||||
* @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 {
|
||||
const decoded = await decryptText(certId, SECRET_KEY);
|
||||
const [teamId, submissionId] = decoded.split('::');
|
||||
return { teamId, submissionId };
|
||||
const parts = decoded.split('::');
|
||||
|
||||
// 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 {
|
||||
throw new Error('Invalid certificate ID');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user