Skip to content

Commit a2f9c4b

Browse files
feat(frontend): form validation with inline error states (#184)
- Add loginSchema, registerSchema, courseSchema, credentialIssuanceSchema to src/lib/schemas.ts with full Zod validation rules - Add reusable FormField component with red-border error state, inline error message (role=alert), helper text, and ARIA attributes - Add LoginForm: email/password/rememberMe, onBlur validation, server error banner, disabled submit while invalid - Add RegisterForm: name/email/password/confirmPassword/acceptTerms, onChange validation (required for checkbox), password complexity rules, cross-field match check - Add CourseCreationForm: all course fields, category/difficulty/currency selects, description character counter, publish toggle - Add CredentialIssuanceForm: recipient info, credential type/title, fieldset grouping, issue/expiration date validation - Add /app/auth/login and /app/auth/register pages with branded layout - Add 54 tests: 38 schema unit tests + 16 RTL component tests Closes #184
1 parent a123741 commit a2f9c4b

11 files changed

Lines changed: 1750 additions & 0 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"zod": "^4.4.3"
6969
},
7070
"devDependencies": {
71+
"@ducanh2912/next-pwa": "^10.2.9",
7172
"@next/bundle-analyzer": "^14.0.0",
7273
"@sentry/webpack-plugin": "^2.10.2",
7374
"@stellar/freighter-api": "^5.0.0",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { LoginForm } from '@/components/forms/LoginForm';
5+
import type { LoginFormData } from '@/lib/schemas';
6+
7+
export default function LoginPage() {
8+
const router = useRouter();
9+
10+
const handleLogin = async (data: LoginFormData) => {
11+
const response = await fetch('/api/auth/login', {
12+
method: 'POST',
13+
headers: { 'Content-Type': 'application/json' },
14+
body: JSON.stringify({ email: data.email, password: data.password }),
15+
});
16+
17+
if (!response.ok) {
18+
const body = await response.json().catch(() => ({}));
19+
throw new Error(body?.message ?? 'Invalid email or password');
20+
}
21+
22+
const { token } = await response.json();
23+
if (token) {
24+
localStorage.setItem('admin_token', token);
25+
}
26+
27+
router.push('/');
28+
};
29+
30+
return (
31+
<main className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-950 px-4 py-12">
32+
<div className="w-full max-w-md">
33+
{/* Logo / brand */}
34+
<div className="mb-8 text-center">
35+
<span className="inline-block text-3xl font-bold text-blue-600">StarkEd</span>
36+
<h1 id="login-heading" className="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
37+
Sign in to your account
38+
</h1>
39+
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
40+
Welcome back! Please enter your details.
41+
</p>
42+
</div>
43+
44+
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-md border border-gray-200 dark:border-slate-700 p-8">
45+
<LoginForm
46+
onSubmit={handleLogin}
47+
registerHref="/auth/register"
48+
forgotPasswordHref="/auth/forgot-password"
49+
/>
50+
</div>
51+
</div>
52+
</main>
53+
);
54+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { RegisterForm } from '@/components/forms/RegisterForm';
5+
import type { RegisterFormData } from '@/lib/schemas';
6+
7+
export default function RegisterPage() {
8+
const router = useRouter();
9+
10+
const handleRegister = async (data: RegisterFormData) => {
11+
const response = await fetch('/api/auth/register', {
12+
method: 'POST',
13+
headers: { 'Content-Type': 'application/json' },
14+
body: JSON.stringify({
15+
name: data.name,
16+
email: data.email,
17+
password: data.password,
18+
}),
19+
});
20+
21+
if (!response.ok) {
22+
const body = await response.json().catch(() => ({}));
23+
throw new Error(body?.message ?? 'Registration failed. Please try again.');
24+
}
25+
26+
// Redirect to login so the user signs in after registering
27+
router.push('/auth/login?registered=true');
28+
};
29+
30+
return (
31+
<main className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-slate-950 px-4 py-12">
32+
<div className="w-full max-w-md">
33+
{/* Logo / brand */}
34+
<div className="mb-8 text-center">
35+
<span className="inline-block text-3xl font-bold text-blue-600">StarkEd</span>
36+
<h1 id="register-heading" className="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
37+
Create your account
38+
</h1>
39+
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
40+
Join StarkEd and start earning verifiable credentials.
41+
</p>
42+
</div>
43+
44+
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-md border border-gray-200 dark:border-slate-700 p-8">
45+
<RegisterForm
46+
onSubmit={handleRegister}
47+
loginHref="/auth/login"
48+
/>
49+
</div>
50+
</div>
51+
</main>
52+
);
53+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useForm, type Resolver } from 'react-hook-form';
5+
import { zodResolver } from '@hookform/resolvers/zod';
6+
import { Loader2, BookPlus } from 'lucide-react';
7+
import {
8+
courseSchema,
9+
type CourseFormData,
10+
type CourseFormDataIn,
11+
COURSE_CATEGORIES,
12+
COURSE_DIFFICULTY_LEVELS,
13+
COURSE_CURRENCIES,
14+
} from '@/lib/schemas';
15+
import { FormField } from '@/components/forms/FormField';
16+
17+
export interface CourseCreationFormProps {
18+
onSubmit: (data: CourseFormData) => Promise<void>;
19+
defaultValues?: Partial<CourseFormDataIn>;
20+
submitLabel?: string;
21+
}
22+
23+
const CATEGORY_LABELS: Record<string, string> = {
24+
blockchain: 'Blockchain', programming: 'Programming',
25+
'data-science': 'Data Science', design: 'Design',
26+
business: 'Business', language: 'Language',
27+
mathematics: 'Mathematics', science: 'Science',
28+
arts: 'Arts & Humanities', other: 'Other',
29+
};
30+
31+
const DIFFICULTY_LABELS: Record<string, string> = {
32+
beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced',
33+
};
34+
35+
const selectClasses = (hasError: boolean) =>
36+
`w-full px-3 py-2 border rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 ${
37+
hasError
38+
? 'border-red-400 focus:ring-red-500'
39+
: 'border-gray-300 dark:border-slate-600 focus:ring-blue-500'
40+
}`;
41+
42+
/**
43+
* Course creation form with real-time (onBlur) Zod validation and inline error states.
44+
*/
45+
export function CourseCreationForm({
46+
onSubmit,
47+
defaultValues,
48+
submitLabel = 'Create course',
49+
}: CourseCreationFormProps) {
50+
const [serverError, setServerError] = useState<string | null>(null);
51+
52+
const {
53+
register,
54+
handleSubmit,
55+
formState: { errors, isSubmitting, isValid },
56+
watch,
57+
} = useForm<CourseFormDataIn, any, CourseFormData>({
58+
resolver: zodResolver(
59+
courseSchema as unknown as Parameters<typeof zodResolver>[0],
60+
) as unknown as Resolver<CourseFormDataIn, any, CourseFormData>,
61+
mode: 'onBlur',
62+
defaultValues: {
63+
price: '0', currency: 'XLM', isPublished: false,
64+
prerequisites: '', tags: '',
65+
...defaultValues,
66+
},
67+
});
68+
69+
const descLength = (watch('description') ?? '').length;
70+
71+
const onFormSubmit = async (data: CourseFormData) => {
72+
setServerError(null);
73+
try {
74+
await onSubmit(data);
75+
} catch (error: unknown) {
76+
setServerError(error instanceof Error ? error.message : 'Failed to save course.');
77+
}
78+
};
79+
80+
return (
81+
<form onSubmit={handleSubmit(onFormSubmit)} noValidate className="space-y-6">
82+
{/* Title */}
83+
<FormField
84+
label="Course title"
85+
type="text"
86+
required
87+
placeholder="e.g. Introduction to Stellar Blockchain"
88+
helperText="5–120 characters."
89+
error={errors.title?.message}
90+
{...register('title')}
91+
/>
92+
93+
{/* Description */}
94+
<div className="space-y-1">
95+
<div className="flex items-baseline justify-between">
96+
<label htmlFor="course-desc" className="text-sm font-medium text-gray-700 dark:text-gray-300">
97+
Description <span className="text-red-500 ml-0.5" aria-label="required">*</span>
98+
</label>
99+
<span className={`text-xs ${descLength > 2000 ? 'text-red-600' : 'text-gray-500'}`}>
100+
{descLength}/2,000
101+
</span>
102+
</div>
103+
<textarea
104+
id="course-desc"
105+
rows={5}
106+
aria-invalid={Boolean(errors.description)}
107+
aria-describedby={errors.description ? 'course-desc-error' : undefined}
108+
placeholder="Describe what students will learn…"
109+
className={`w-full px-3 py-2 border rounded-lg resize-none bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
110+
errors.description ? 'border-red-400 focus:ring-red-500' : 'border-gray-300 dark:border-slate-600 focus:ring-blue-500'
111+
}`}
112+
{...register('description')}
113+
/>
114+
{errors.description && (
115+
<p id="course-desc-error" role="alert" className="text-sm text-red-600 dark:text-red-400">
116+
{errors.description.message}
117+
</p>
118+
)}
119+
</div>
120+
121+
{/* Category + Difficulty */}
122+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
123+
<div className="space-y-1">
124+
<label htmlFor="course-category" className="text-sm font-medium text-gray-700 dark:text-gray-300">
125+
Category <span className="text-red-500 ml-0.5" aria-label="required">*</span>
126+
</label>
127+
<select
128+
id="course-category"
129+
aria-invalid={Boolean(errors.category)}
130+
aria-describedby={errors.category ? 'course-category-error' : undefined}
131+
className={selectClasses(Boolean(errors.category))}
132+
{...register('category')}
133+
>
134+
<option value="">Select a category</option>
135+
{COURSE_CATEGORIES.map((cat) => (
136+
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] ?? cat}</option>
137+
))}
138+
</select>
139+
{errors.category && (
140+
<p id="course-category-error" role="alert" className="text-sm text-red-600 dark:text-red-400">
141+
{errors.category.message}
142+
</p>
143+
)}
144+
</div>
145+
146+
<div className="space-y-1">
147+
<label htmlFor="course-difficulty" className="text-sm font-medium text-gray-700 dark:text-gray-300">
148+
Difficulty <span className="text-red-500 ml-0.5" aria-label="required">*</span>
149+
</label>
150+
<select
151+
id="course-difficulty"
152+
aria-invalid={Boolean(errors.difficulty)}
153+
aria-describedby={errors.difficulty ? 'course-difficulty-error' : undefined}
154+
className={selectClasses(Boolean(errors.difficulty))}
155+
{...register('difficulty')}
156+
>
157+
<option value="">Select difficulty</option>
158+
{COURSE_DIFFICULTY_LEVELS.map((level) => (
159+
<option key={level} value={level}>{DIFFICULTY_LABELS[level] ?? level}</option>
160+
))}
161+
</select>
162+
{errors.difficulty && (
163+
<p id="course-difficulty-error" role="alert" className="text-sm text-red-600 dark:text-red-400">
164+
{errors.difficulty.message}
165+
</p>
166+
)}
167+
</div>
168+
</div>
169+
170+
{/* Price + Currency */}
171+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
172+
<FormField
173+
label="Price"
174+
type="text"
175+
inputMode="decimal"
176+
required
177+
placeholder="0"
178+
helperText="Enter 0 for a free course."
179+
error={errors.price?.message}
180+
{...register('price')}
181+
/>
182+
183+
<div className="space-y-1">
184+
<label htmlFor="course-currency" className="text-sm font-medium text-gray-700 dark:text-gray-300">
185+
Currency <span className="text-red-500 ml-0.5" aria-label="required">*</span>
186+
</label>
187+
<select
188+
id="course-currency"
189+
aria-invalid={Boolean(errors.currency)}
190+
aria-describedby={errors.currency ? 'course-currency-error' : undefined}
191+
className={selectClasses(Boolean(errors.currency))}
192+
{...register('currency')}
193+
>
194+
{COURSE_CURRENCIES.map((c) => (
195+
<option key={c} value={c}>{c}</option>
196+
))}
197+
</select>
198+
{errors.currency && (
199+
<p id="course-currency-error" role="alert" className="text-sm text-red-600 dark:text-red-400">
200+
{errors.currency.message}
201+
</p>
202+
)}
203+
</div>
204+
</div>
205+
206+
{/* Duration */}
207+
<FormField
208+
label="Estimated duration"
209+
type="text"
210+
required
211+
placeholder="e.g. 8 hours, 4 weeks"
212+
helperText="Give students a realistic time estimate."
213+
error={errors.duration?.message}
214+
{...register('duration')}
215+
/>
216+
217+
{/* Prerequisites */}
218+
<FormField
219+
label="Prerequisites"
220+
multiline
221+
rows={2}
222+
placeholder="What should students know beforehand? (optional)"
223+
error={errors.prerequisites?.message}
224+
{...register('prerequisites')}
225+
/>
226+
227+
{/* Tags */}
228+
<FormField
229+
label="Tags"
230+
type="text"
231+
placeholder="stellar, blockchain, defi (comma-separated, optional)"
232+
helperText="Comma-separated keywords to aid discoverability."
233+
error={errors.tags?.message}
234+
{...register('tags')}
235+
/>
236+
237+
{/* Publish toggle */}
238+
<label className="flex items-center gap-3 cursor-pointer">
239+
<input
240+
type="checkbox"
241+
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
242+
{...register('isPublished')}
243+
/>
244+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Publish immediately</span>
245+
</label>
246+
247+
{serverError && (
248+
<div role="alert" aria-live="assertive" className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
249+
{serverError}
250+
</div>
251+
)}
252+
253+
<button
254+
type="submit"
255+
disabled={isSubmitting || !isValid}
256+
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
257+
aria-busy={isSubmitting}
258+
>
259+
{isSubmitting ? (
260+
<><Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> Saving…</>
261+
) : (
262+
<><BookPlus className="w-4 h-4" aria-hidden="true" /> {submitLabel}</>
263+
)}
264+
</button>
265+
</form>
266+
);
267+
}

0 commit comments

Comments
 (0)