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
40 changes: 40 additions & 0 deletions src/app/certificates/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, afterEach } from 'vitest';
import CertificateGenerationPage from '../page';
import { apiClient } from '@/lib/api';

vi.mock('@/lib/api', () => ({
apiClient: {
post: vi.fn(),
},
}));

afterEach(() => {
vi.clearAllMocks();
});

describe('CertificateGenerationPage', () => {
it('submits certificate generation data and displays a success message', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-123' });

render(<CertificateGenerationPage />);

fireEvent.change(screen.getByLabelText(/Course ID/i), {
target: { value: '123e4567-e89b-12d3-a456-426614174000' },
});
fireEvent.change(screen.getByLabelText(/Student Name/i), {
target: { value: 'Jane Doe' },
});

fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));

await waitFor(() => {
expect(screen.getByText(/Certificate generated successfully/i)).toBeInTheDocument();
});

expect(apiClient.post).toHaveBeenCalledWith('/api/certificates/generate', {
courseId: '123e4567-e89b-12d3-a456-426614174000',
name: 'Jane Doe',
});
});
});
119 changes: 119 additions & 0 deletions src/app/certificates/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use client';

import { useState } 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 { FieldError, FormError } from '@/components/forms/FormError';
import { SubmitButton } from '@/components/forms/SubmitButton';

export default function CertificateGenerationPage() {
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: { errors, 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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 py-12 px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8 rounded-3xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-8 shadow-sm"
>
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
Certification Program
</p>
<h1 className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
Generate your Course Certificate
</h1>
<p className="mt-3 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}>
<motion.form
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
>
<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>
</motion.form>
</FormProvider>
</motion.div>
</div>
</div>
);
}
16 changes: 14 additions & 2 deletions src/components/forms/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface FormInputProps
children?: React.ReactNode; // For select options
rows?: number; // Explicitly add rows for textarea
helperText?: React.ReactNode;
certificationProgram?: string;
}

/**
Expand All @@ -29,6 +30,7 @@ export const FormInput: React.FC<FormInputProps> = ({
id,
required,
helperText,
certificationProgram,
'aria-describedby': ariaDescribedBy,
...props
}) => {
Expand All @@ -42,12 +44,14 @@ export const FormInput: React.FC<FormInputProps> = ({
const isError = !!error;
const inputId = id ?? `${name}-${generatedId}`;
const helperTextId = helperText ? `${inputId}-helper` : undefined;
const certificationProgramId = certificationProgram ? `${inputId}-certification-program` : undefined;
const errorId = isError ? `${inputId}-error` : undefined;
const describedBy =
[ariaDescribedBy, helperTextId, errorId].filter(Boolean).join(' ') || undefined;
[ariaDescribedBy, helperTextId, certificationProgramId, errorId].filter(Boolean).join(' ') || undefined;

const paddingLeftClass = Icon ? 'pl-10' : 'pl-4';
const baseStyles = `
w-full pl-${Icon ? '10' : '4'} pr-4 py-2.5
w-full ${paddingLeftClass} pr-4 py-2.5
bg-gray-50 border rounded-xl
focus:bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500
outline-none transition-all duration-200
Expand All @@ -63,6 +67,14 @@ export const FormInput: React.FC<FormInputProps> = ({
>
{label}
</label>
{certificationProgram && (
<p
id={certificationProgramId}
className="mt-1 text-xs font-semibold text-blue-700 dark:text-blue-200"
>
Certification program: {certificationProgram}
</p>
)}
<div className="relative group">
{Icon && (
<div
Expand Down
18 changes: 18 additions & 0 deletions src/components/forms/__tests__/FormInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ describe('FormInput', () => {
expect(input).toHaveAttribute('aria-invalid', 'false');
});

it('renders certification program metadata and includes it in accessible description', () => {
renderWithForm(
<FormInput
name="name"
label="Student Name"
helperText="Enter the full name to display on the certificate."
certificationProgram="Certificate of completion"
/>,
);

expect(screen.getByText('Certification program: Certificate of completion')).toBeInTheDocument();

const input = screen.getByRole('textbox', { name: 'Student Name' });
expect(input).toHaveAccessibleDescription(
'Enter the full name to display on the certificate. Certification program: Certificate of completion',
);
});

it('announces validation errors through aria-describedby and alert role', async () => {
renderWithForm(
<FormInput
Expand Down
Loading