repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Pan-African Youth Internship,…
stars
latest
clone command
git clone gitlawb://did:key:z6MkmNFz...XP9B/pan-african-you...git clone gitlawb://did:key:z6MkmNFz.../pan-african-you...53f59357sync from playground21m ago| #1 | import { useState, type ChangeEvent, type FormEvent } from "react"; |
| #2 | import { supabase } from "./supabaseClient"; |
| #3 | |
| #4 | // ---- Data constants ---- |
| #5 | |
| #6 | const GENDER_OPTIONS = ["Male", "Female", "Non-binary", "Prefer not to say"]; |
| #7 | |
| #8 | const NATIONALITY_OPTIONS = [ |
| #9 | "Algerian", "Angolan", "Beninese", "Motswana", "Burkinabe", "Burundian", |
| #10 | "Cameroonian", "Cape Verdean", "Central African", "Chadian", "Comorian", |
| #11 | "Congolese (DRC)", "Congolese (Republic)", "Djiboutian", "Egyptian", |
| #12 | "Equatorial Guinean", "Eritrean", "Ethiopian", "Gabonese", "Gambian", |
| #13 | "Ghanaian", "Guinean", "Guinea-Bissauan", "Ivorian", "Kenyan", "Basotho", |
| #14 | "Liberian", "Libyan", "Malagasy", "Malawian", "Malian", "Mauritanian", |
| #15 | "Mauritian", "Moroccan", "Mozambican", "Namibian", "Nigerien", "Nigerian", |
| #16 | "Rwandan", "Sao Tomean", "Senegalese", "Seychellois", "Sierra Leonean", |
| #17 | "Somali", "South African", "South Sudanese", "Sudanese", "Swazi", |
| #18 | "Tanzanian", "Togolese", "Tunisian", "Ugandan", "Zambian", "Zimbabwean", |
| #19 | "Other", |
| #20 | ]; |
| #21 | |
| #22 | const COUNTRY_OPTIONS = [ |
| #23 | "Algeria", "Angola", "Benin", "Botswana", "Burkina Faso", "Burundi", |
| #24 | "Cameroon", "Cape Verde", "Central African Republic", "Chad", "Comoros", |
| #25 | "Democratic Republic of the Congo", "Republic of the Congo", "Djibouti", |
| #26 | "Egypt", "Equatorial Guinea", "Eritrea", "Ethiopia", "Gabon", "Gambia", |
| #27 | "Ghana", "Guinea", "Guinea-Bissau", "Ivory Coast", "Kenya", "Lesotho", |
| #28 | "Liberia", "Libya", "Madagascar", "Malawi", "Mali", "Mauritania", |
| #29 | "Mauritius", "Morocco", "Mozambique", "Namibia", "Niger", "Nigeria", |
| #30 | "Rwanda", "Sao Tome and Principe", "Senegal", "Seychelles", |
| #31 | "Sierra Leone", "Somalia", "South Africa", "South Sudan", "Sudan", |
| #32 | "Eswatini", "Tanzania", "Togo", "Tunisia", "Uganda", "Zambia", "Zimbabwe", |
| #33 | "Other", |
| #34 | ]; |
| #35 | |
| #36 | const LEVEL_OPTIONS = ["Diploma", "Bachelor's", "Master's", "PhD", "Technical/Vocational"]; |
| #37 | |
| #38 | const YEAR_OF_STUDY_OPTIONS = ["1st Year", "2nd Year", "3rd Year", "4th Year", "5th Year", "Graduated"]; |
| #39 | |
| #40 | const SKILLS_OPTIONS = [ |
| #41 | "Graphic Design", "Data Analysis", "Social Media Management", "Accounting", |
| #42 | "Research", "Programming", "Writing", "Translation", "Marketing", |
| #43 | "Community Mobilisation", "Project Management", "Public Speaking", |
| #44 | "Photography", "Video Editing", "Customer Service", "Sales", |
| #45 | "Event Planning", "Fundraising", "Grant Writing", "Monitoring & Evaluation", |
| #46 | ]; |
| #47 | |
| #48 | const LANGUAGE_OPTIONS = [ |
| #49 | "English", "French", "Arabic", "Portuguese", "Swahili", "Amharic", |
| #50 | "Hausa", "Yoruba", "Igbo", "Zulu", "Xhosa", "Shona", "Wolof", |
| #51 | "Somali", "Tigrinya", "Oromo", "Lingala", "Malagasy", "Kinyarwanda", |
| #52 | "Other", |
| #53 | ]; |
| #54 | |
| #55 | const DIGITAL_SKILLS_LEVEL = ["Beginner", "Intermediate", "Advanced", "Expert"]; |
| #56 | |
| #57 | const OPPORTUNITY_TYPES = [ |
| #58 | "Internship", "Volunteering", "Field Placement", "Research Assistantship", |
| #59 | "Graduate Trainee Programme", "Part-time Opportunity", "Remote Opportunity", |
| #60 | ]; |
| #61 | |
| #62 | const CAREER_FIELDS = [ |
| #63 | "Technology", "Health", "Agriculture", "Climate & Environment", |
| #64 | "Education", "Finance & Accounting", "Media & Communications", |
| #65 | "NGO & Civil Society", "Engineering", "Research & Academia", |
| #66 | "Government & Public Policy", "Law & Justice", "Arts & Culture", |
| #67 | "Tourism & Hospitality", "Energy", "Manufacturing", |
| #68 | ]; |
| #69 | |
| #70 | const EXPERIENCE_OPTIONS = [ |
| #71 | "Previous internship", "Volunteer experience", "Leadership roles", |
| #72 | "Student clubs & societies", "Freelance work", "Entrepreneurship", |
| #73 | "Community projects", "Research projects", "None yet", |
| #74 | ]; |
| #75 | |
| #76 | const DURATION_OPTIONS = [ |
| #77 | "1 month", "2 months", "3 months", "6 months", "12 months", "Flexible", |
| #78 | ]; |
| #79 | |
| #80 | const AVAILABILITY_OPTIONS = [ |
| #81 | "Full-time", "Part-time", "Weekends only", "Remote only", |
| #82 | ]; |
| #83 | |
| #84 | // ---- Types ---- |
| #85 | |
| #86 | interface FormData { |
| #87 | // Step 1 |
| #88 | fullName: string; |
| #89 | gender: string; |
| #90 | dateOfBirth: string; |
| #91 | nationality: string; |
| #92 | countryOfResidence: string; |
| #93 | city: string; |
| #94 | phone: string; |
| #95 | email: string; |
| #96 | password: string; |
| #97 | confirmPassword: string; |
| #98 | // Step 2 |
| #99 | university: string; |
| #100 | programme: string; |
| #101 | levelOfStudy: string; |
| #102 | yearOfStudy: string; |
| #103 | graduationYear: string; |
| #104 | specialisation: string; |
| #105 | gpa: string; |
| #106 | skills: string[]; |
| #107 | customSkill: string; |
| #108 | languages: string[]; |
| #109 | digitalSkillsLevel: string; |
| #110 | // Step 3 |
| #111 | opportunityTypes: string[]; |
| #112 | careerFields: string[]; |
| #113 | experience: string[]; |
| #114 | experienceDescription: string; |
| #115 | // Step 4 |
| #116 | cvFile: File | null; |
| #117 | coverLetterFile: File | null; |
| #118 | transcriptFile: File | null; |
| #119 | portfolioFile: File | null; |
| #120 | linkedinUrl: string; |
| #121 | // Step 5 |
| #122 | immediatelyAvailable: string; |
| #123 | preferredDuration: string; |
| #124 | availabilityType: string[]; |
| #125 | preferredCountry: string; |
| #126 | preferredCity: string; |
| #127 | openToRemote: string; |
| #128 | willingToRelocate: string; |
| #129 | studentIdFile: File | null; |
| #130 | universityEmail: string; |
| #131 | nationalIdFile: File | null; |
| #132 | acceptTerms: boolean; |
| #133 | } |
| #134 | |
| #135 | const INITIAL_FORM: FormData = { |
| #136 | fullName: "", |
| #137 | gender: "", |
| #138 | dateOfBirth: "", |
| #139 | nationality: "", |
| #140 | countryOfResidence: "", |
| #141 | city: "", |
| #142 | phone: "", |
| #143 | email: "", |
| #144 | password: "", |
| #145 | confirmPassword: "", |
| #146 | university: "", |
| #147 | programme: "", |
| #148 | levelOfStudy: "", |
| #149 | yearOfStudy: "", |
| #150 | graduationYear: "", |
| #151 | specialisation: "", |
| #152 | gpa: "", |
| #153 | skills: [], |
| #154 | customSkill: "", |
| #155 | languages: [], |
| #156 | digitalSkillsLevel: "", |
| #157 | opportunityTypes: [], |
| #158 | careerFields: [], |
| #159 | experience: [], |
| #160 | experienceDescription: "", |
| #161 | cvFile: null, |
| #162 | coverLetterFile: null, |
| #163 | transcriptFile: null, |
| #164 | portfolioFile: null, |
| #165 | linkedinUrl: "", |
| #166 | immediatelyAvailable: "", |
| #167 | preferredDuration: "", |
| #168 | availabilityType: [], |
| #169 | preferredCountry: "", |
| #170 | preferredCity: "", |
| #171 | openToRemote: "", |
| #172 | willingToRelocate: "", |
| #173 | studentIdFile: null, |
| #174 | universityEmail: "", |
| #175 | nationalIdFile: null, |
| #176 | acceptTerms: false, |
| #177 | }; |
| #178 | |
| #179 | const STEP_TITLES = [ |
| #180 | "Personal Details", |
| #181 | "Education & Skills", |
| #182 | "Experience & Interests", |
| #183 | "Documents", |
| #184 | "Preferences & Verification", |
| #185 | ]; |
| #186 | |
| #187 | // ---- Helper components ---- |
| #188 | |
| #189 | function FormField({ |
| #190 | label, |
| #191 | required, |
| #192 | children, |
| #193 | hint, |
| #194 | }: { |
| #195 | label: string; |
| #196 | required?: boolean; |
| #197 | children: React.ReactNode; |
| #198 | hint?: string; |
| #199 | }) { |
| #200 | return ( |
| #201 | <div className="reg-field"> |
| #202 | <label className="reg-label"> |
| #203 | {label} |
| #204 | {required && <span className="reg-required">*</span>} |
| #205 | </label> |
| #206 | {children} |
| #207 | {hint && <span className="reg-hint">{hint}</span>} |
| #208 | </div> |
| #209 | ); |
| #210 | } |
| #211 | |
| #212 | function TextInput({ |
| #213 | value, |
| #214 | onChange, |
| #215 | placeholder, |
| #216 | type = "text", |
| #217 | }: { |
| #218 | value: string; |
| #219 | onChange: (v: string) => void; |
| #220 | placeholder?: string; |
| #221 | type?: string; |
| #222 | }) { |
| #223 | return ( |
| #224 | <input |
| #225 | type={type} |
| #226 | className="reg-input" |
| #227 | value={value} |
| #228 | onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} |
| #229 | placeholder={placeholder} |
| #230 | /> |
| #231 | ); |
| #232 | } |
| #233 | |
| #234 | function SelectInput({ |
| #235 | value, |
| #236 | onChange, |
| #237 | options, |
| #238 | placeholder, |
| #239 | }: { |
| #240 | value: string; |
| #241 | onChange: (v: string) => void; |
| #242 | options: string[]; |
| #243 | placeholder?: string; |
| #244 | }) { |
| #245 | return ( |
| #246 | <select |
| #247 | className="reg-input" |
| #248 | value={value} |
| #249 | onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)} |
| #250 | > |
| #251 | <option value="">{placeholder || "Select..."}</option> |
| #252 | {options.map((o) => ( |
| #253 | <option key={o} value={o}>{o}</option> |
| #254 | ))} |
| #255 | </select> |
| #256 | ); |
| #257 | } |
| #258 | |
| #259 | function TextareaInput({ |
| #260 | value, |
| #261 | onChange, |
| #262 | placeholder, |
| #263 | rows = 3, |
| #264 | }: { |
| #265 | value: string; |
| #266 | onChange: (v: string) => void; |
| #267 | placeholder?: string; |
| #268 | rows?: number; |
| #269 | }) { |
| #270 | return ( |
| #271 | <textarea |
| #272 | className="reg-input reg-textarea" |
| #273 | value={value} |
| #274 | onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} |
| #275 | placeholder={placeholder} |
| #276 | rows={rows} |
| #277 | /> |
| #278 | ); |
| #279 | } |
| #280 | |
| #281 | function ChipSelect({ |
| #282 | options, |
| #283 | selected, |
| #284 | onToggle, |
| #285 | }: { |
| #286 | options: string[]; |
| #287 | selected: string[]; |
| #288 | onToggle: (item: string) => void; |
| #289 | }) { |
| #290 | return ( |
| #291 | <div className="reg-chips"> |
| #292 | {options.map((opt) => ( |
| #293 | <button |
| #294 | key={opt} |
| #295 | type="button" |
| #296 | className={`reg-chip ${selected.includes(opt) ? "reg-chip-active" : ""}`} |
| #297 | onClick={() => onToggle(opt)} |
| #298 | > |
| #299 | {selected.includes(opt) && <span className="reg-chip-check">✓</span>} |
| #300 | {opt} |
| #301 | </button> |
| #302 | ))} |
| #303 | </div> |
| #304 | ); |
| #305 | } |
| #306 | |
| #307 | function FileUpload({ |
| #308 | file, |
| #309 | onFileChange, |
| #310 | accept, |
| #311 | }: { |
| #312 | file: File | null; |
| #313 | onFileChange: (f: File | null) => void; |
| #314 | accept?: string; |
| #315 | }) { |
| #316 | return ( |
| #317 | <div className="reg-file-upload"> |
| #318 | <label className="reg-file-label"> |
| #319 | <input |
| #320 | type="file" |
| #321 | accept={accept} |
| #322 | onChange={(e: ChangeEvent<HTMLInputElement>) => |
| #323 | onFileChange(e.target.files?.[0] || null) |
| #324 | } |
| #325 | className="reg-file-input" |
| #326 | /> |
| #327 | <span className="reg-file-btn"> |
| #328 | {file ? "Change File" : "Choose File"} |
| #329 | </span> |
| #330 | <span className="reg-file-name"> |
| #331 | {file ? file.name : "No file chosen"} |
| #332 | </span> |
| #333 | </label> |
| #334 | </div> |
| #335 | ); |
| #336 | } |
| #337 | |
| #338 | // ---- Step renderers ---- |
| #339 | |
| #340 | function Step1({ |
| #341 | form, |
| #342 | setField, |
| #343 | }: { |
| #344 | form: FormData; |
| #345 | setField: <K extends keyof FormData>(key: K, val: FormData[K]) => void; |
| #346 | }) { |
| #347 | return ( |
| #348 | <div className="reg-step-content"> |
| #349 | <div className="reg-step-intro"> |
| #350 | <h3>Let's start with the basics</h3> |
| #351 | <p>Tell us about yourself so organisations can get to know you.</p> |
| #352 | </div> |
| #353 | <div className="reg-grid-2"> |
| #354 | <FormField label="Full Name" required> |
| #355 | <TextInput |
| #356 | value={form.fullName} |
| #357 | onChange={(v) => setField("fullName", v)} |
| #358 | placeholder="e.g. Amara Okafor" |
| #359 | /> |
| #360 | </FormField> |
| #361 | <FormField label="Gender"> |
| #362 | <SelectInput |
| #363 | value={form.gender} |
| #364 | onChange={(v) => setField("gender", v)} |
| #365 | options={GENDER_OPTIONS} |
| #366 | placeholder="Select gender..." |
| #367 | /> |
| #368 | </FormField> |
| #369 | <FormField label="Date of Birth" required> |
| #370 | <TextInput |
| #371 | value={form.dateOfBirth} |
| #372 | onChange={(v) => setField("dateOfBirth", v)} |
| #373 | type="date" |
| #374 | /> |
| #375 | </FormField> |
| #376 | <FormField label="Nationality" required> |
| #377 | <SelectInput |
| #378 | value={form.nationality} |
| #379 | onChange={(v) => setField("nationality", v)} |
| #380 | options={NATIONALITY_OPTIONS} |
| #381 | placeholder="Select nationality..." |
| #382 | /> |
| #383 | </FormField> |
| #384 | <FormField label="Country of Residence" required> |
| #385 | <SelectInput |
| #386 | value={form.countryOfResidence} |
| #387 | onChange={(v) => setField("countryOfResidence", v)} |
| #388 | options={COUNTRY_OPTIONS} |
| #389 | placeholder="Select country..." |
| #390 | /> |
| #391 | </FormField> |
| #392 | <FormField label="City / Region" required> |
| #393 | <TextInput |
| #394 | value={form.city} |
| #395 | onChange={(v) => setField("city", v)} |
| #396 | placeholder="e.g. Nairobi" |
| #397 | /> |
| #398 | </FormField> |
| #399 | <FormField label="Phone Number" required> |
| #400 | <TextInput |
| #401 | value={form.phone} |
| #402 | onChange={(v) => setField("phone", v)} |
| #403 | placeholder="+254 700 000 000" |
| #404 | type="tel" |
| #405 | /> |
| #406 | </FormField> |
| #407 | <FormField label="Email Address" required> |
| #408 | <TextInput |
| #409 | value={form.email} |
| #410 | onChange={(v) => setField("email", v)} |
| #411 | placeholder="you@example.com" |
| #412 | type="email" |
| #413 | /> |
| #414 | </FormField> |
| #415 | <FormField label="Password" required> |
| #416 | <TextInput |
| #417 | value={form.password} |
| #418 | onChange={(v) => setField("password", v)} |
| #419 | placeholder="Min. 8 characters" |
| #420 | type="password" |
| #421 | /> |
| #422 | </FormField> |
| #423 | <FormField label="Confirm Password" required> |
| #424 | <TextInput |
| #425 | value={form.confirmPassword} |
| #426 | onChange={(v) => setField("confirmPassword", v)} |
| #427 | placeholder="Re-enter password" |
| #428 | type="password" |
| #429 | /> |
| #430 | </FormField> |
| #431 | </div> |
| #432 | </div> |
| #433 | ); |
| #434 | } |
| #435 | |
| #436 | function Step2({ |
| #437 | form, |
| #438 | setField, |
| #439 | toggleChip, |
| #440 | }: { |
| #441 | form: FormData; |
| #442 | setField: <K extends keyof FormData>(key: K, val: FormData[K]) => void; |
| #443 | toggleChip: (key: "skills" | "languages", item: string) => void; |
| #444 | }) { |
| #445 | return ( |
| #446 | <div className="reg-step-content"> |
| #447 | <div className="reg-step-intro"> |
| #448 | <h3>Your education & skills</h3> |
| #449 | <p>Help organisations understand your academic background and capabilities.</p> |
| #450 | </div> |
| #451 | |
| #452 | <div className="reg-grid-2"> |
| #453 | <FormField label="University / College" required> |
| #454 | <TextInput |
| #455 | value={form.university} |
| #456 | onChange={(v) => setField("university", v)} |
| #457 | placeholder="e.g. University of Lagos" |
| #458 | /> |
| #459 | </FormField> |
| #460 | <FormField label="Course / Programme" required> |
| #461 | <TextInput |
| #462 | value={form.programme} |
| #463 | onChange={(v) => setField("programme", v)} |
| #464 | placeholder="e.g. Computer Science" |
| #465 | /> |
| #466 | </FormField> |
| #467 | <FormField label="Level of Study" required> |
| #468 | <SelectInput |
| #469 | value={form.levelOfStudy} |
| #470 | onChange={(v) => setField("levelOfStudy", v)} |
| #471 | options={LEVEL_OPTIONS} |
| #472 | /> |
| #473 | </FormField> |
| #474 | <FormField label="Year of Study" required> |
| #475 | <SelectInput |
| #476 | value={form.yearOfStudy} |
| #477 | onChange={(v) => setField("yearOfStudy", v)} |
| #478 | options={YEAR_OF_STUDY_OPTIONS} |
| #479 | /> |
| #480 | </FormField> |
| #481 | <FormField label="Graduation Year" required> |
| #482 | <TextInput |
| #483 | value={form.graduationYear} |
| #484 | onChange={(v) => setField("graduationYear", v)} |
| #485 | placeholder="e.g. 2027" |
| #486 | /> |
| #487 | </FormField> |
| #488 | <FormField label="Academic Specialisation"> |
| #489 | <TextInput |
| #490 | value={form.specialisation} |
| #491 | onChange={(v) => setField("specialisation", v)} |
| #492 | placeholder="e.g. Software Engineering" |
| #493 | /> |
| #494 | </FormField> |
| #495 | </div> |
| #496 | |
| #497 | <FormField label="GPA / Grade (optional)"> |
| #498 | <TextInput |
| #499 | value={form.gpa} |
| #500 | onChange={(v) => setField("gpa", v)} |
| #501 | placeholder="e.g. 3.5/4.0 or First Class" |
| #502 | /> |
| #503 | </FormField> |
| #504 | |
| #505 | <FormField label="Skills" required> |
| #506 | <ChipSelect |
| #507 | options={SKILLS_OPTIONS} |
| #508 | selected={form.skills} |
| #509 | onToggle={(item) => toggleChip("skills", item)} |
| #510 | /> |
| #511 | <div className="reg-custom-skill"> |
| #512 | <TextInput |
| #513 | value={form.customSkill} |
| #514 | onChange={(v) => setField("customSkill", v)} |
| #515 | placeholder="Add a custom skill..." |
| #516 | /> |
| #517 | <button |
| #518 | type="button" |
| #519 | className="reg-chip reg-chip-add" |
| #520 | onClick={() => { |
| #521 | if (form.customSkill.trim() && !form.skills.includes(form.customSkill.trim())) { |
| #522 | toggleChip("skills", form.customSkill.trim()); |
| #523 | setField("customSkill", ""); |
| #524 | } |
| #525 | }} |
| #526 | > |
| #527 | + Add |
| #528 | </button> |
| #529 | </div> |
| #530 | </FormField> |
| #531 | |
| #532 | <FormField label="Languages Spoken" required> |
| #533 | <ChipSelect |
| #534 | options={LANGUAGE_OPTIONS} |
| #535 | selected={form.languages} |
| #536 | onToggle={(item) => toggleChip("languages", item)} |
| #537 | /> |
| #538 | </FormField> |
| #539 | |
| #540 | <FormField label="Digital Skills Level" required> |
| #541 | <SelectInput |
| #542 | value={form.digitalSkillsLevel} |
| #543 | onChange={(v) => setField("digitalSkillsLevel", v)} |
| #544 | options={DIGITAL_SKILLS_LEVEL} |
| #545 | /> |
| #546 | </FormField> |
| #547 | </div> |
| #548 | ); |
| #549 | } |
| #550 | |
| #551 | function Step3({ |
| #552 | form, |
| #553 | setField, |
| #554 | toggleChip, |
| #555 | }: { |
| #556 | form: FormData; |
| #557 | setField: <K extends keyof FormData>(key: K, val: FormData[K]) => void; |
| #558 | toggleChip: ( |
| #559 | key: "opportunityTypes" | "careerFields" | "experience", |
| #560 | item: string, |
| #561 | ) => void; |
| #562 | }) { |
| #563 | return ( |
| #564 | <div className="reg-step-content"> |
| #565 | <div className="reg-step-intro"> |
| #566 | <h3>What are you looking for?</h3> |
| #567 | <p>Tell us about your interests and any experience you have — even small experiences count.</p> |
| #568 | </div> |
| #569 | |
| #570 | <FormField label="What type of opportunities are you interested in?" required> |
| #571 | <ChipSelect |
| #572 | options={OPPORTUNITY_TYPES} |
| #573 | selected={form.opportunityTypes} |
| #574 | onToggle={(item) => toggleChip("opportunityTypes", item)} |
| #575 | /> |
| #576 | </FormField> |
| #577 | |
| #578 | <FormField label="Fields of Interest" required> |
| #579 | <ChipSelect |
| #580 | options={CAREER_FIELDS} |
| #581 | selected={form.careerFields} |
| #582 | onToggle={(item) => toggleChip("careerFields", item)} |
| #583 | /> |
| #584 | </FormField> |
| #585 | |
| #586 | <FormField label="Experience (select all that apply)"> |
| #587 | <ChipSelect |
| #588 | options={EXPERIENCE_OPTIONS} |
| #589 | selected={form.experience} |
| #590 | onToggle={(item) => toggleChip("experience", item)} |
| #591 | /> |
| #592 | </FormField> |
| #593 | |
| #594 | <FormField label="Briefly describe your experience (optional)"> |
| #595 | <TextareaInput |
| #596 | value={form.experienceDescription} |
| #597 | onChange={(v) => setField("experienceDescription", v)} |
| #598 | placeholder="Describe any relevant experience, projects, or achievements..." |
| #599 | rows={4} |
| #600 | /> |
| #601 | </FormField> |
| #602 | </div> |
| #603 | ); |
| #604 | } |
| #605 | |
| #606 | function Step4({ |
| #607 | form, |
| #608 | setField, |
| #609 | }: { |
| #610 | form: FormData; |
| #611 | setField: <K extends keyof FormData>(key: K, val: FormData[K]) => void; |
| #612 | }) { |
| #613 | return ( |
| #614 | <div className="reg-step-content"> |
| #615 | <div className="reg-step-intro"> |
| #616 | <h3>Upload your documents</h3> |
| #617 | <p>Having your documents ready helps organisations evaluate your application faster.</p> |
| #618 | </div> |
| #619 | |
| #620 | <FormField label="CV / Resume" required hint="PDF or DOC, max 5MB"> |
| #621 | <FileUpload |
| #622 | file={form.cvFile} |
| #623 | onFileChange={(f) => setField("cvFile", f)} |
| #624 | accept=".pdf,.doc,.docx" |
| #625 | /> |
| #626 | </FormField> |
| #627 | |
| #628 | <FormField label="Cover Letter" hint="Optional. PDF or DOC, max 5MB"> |
| #629 | <FileUpload |
| #630 | file={form.coverLetterFile} |
| #631 | onFileChange={(f) => setField("coverLetterFile", f)} |
| #632 | accept=".pdf,.doc,.docx" |
| #633 | /> |
| #634 | </FormField> |
| #635 | |
| #636 | <FormField label="Academic Transcript" hint="Optional. PDF or image, max 5MB"> |
| #637 | <FileUpload |
| #638 | file={form.transcriptFile} |
| #639 | onFileChange={(f) => setField("transcriptFile", f)} |
| #640 | accept=".pdf,.jpg,.jpeg,.png" |
| #641 | /> |
| #642 | </FormField> |
| #643 | |
| #644 | <FormField label="Portfolio / Work Samples" hint="Optional. PDF, image, or ZIP, max 10MB"> |
| #645 | <FileUpload |
| #646 | file={form.portfolioFile} |
| #647 | onFileChange={(f) => setField("portfolioFile", f)} |
| #648 | accept=".pdf,.jpg,.jpeg,.png,.zip" |
| #649 | /> |
| #650 | </FormField> |
| #651 | |
| #652 | <FormField label="LinkedIn Profile URL" hint="Optional"> |
| #653 | <TextInput |
| #654 | value={form.linkedinUrl} |
| #655 | onChange={(v) => setField("linkedinUrl", v)} |
| #656 | placeholder="https://linkedin.com/in/yourprofile" |
| #657 | /> |
| #658 | </FormField> |
| #659 | </div> |
| #660 | ); |
| #661 | } |
| #662 | |
| #663 | function Step5({ |
| #664 | form, |
| #665 | setField, |
| #666 | toggleChip, |
| #667 | }: { |
| #668 | form: FormData; |
| #669 | setField: <K extends keyof FormData>(key: K, val: FormData[K]) => void; |
| #670 | toggleChip: (key: "availabilityType", item: string) => void; |
| #671 | }) { |
| #672 | return ( |
| #673 | <div className="reg-step-content"> |
| #674 | <div className="reg-step-intro"> |
| #675 | <h3>Preferences & verification</h3> |
| #676 | <p>Help us match you with the right opportunities and verify your identity.</p> |
| #677 | </div> |
| #678 | |
| #679 | <h4 className="reg-sub-heading">Availability</h4> |
| #680 | <div className="reg-grid-2"> |
| #681 | <FormField label="Immediately available?" required> |
| #682 | <SelectInput |
| #683 | value={form.immediatelyAvailable} |
| #684 | onChange={(v) => setField("immediatelyAvailable", v)} |
| #685 | options={["Yes", "No"]} |
| #686 | /> |
| #687 | </FormField> |
| #688 | <FormField label="Preferred Duration"> |
| #689 | <SelectInput |
| #690 | value={form.preferredDuration} |
| #691 | onChange={(v) => setField("preferredDuration", v)} |
| #692 | options={DURATION_OPTIONS} |
| #693 | /> |
| #694 | </FormField> |
| #695 | </div> |
| #696 | |
| #697 | <FormField label="Availability Type" required> |
| #698 | <ChipSelect |
| #699 | options={AVAILABILITY_OPTIONS} |
| #700 | selected={form.availabilityType} |
| #701 | onToggle={(item) => toggleChip("availabilityType", item)} |
| #702 | /> |
| #703 | </FormField> |
| #704 | |
| #705 | <h4 className="reg-sub-heading">Location Preferences</h4> |
| #706 | <div className="reg-grid-2"> |
| #707 | <FormField label="Preferred Country"> |
| #708 | <SelectInput |
| #709 | value={form.preferredCountry} |
| #710 | onChange={(v) => setField("preferredCountry", v)} |
| #711 | options={COUNTRY_OPTIONS} |
| #712 | placeholder="Any country..." |
| #713 | /> |
| #714 | </FormField> |
| #715 | <FormField label="Preferred City / Region"> |
| #716 | <TextInput |
| #717 | value={form.preferredCity} |
| #718 | onChange={(v) => setField("preferredCity", v)} |
| #719 | placeholder="e.g. Lagos, Nairobi..." |
| #720 | /> |
| #721 | </FormField> |
| #722 | <FormField label="Open to remote opportunities?"> |
| #723 | <SelectInput |
| #724 | value={form.openToRemote} |
| #725 | onChange={(v) => setField("openToRemote", v)} |
| #726 | options={["Yes", "No", "Preferred"]} |
| #727 | /> |
| #728 | </FormField> |
| #729 | <FormField label="Willing to relocate?"> |
| #730 | <SelectInput |
| #731 | value={form.willingToRelocate} |
| #732 | onChange={(v) => setField("willingToRelocate", v)} |
| #733 | options={["Yes", "No", "Depends on opportunity"]} |
| #734 | /> |
| #735 | </FormField> |
| #736 | </div> |
| #737 | |
| #738 | <h4 className="reg-sub-heading">Verification</h4> |
| #739 | <FormField label="Student ID" hint="Upload a photo or scan of your student ID"> |
| #740 | <FileUpload |
| #741 | file={form.studentIdFile} |
| #742 | onFileChange={(f) => setField("studentIdFile", f)} |
| #743 | accept=".pdf,.jpg,.jpeg,.png" |
| #744 | /> |
| #745 | </FormField> |
| #746 | |
| #747 | <FormField label="University Email" hint="We'll send a verification link"> |
| #748 | <TextInput |
| #749 | value={form.universityEmail} |
| #750 | onChange={(v) => setField("universityEmail", v)} |
| #751 | placeholder="you@university.edu" |
| #752 | type="email" |
| #753 | /> |
| #754 | </FormField> |
| #755 | |
| #756 | <FormField label="National ID / Passport" hint="Optional. For identity verification"> |
| #757 | <FileUpload |
| #758 | file={form.nationalIdFile} |
| #759 | onFileChange={(f) => setField("nationalIdFile", f)} |
| #760 | accept=".pdf,.jpg,.jpeg,.png" |
| #761 | /> |
| #762 | </FormField> |
| #763 | |
| #764 | <div style={{ marginTop: "1.5rem", padding: "1rem", background: "#f9fafb", borderRadius: "0.75rem", border: "1px solid #e5e7eb" }}> |
| #765 | <label style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", cursor: "pointer" }}> |
| #766 | <input |
| #767 | type="checkbox" |
| #768 | checked={form.acceptTerms} |
| #769 | onChange={(e) => setField("acceptTerms", e.target.checked)} |
| #770 | style={{ marginTop: "0.25rem", width: "1.1rem", height: "1.1rem", accentColor: "var(--green-600)" }} |
| #771 | /> |
| #772 | <span style={{ fontSize: "0.9rem", lineHeight: 1.5, color: "var(--gray-700)" }}> |
| #773 | I confirm that the information provided is accurate. I agree to the{" "} |
| #774 | <a href="#" onClick={(e) => { e.preventDefault(); window.open(window.location.origin + "?page=terms", "_blank"); }} style={{ color: "var(--green-700)", fontWeight: 600 }}>Terms & Conditions</a>{" "} |
| #775 | and{" "} |
| #776 | <a href="#" onClick={(e) => { e.preventDefault(); window.open(window.location.origin + "?page=privacy", "_blank"); }} style={{ color: "var(--green-700)", fontWeight: 600 }}>Privacy Policy</a>{" "} |
| #777 | of FursaLink Africa. |
| #778 | </span> |
| #779 | </label> |
| #780 | </div> |
| #781 | </div> |
| #782 | ); |
| #783 | } |
| #784 | |
| #785 | // ---- Main component ---- |
| #786 | |
| #787 | interface RegistrationPageProps { |
| #788 | onBack: () => void; |
| #789 | } |
| #790 | |
| #791 | export default function RegistrationPage({ onBack }: RegistrationPageProps) { |
| #792 | const [step, setStep] = useState(1); |
| #793 | const [form, setForm] = useState<FormData>({ ...INITIAL_FORM }); |
| #794 | const [submitted, setSubmitted] = useState(false); |
| #795 | const [, setSubmitting] = useState(false); |
| #796 | const [submitError, setSubmitError] = useState(""); |
| #797 | |
| #798 | const totalSteps = 5; |
| #799 | |
| #800 | const setField = <K extends keyof FormData>(key: K, val: FormData[K]) => { |
| #801 | setForm((prev) => ({ ...prev, [key]: val })); |
| #802 | }; |
| #803 | |
| #804 | const toggleChip = ( |
| #805 | key: "skills" | "languages" | "opportunityTypes" | "careerFields" | "experience" | "availabilityType", |
| #806 | item: string, |
| #807 | ) => { |
| #808 | setForm((prev) => { |
| #809 | const arr = prev[key] as string[]; |
| #810 | return { |
| #811 | ...prev, |
| #812 | [key]: arr.includes(item) ? arr.filter((x) => x !== item) : [...arr, item], |
| #813 | }; |
| #814 | }); |
| #815 | }; |
| #816 | |
| #817 | const canProceed = (): boolean => { |
| #818 | if (step === 1) { |
| #819 | return !!( |
| #820 | form.fullName && |
| #821 | form.dateOfBirth && |
| #822 | form.nationality && |
| #823 | form.countryOfResidence && |
| #824 | form.city && |
| #825 | form.phone && |
| #826 | form.email && |
| #827 | form.password && |
| #828 | form.confirmPassword && |
| #829 | form.password === form.confirmPassword && |
| #830 | form.password.length >= 8 |
| #831 | ); |
| #832 | } |
| #833 | if (step === 2) { |
| #834 | return !!( |
| #835 | form.university && |
| #836 | form.programme && |
| #837 | form.levelOfStudy && |
| #838 | form.yearOfStudy && |
| #839 | form.graduationYear && |
| #840 | form.skills.length > 0 && |
| #841 | form.languages.length > 0 && |
| #842 | form.digitalSkillsLevel |
| #843 | ); |
| #844 | } |
| #845 | if (step === 3) { |
| #846 | return form.opportunityTypes.length > 0 && form.careerFields.length > 0; |
| #847 | } |
| #848 | if (step === 4) { |
| #849 | return !!form.cvFile; |
| #850 | } |
| #851 | if (step === 5) { |
| #852 | return !!( |
| #853 | form.immediatelyAvailable && |
| #854 | form.availabilityType.length > 0 && |
| #855 | form.acceptTerms |
| #856 | ); |
| #857 | } |
| #858 | return true; |
| #859 | }; |
| #860 | |
| #861 | const handleNext = () => { |
| #862 | if (step < totalSteps) setStep(step + 1); |
| #863 | }; |
| #864 | |
| #865 | const handlePrev = () => { |
| #866 | if (step > 1) setStep(step - 1); |
| #867 | }; |
| #868 | |
| #869 | const handleSubmit = async (e: FormEvent) => { |
| #870 | e.preventDefault(); |
| #871 | setSubmitting(true); |
| #872 | setSubmitError(""); |
| #873 | |
| #874 | try { |
| #875 | // 1. Create auth user |
| #876 | let userId: string | undefined; |
| #877 | const { data: authData, error: authError } = await supabase.auth.signUp({ |
| #878 | email: form.email, |
| #879 | password: form.password, |
| #880 | options: { |
| #881 | emailRedirectTo: window.location.origin, |
| #882 | }, |
| #883 | }); |
| #884 | |
| #885 | if (authError) { |
| #886 | // If rate limited or user already exists, try signing in instead |
| #887 | if ( |
| #888 | authError.message.includes("rate limit") || |
| #889 | authError.message.includes("already registered") || |
| #890 | authError.message.includes("User already registered") |
| #891 | ) { |
| #892 | const { data: signInData, error: signInError } = |
| #893 | await supabase.auth.signInWithPassword({ |
| #894 | email: form.email, |
| #895 | password: form.password, |
| #896 | }); |
| #897 | if (signInError) { |
| #898 | throw new Error( |
| #899 | "This email is already registered. Please sign in instead, or use a different email." |
| #900 | ); |
| #901 | } |
| #902 | userId = signInData.user?.id; |
| #903 | } else { |
| #904 | throw authError; |
| #905 | } |
| #906 | } else { |
| #907 | userId = authData.user?.id; |
| #908 | } |
| #909 | |
| #910 | if (!userId) throw new Error("User creation failed — no ID returned."); |
| #911 | |
| #912 | // 2. Insert profile into youth_profiles table (upsert to handle re-registration) |
| #913 | const { error: profileError } = await supabase.from("youth_profiles").upsert( |
| #914 | { |
| #915 | user_id: userId, |
| #916 | full_name: form.fullName, |
| #917 | gender: form.gender, |
| #918 | date_of_birth: form.dateOfBirth, |
| #919 | nationality: form.nationality, |
| #920 | country_of_residence: form.countryOfResidence, |
| #921 | city: form.city, |
| #922 | phone: form.phone, |
| #923 | email: form.email, |
| #924 | university: form.university, |
| #925 | programme: form.programme, |
| #926 | level_of_study: form.levelOfStudy, |
| #927 | year_of_study: form.yearOfStudy, |
| #928 | graduation_year: form.graduationYear, |
| #929 | specialisation: form.specialisation, |
| #930 | gpa: form.gpa, |
| #931 | skills: form.skills, |
| #932 | languages: form.languages, |
| #933 | digital_skills_level: form.digitalSkillsLevel, |
| #934 | opportunity_types: form.opportunityTypes, |
| #935 | career_fields: form.careerFields, |
| #936 | experience: form.experience, |
| #937 | experience_description: form.experienceDescription, |
| #938 | linkedin_url: form.linkedinUrl, |
| #939 | immediately_available: form.immediatelyAvailable, |
| #940 | preferred_duration: form.preferredDuration, |
| #941 | availability_type: form.availabilityType, |
| #942 | preferred_country: form.preferredCountry, |
| #943 | preferred_city: form.preferredCity, |
| #944 | open_to_remote: form.openToRemote, |
| #945 | willing_to_relocate: form.willingToRelocate, |
| #946 | university_email: form.universityEmail, |
| #947 | }, |
| #948 | { onConflict: "user_id" } |
| #949 | ); |
| #950 | |
| #951 | if (profileError) throw profileError; |
| #952 | |
| #953 | // 3. Insert into youth_registrations summary table |
| #954 | const { error: regError } = await supabase.from("youth_registrations").insert({ |
| #955 | full_name: form.fullName, |
| #956 | email: form.email, |
| #957 | country: form.countryOfResidence, |
| #958 | university: form.university, |
| #959 | programme: form.programme, |
| #960 | }); |
| #961 | |
| #962 | if (regError) throw regError; |
| #963 | |
| #964 | setSubmitted(true); |
| #965 | } catch (err: unknown) { |
| #966 | const message = err instanceof Error ? err.message : (typeof err === 'object' ? JSON.stringify(err) : "Registration failed. Please try again."); |
| #967 | console.error("Registration error:", err); |
| #968 | setSubmitError(message); |
| #969 | } finally { |
| #970 | setSubmitting(false); |
| #971 | } |
| #972 | }; |
| #973 | |
| #974 | if (submitted) { |
| #975 | return ( |
| #976 | <div className="reg-page"> |
| #977 | <nav className="nav"> |
| #978 | <div className="nav-inner"> |
| #979 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #980 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #981 | <span className="logo-text"> |
| #982 | Fursa<span className="logo-highlight">Link</span> |
| #983 | </span> |
| #984 | </a> |
| #985 | </div> |
| #986 | </nav> |
| #987 | <div className="reg-success"> |
| #988 | <div className="reg-success-card"> |
| #989 | <div className="reg-success-icon">🎉</div> |
| #990 | <h2>Registration Complete!</h2> |
| #991 | <p> |
| #992 | Welcome to FursaLink Africa, <strong>{form.fullName}</strong>! Your profile |
| #993 | has been created successfully. |
| #994 | </p> |
| #995 | <p className="reg-success-detail"> |
| #996 | We've sent a verification email to <strong>{form.email}</strong>. Please |
| #997 | check your inbox and verify your email to activate your account. |
| #998 | </p> |
| #999 | <div className="reg-success-next"> |
| #1000 | <h4>What's next?</h4> |
| #1001 | <ul> |
| #1002 | <li>Verify your email address</li> |
| #1003 | <li>Complete your profile for better matches</li> |
| #1004 | <li>Browse and apply to opportunities</li> |
| #1005 | <li>Set up notification preferences</li> |
| #1006 | </ul> |
| #1007 | </div> |
| #1008 | <button |
| #1009 | type="button" |
| #1010 | className="btn btn-primary btn-lg" |
| #1011 | onClick={onBack} |
| #1012 | > |
| #1013 | Back to Home |
| #1014 | </button> |
| #1015 | </div> |
| #1016 | </div> |
| #1017 | </div> |
| #1018 | ); |
| #1019 | } |
| #1020 | |
| #1021 | return ( |
| #1022 | <div className="reg-page"> |
| #1023 | {/* Nav */} |
| #1024 | <nav className="nav"> |
| #1025 | <div className="nav-inner"> |
| #1026 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #1027 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #1028 | <span className="logo-text"> |
| #1029 | Fursa<span className="logo-highlight">Link</span> |
| #1030 | </span> |
| #1031 | </a> |
| #1032 | <div className="nav-actions"> |
| #1033 | <button type="button" className="btn btn-outline btn-sm" onClick={onBack}> |
| #1034 | ← Back to Home |
| #1035 | </button> |
| #1036 | </div> |
| #1037 | </div> |
| #1038 | </nav> |
| #1039 | |
| #1040 | <div className="reg-container"> |
| #1041 | {/* Sidebar progress */} |
| #1042 | <aside className="reg-sidebar"> |
| #1043 | <div className="reg-sidebar-inner"> |
| #1044 | <h2 className="reg-sidebar-title">Youth Registration</h2> |
| #1045 | <p className="reg-sidebar-subtitle">Complete all steps to create your profile</p> |
| #1046 | <div className="reg-progress-steps"> |
| #1047 | {STEP_TITLES.map((title, i) => { |
| #1048 | const stepNum = i + 1; |
| #1049 | const isActive = step === stepNum; |
| #1050 | const isCompleted = step > stepNum; |
| #1051 | return ( |
| #1052 | <div |
| #1053 | key={title} |
| #1054 | className={`reg-progress-step ${isActive ? "reg-progress-active" : ""} ${isCompleted ? "reg-progress-done" : ""}`} |
| #1055 | > |
| #1056 | <div className="reg-progress-marker"> |
| #1057 | {isCompleted ? "✓" : stepNum} |
| #1058 | </div> |
| #1059 | <div className="reg-progress-info"> |
| #1060 | <span className="reg-progress-label">Step {stepNum}</span> |
| #1061 | <span className="reg-progress-title">{title}</span> |
| #1062 | </div> |
| #1063 | </div> |
| #1064 | ); |
| #1065 | })} |
| #1066 | </div> |
| #1067 | <div className="reg-progress-bar-container"> |
| #1068 | <div |
| #1069 | className="reg-progress-bar" |
| #1070 | style={{ width: `${(step / totalSteps) * 100}%` }} |
| #1071 | /> |
| #1072 | </div> |
| #1073 | <p className="reg-progress-pct"> |
| #1074 | {Math.round((step / totalSteps) * 100)}% complete |
| #1075 | </p> |
| #1076 | </div> |
| #1077 | </aside> |
| #1078 | |
| #1079 | {/* Form area */} |
| #1080 | <main className="reg-main"> |
| #1081 | <form onSubmit={handleSubmit} className="reg-form"> |
| #1082 | <div className="reg-form-header"> |
| #1083 | <span className="reg-step-badge"> |
| #1084 | Step {step} of {totalSteps} |
| #1085 | </span> |
| #1086 | <h2 className="reg-form-title">{STEP_TITLES[step - 1]}</h2> |
| #1087 | </div> |
| #1088 | |
| #1089 | {step === 1 && <Step1 form={form} setField={setField} />} |
| #1090 | {step === 2 && ( |
| #1091 | <Step2 form={form} setField={setField} toggleChip={toggleChip} /> |
| #1092 | )} |
| #1093 | {step === 3 && ( |
| #1094 | <Step3 form={form} setField={setField} toggleChip={toggleChip} /> |
| #1095 | )} |
| #1096 | {step === 4 && <Step4 form={form} setField={setField} />} |
| #1097 | {step === 5 && ( |
| #1098 | <Step5 form={form} setField={setField} toggleChip={toggleChip} /> |
| #1099 | )} |
| #1100 | |
| #1101 | {/* Submit error */} |
| #1102 | {submitError && <p className="reg-validation-hint" style={{ color: "#e74c3c" }}>{submitError}</p>} |
| #1103 | |
| #1104 | {/* Validation hints */} |
| #1105 | {step === 1 && form.password && form.password.length < 8 && ( |
| #1106 | <p className="reg-validation-hint">Password must be at least 8 characters.</p> |
| #1107 | )} |
| #1108 | {step === 1 && form.confirmPassword && form.password !== form.confirmPassword && ( |
| #1109 | <p className="reg-validation-hint">Passwords do not match.</p> |
| #1110 | )} |
| #1111 | |
| #1112 | {/* Nav buttons */} |
| #1113 | <div className="reg-form-actions"> |
| #1114 | {step > 1 ? ( |
| #1115 | <button type="button" className="btn btn-outline" onClick={handlePrev}> |
| #1116 | ← Previous |
| #1117 | </button> |
| #1118 | ) : ( |
| #1119 | <div /> |
| #1120 | )} |
| #1121 | {step < totalSteps ? ( |
| #1122 | <button |
| #1123 | type="button" |
| #1124 | className="btn btn-primary" |
| #1125 | onClick={handleNext} |
| #1126 | disabled={!canProceed()} |
| #1127 | > |
| #1128 | Continue → |
| #1129 | </button> |
| #1130 | ) : ( |
| #1131 | <button |
| #1132 | type="submit" |
| #1133 | className="btn btn-primary" |
| #1134 | disabled={!canProceed()} |
| #1135 | > |
| #1136 | Complete Registration ✓ |
| #1137 | </button> |
| #1138 | )} |
| #1139 | </div> |
| #1140 | </form> |
| #1141 | </main> |
| #1142 | </div> |
| #1143 | </div> |
| #1144 | ); |
| #1145 | } |
| #1146 |