Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/app/profile/__tests__/ProfileTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,21 @@ describe('ProfileTabs', () => {
expect(screen.getByText('Web3 Master')).toBeInTheDocument();
expect(screen.queryByLabelText('Full Name')).not.toBeInTheDocument();
});

it('loads certificates only when the certificates tab is selected', async () => {
const user = userEvent.setup();

render(<ProfileTabs />);
await user.click(screen.getByRole('tab', { name: 'Certification Program' }));

await waitFor(() =>
expect(screen.getByRole('tabpanel', { name: 'Certification Program' })).toBeInTheDocument(),
);
expect(screen.getByRole('tab', { name: 'Certification Program' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(screen.getByText('Generate your Course Certificate')).toBeInTheDocument();
expect(screen.queryByLabelText('Full Name')).not.toBeInTheDocument();
});
});
115 changes: 115 additions & 0 deletions src/app/profile/components/CertificatesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client';

import { useState, memo } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { CertificateInputSchema, type CertificateInput } from '@/schemas/certificate.schema';
import { apiClient } from '@/lib/api';
import { FormInput } from '@/components/forms/FormInput';
import { FormError } from '@/components/forms/FormError';
import { SubmitButton } from '@/components/forms/SubmitButton';

function CertificatesPanel() {
const [apiError, setApiError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);

const methods = useForm<CertificateInput>({
resolver: zodResolver(CertificateInputSchema),
mode: 'onTouched',
});

const {
handleSubmit,
formState: { isSubmitting },
reset,
} = methods;

const onSubmit = async (data: CertificateInput) => {
setApiError(null);
setSuccessMessage(null);

try {
const result = await apiClient.post<{ certificateId: string }>(
'/api/certificates/generate',
data,
);
setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`);
reset();
} catch (error) {
setApiError(
error instanceof Error
? error.message
: 'Unable to generate certificate. Please try again.',
);
}
};

return (
<section
id="certificates-panel"
role="tabpanel"
aria-labelledby="certificates-tab"
className="rounded-lg bg-white p-6 shadow dark:bg-gray-900"
>
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
Certification Program
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
Generate your Course Certificate
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 max-w-2xl">
Complete the form below to request a certificate for a completed course. Your input fields
are validated, accessible, and connected to the Certification Program workflow.
</p>
</div>

<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl">
<FormInput
name="courseId"
label="Course ID"
placeholder="e.g. 123e4567-e89b-12d3-a456-426614174000"
helperText="Enter the UUID of the completed course for which you want a certificate."
required
/>

<FormInput
name="name"
label="Student Name"
placeholder="Your full name"
helperText="This name will appear on the issued certificate exactly as entered."
certificationProgram="Certificate of completion"
required
/>

<FormError error={apiError} id="certificate-api-error" />
{successMessage && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700"
role="status"
aria-live="polite"
>
{successMessage}
</motion.div>
)}

<div>
<SubmitButton
isLoading={isSubmitting}
loadingText="Generating certificate…"
className="w-full rounded-2xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Generate certificate
</SubmitButton>
</div>
</form>
</FormProvider>
</section>
);
}

export default memo(CertificatesPanel);
5 changes: 5 additions & 0 deletions src/app/profile/components/ProfileTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const CustomerSupportPanel = dynamic(() => import('./CustomerSupportPanel'), {
loading: () => <ProfilePanelSkeleton label="support" />,
});

const CertificatesPanel = dynamic(() => import('./CertificatesPanel'), {
loading: () => <ProfilePanelSkeleton label="certificates" />,
});

interface ProfileTabButtonProps {
tab: { id: ProfileTabId; label: string };
isActive: boolean;
Expand Down Expand Up @@ -76,6 +80,7 @@ export default function ProfileTabs() {
{activeTab === 'settings' && <SettingsPanel />}
{activeTab === 'achievements' && <AchievementsPanel />}
{activeTab === 'support' && <CustomerSupportPanel />}
{activeTab === 'certificates' && <CertificatesPanel />}
</>
);
}
3 changes: 2 additions & 1 deletion src/app/profile/profile-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProfileTabId = 'profile' | 'settings' | 'achievements' | 'support';
export type ProfileTabId = 'profile' | 'settings' | 'achievements' | 'support' | 'certificates';

export interface ProfileUser {
initials: string;
Expand Down Expand Up @@ -43,6 +43,7 @@ export const profileTabs: Array<{ id: ProfileTabId; label: string }> = [
{ id: 'settings', label: 'Settings' },
{ id: 'achievements', label: 'Achievements' },
{ id: 'support', label: 'Customer Support' },
{ id: 'certificates', label: 'Certification Program' },
];

// ── Customer Support ──────────────────────────────────────────────────────────
Expand Down
5 changes: 3 additions & 2 deletions src/lib/feature-flags/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ for (const f of SEED_FLAGS) {

// ─── Helpers ──────────────────────────────────────────────────────────────────

export function generateId(prefix = 'flag'): string {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
export function generateId(prefix = ''): string {
const uuid = crypto.randomUUID();
return prefix ? `${prefix}_${uuid}` : uuid;
}

export function createAuditEntry(
Expand Down
Loading