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 | // ---- Constants ---- |
| #5 | |
| #6 | const ORG_TYPE_OPTIONS = [ |
| #7 | "NGO", "Company", "Startup", "Government Institution", |
| #8 | "School/University", "Hospital", "Research Institution", "Social Enterprise", |
| #9 | ]; |
| #10 | |
| #11 | const SECTOR_OPTIONS = [ |
| #12 | "Agriculture", "Technology", "Health", "Education", "Climate & Environment", |
| #13 | "Finance", "Media & Communications", "Engineering", "Development", |
| #14 | "Humanitarian Work", "Law & Justice", "Energy", "Manufacturing", |
| #15 | "Tourism & Hospitality", "Arts & Culture", "Government & Public Policy", |
| #16 | ]; |
| #17 | |
| #18 | const OPPORTUNITY_TYPE_OPTIONS = [ |
| #19 | "Internship", "Volunteering", "Graduate Trainee", "Field Placement", |
| #20 | "Research Assistantship", "Fellowship", "Apprenticeship", |
| #21 | ]; |
| #22 | |
| #23 | const COUNTRY_OPTIONS = [ |
| #24 | "Algeria", "Angola", "Benin", "Botswana", "Burkina Faso", "Burundi", |
| #25 | "Cameroon", "Cape Verde", "Central African Republic", "Chad", "Comoros", |
| #26 | "Democratic Republic of the Congo", "Republic of the Congo", "Djibouti", |
| #27 | "Egypt", "Equatorial Guinea", "Eritrea", "Ethiopia", "Gabon", "Gambia", |
| #28 | "Ghana", "Guinea", "Guinea-Bissau", "Ivory Coast", "Kenya", "Lesotho", |
| #29 | "Liberia", "Libya", "Madagascar", "Malawi", "Mali", "Mauritania", |
| #30 | "Mauritius", "Morocco", "Mozambique", "Namibia", "Niger", "Nigeria", |
| #31 | "Rwanda", "Sao Tome and Principe", "Senegal", "Seychelles", |
| #32 | "Sierra Leone", "Somalia", "South Africa", "South Sudan", "Sudan", |
| #33 | "Eswatini", "Tanzania", "Togo", "Tunisia", "Uganda", "Zambia", "Zimbabwe", |
| #34 | "Other", |
| #35 | ]; |
| #36 | |
| #37 | // ---- Types ---- |
| #38 | |
| #39 | interface OrgFormData { |
| #40 | // Step 1 |
| #41 | orgName: string; |
| #42 | orgType: string; |
| #43 | sector: string; |
| #44 | country: string; |
| #45 | regionCity: string; |
| #46 | website: string; |
| #47 | orgEmail: string; |
| #48 | orgPhone: string; |
| #49 | // Step 2 |
| #50 | contactName: string; |
| #51 | contactPosition: string; |
| #52 | contactEmail: string; |
| #53 | contactPhone: string; |
| #54 | contactLinkedin: string; |
| #55 | // Step 3 |
| #56 | orgDescription: string; |
| #57 | missionFocus: string; |
| #58 | servicesProducts: string; |
| #59 | numEmployees: string; |
| #60 | // Step 4 |
| #61 | verificationDoc: File | null; |
| #62 | // Step 5 |
| #63 | socialWebsite: string; |
| #64 | socialLinkedin: string; |
| #65 | socialInstagram: string; |
| #66 | socialFacebook: string; |
| #67 | socialTwitter: string; |
| #68 | // Step 6 |
| #69 | opportunityTypes: string[]; |
| #70 | // Step 7 |
| #71 | sectors: string[]; |
| #72 | // Step 8 |
| #73 | accountEmail: string; |
| #74 | accountPassword: string; |
| #75 | accountConfirmPassword: string; |
| #76 | acceptTerms: boolean; |
| #77 | } |
| #78 | |
| #79 | const INITIAL_FORM: OrgFormData = { |
| #80 | orgName: "", |
| #81 | orgType: "", |
| #82 | sector: "", |
| #83 | country: "", |
| #84 | regionCity: "", |
| #85 | website: "", |
| #86 | orgEmail: "", |
| #87 | orgPhone: "", |
| #88 | contactName: "", |
| #89 | contactPosition: "", |
| #90 | contactEmail: "", |
| #91 | contactPhone: "", |
| #92 | contactLinkedin: "", |
| #93 | orgDescription: "", |
| #94 | missionFocus: "", |
| #95 | servicesProducts: "", |
| #96 | numEmployees: "", |
| #97 | verificationDoc: null, |
| #98 | socialWebsite: "", |
| #99 | socialLinkedin: "", |
| #100 | socialInstagram: "", |
| #101 | socialFacebook: "", |
| #102 | socialTwitter: "", |
| #103 | opportunityTypes: [], |
| #104 | sectors: [], |
| #105 | accountEmail: "", |
| #106 | accountPassword: "", |
| #107 | accountConfirmPassword: "", |
| #108 | acceptTerms: false, |
| #109 | }; |
| #110 | |
| #111 | const STEP_TITLES = [ |
| #112 | "Organisation Details", |
| #113 | "Contact Person", |
| #114 | "Organisation Description", |
| #115 | "Social & Digital Presence", |
| #116 | "Opportunity Categories", |
| #117 | "Areas of Interest", |
| #118 | "Account Setup", |
| #119 | ]; |
| #120 | |
| #121 | const BLOCKED_EMAIL_DOMAINS = [ |
| #122 | "gmail.com", "yahoo.com", "hotmail.com", "outlook.com", |
| #123 | "icloud.com", "live.com", "ymail.com", "aol.com", "zoho.com", "protonmail.com", |
| #124 | ]; |
| #125 | |
| #126 | // ---- Reusable components ---- |
| #127 | |
| #128 | function FormField({ |
| #129 | label, |
| #130 | required, |
| #131 | children, |
| #132 | hint, |
| #133 | }: { |
| #134 | label: string; |
| #135 | required?: boolean; |
| #136 | children: React.ReactNode; |
| #137 | hint?: string; |
| #138 | }) { |
| #139 | return ( |
| #140 | <div className="reg-field"> |
| #141 | <label className="reg-label"> |
| #142 | {label} |
| #143 | {required && <span className="reg-required">*</span>} |
| #144 | </label> |
| #145 | {children} |
| #146 | {hint && <span className="reg-hint">{hint}</span>} |
| #147 | </div> |
| #148 | ); |
| #149 | } |
| #150 | |
| #151 | function TextInput({ |
| #152 | value, |
| #153 | onChange, |
| #154 | placeholder, |
| #155 | type = "text", |
| #156 | }: { |
| #157 | value: string; |
| #158 | onChange: (v: string) => void; |
| #159 | placeholder?: string; |
| #160 | type?: string; |
| #161 | }) { |
| #162 | return ( |
| #163 | <input |
| #164 | type={type} |
| #165 | className="reg-input" |
| #166 | value={value} |
| #167 | onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} |
| #168 | placeholder={placeholder} |
| #169 | /> |
| #170 | ); |
| #171 | } |
| #172 | |
| #173 | function SelectInput({ |
| #174 | value, |
| #175 | onChange, |
| #176 | options, |
| #177 | placeholder, |
| #178 | }: { |
| #179 | value: string; |
| #180 | onChange: (v: string) => void; |
| #181 | options: string[]; |
| #182 | placeholder?: string; |
| #183 | }) { |
| #184 | return ( |
| #185 | <select |
| #186 | className="reg-input" |
| #187 | value={value} |
| #188 | onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)} |
| #189 | > |
| #190 | <option value="">{placeholder || "Select..."}</option> |
| #191 | {options.map((o) => ( |
| #192 | <option key={o} value={o}>{o}</option> |
| #193 | ))} |
| #194 | </select> |
| #195 | ); |
| #196 | } |
| #197 | |
| #198 | function TextareaInput({ |
| #199 | value, |
| #200 | onChange, |
| #201 | placeholder, |
| #202 | rows = 4, |
| #203 | }: { |
| #204 | value: string; |
| #205 | onChange: (v: string) => void; |
| #206 | placeholder?: string; |
| #207 | rows?: number; |
| #208 | }) { |
| #209 | return ( |
| #210 | <textarea |
| #211 | className="reg-input reg-textarea" |
| #212 | value={value} |
| #213 | onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} |
| #214 | placeholder={placeholder} |
| #215 | rows={rows} |
| #216 | /> |
| #217 | ); |
| #218 | } |
| #219 | |
| #220 | function ChipSelect({ |
| #221 | options, |
| #222 | selected, |
| #223 | onToggle, |
| #224 | }: { |
| #225 | options: string[]; |
| #226 | selected: string[]; |
| #227 | onToggle: (item: string) => void; |
| #228 | }) { |
| #229 | return ( |
| #230 | <div className="reg-chips"> |
| #231 | {options.map((opt) => ( |
| #232 | <button |
| #233 | key={opt} |
| #234 | type="button" |
| #235 | className={`reg-chip ${selected.includes(opt) ? "reg-chip-active" : ""}`} |
| #236 | onClick={() => onToggle(opt)} |
| #237 | > |
| #238 | {selected.includes(opt) && <span className="reg-chip-check">✓</span>} |
| #239 | {opt} |
| #240 | </button> |
| #241 | ))} |
| #242 | </div> |
| #243 | ); |
| #244 | } |
| #245 | |
| #246 | // ---- Step renderers ---- |
| #247 | |
| #248 | function Step1({ |
| #249 | form, |
| #250 | setField, |
| #251 | isBlockedDomain, |
| #252 | }: { |
| #253 | form: OrgFormData; |
| #254 | setField: <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => void; |
| #255 | isBlockedDomain: boolean; |
| #256 | }) { |
| #257 | return ( |
| #258 | <div className="reg-step-content"> |
| #259 | <div className="reg-step-intro"> |
| #260 | <h3>Tell us about your organisation</h3> |
| #261 | <p>Basic information to get your organisation set up on FursaLink. Please use your official work email address.</p> |
| #262 | </div> |
| #263 | <FormField label="Organisation Name" required> |
| #264 | <TextInput |
| #265 | value={form.orgName} |
| #266 | onChange={(v) => setField("orgName", v)} |
| #267 | placeholder="e.g. East Africa Climate Initiative" |
| #268 | /> |
| #269 | </FormField> |
| #270 | <div className="reg-grid-2"> |
| #271 | <FormField label="Organisation Type" required> |
| #272 | <SelectInput |
| #273 | value={form.orgType} |
| #274 | onChange={(v) => setField("orgType", v)} |
| #275 | options={ORG_TYPE_OPTIONS} |
| #276 | /> |
| #277 | </FormField> |
| #278 | <FormField label="Industry / Sector" required> |
| #279 | <SelectInput |
| #280 | value={form.sector} |
| #281 | onChange={(v) => setField("sector", v)} |
| #282 | options={SECTOR_OPTIONS} |
| #283 | /> |
| #284 | </FormField> |
| #285 | <FormField label="Country" required> |
| #286 | <SelectInput |
| #287 | value={form.country} |
| #288 | onChange={(v) => setField("country", v)} |
| #289 | options={COUNTRY_OPTIONS} |
| #290 | /> |
| #291 | </FormField> |
| #292 | <FormField label="Region / City" required> |
| #293 | <TextInput |
| #294 | value={form.regionCity} |
| #295 | onChange={(v) => setField("regionCity", v)} |
| #296 | placeholder="e.g. Nairobi" |
| #297 | /> |
| #298 | </FormField> |
| #299 | <FormField label="Website" hint="Optional for small organisations"> |
| #300 | <TextInput |
| #301 | value={form.website} |
| #302 | onChange={(v) => setField("website", v)} |
| #303 | placeholder="https://www.example.org" |
| #304 | /> |
| #305 | </FormField> |
| #306 | <FormField label="Official Work Email" required hint="Must be your organisation's official email, not a personal one"> |
| #307 | <TextInput |
| #308 | value={form.orgEmail} |
| #309 | onChange={(v) => setField("orgEmail", v)} |
| #310 | placeholder="info@organisation.org" |
| #311 | type="email" |
| #312 | /> |
| #313 | {isBlockedDomain && form.orgEmail && ( |
| #314 | <p style={{ color: "#dc2626", fontSize: "0.8rem", marginTop: "0.35rem" }}> |
| #315 | Please use your official work email, not a personal email address. |
| #316 | </p> |
| #317 | )} |
| #318 | </FormField> |
| #319 | </div> |
| #320 | <FormField label="Phone Number" required> |
| #321 | <TextInput |
| #322 | value={form.orgPhone} |
| #323 | onChange={(v) => setField("orgPhone", v)} |
| #324 | placeholder="+254 20 123 4567" |
| #325 | type="tel" |
| #326 | /> |
| #327 | </FormField> |
| #328 | </div> |
| #329 | ); |
| #330 | } |
| #331 | |
| #332 | function Step2({ |
| #333 | form, |
| #334 | setField, |
| #335 | }: { |
| #336 | form: OrgFormData; |
| #337 | setField: <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => void; |
| #338 | }) { |
| #339 | return ( |
| #340 | <div className="reg-step-content"> |
| #341 | <div className="reg-step-intro"> |
| #342 | <h3>Who is the primary contact?</h3> |
| #343 | <p>We need a responsible focal person for communications and verification.</p> |
| #344 | </div> |
| #345 | <div className="reg-grid-2"> |
| #346 | <FormField label="Full Name" required> |
| #347 | <TextInput |
| #348 | value={form.contactName} |
| #349 | onChange={(v) => setField("contactName", v)} |
| #350 | placeholder="e.g. Amina Osei" |
| #351 | /> |
| #352 | </FormField> |
| #353 | <FormField label="Position / Title" required> |
| #354 | <TextInput |
| #355 | value={form.contactPosition} |
| #356 | onChange={(v) => setField("contactPosition", v)} |
| #357 | placeholder="e.g. HR Manager" |
| #358 | /> |
| #359 | </FormField> |
| #360 | <FormField label="Official Email" required> |
| #361 | <TextInput |
| #362 | value={form.contactEmail} |
| #363 | onChange={(v) => setField("contactEmail", v)} |
| #364 | placeholder="amina@organisation.org" |
| #365 | type="email" |
| #366 | /> |
| #367 | </FormField> |
| #368 | <FormField label="Phone Number" required> |
| #369 | <TextInput |
| #370 | value={form.contactPhone} |
| #371 | onChange={(v) => setField("contactPhone", v)} |
| #372 | placeholder="+254 700 000 000" |
| #373 | type="tel" |
| #374 | /> |
| #375 | </FormField> |
| #376 | </div> |
| #377 | <FormField label="LinkedIn Profile" hint="Optional"> |
| #378 | <TextInput |
| #379 | value={form.contactLinkedin} |
| #380 | onChange={(v) => setField("contactLinkedin", v)} |
| #381 | placeholder="https://linkedin.com/in/amina-osei" |
| #382 | /> |
| #383 | </FormField> |
| #384 | </div> |
| #385 | ); |
| #386 | } |
| #387 | |
| #388 | function Step3({ |
| #389 | form, |
| #390 | setField, |
| #391 | }: { |
| #392 | form: OrgFormData; |
| #393 | setField: <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => void; |
| #394 | }) { |
| #395 | const descLen = form.orgDescription.length; |
| #396 | return ( |
| #397 | <div className="reg-step-content"> |
| #398 | <div className="reg-step-intro"> |
| #399 | <h3>Describe your organisation</h3> |
| #400 | <p>Help youth understand who you are and what you do. Keep it concise and mobile-friendly.</p> |
| #401 | </div> |
| #402 | <FormField label="Short Description" required hint={`${descLen}/500 characters`}> |
| #403 | <TextareaInput |
| #404 | value={form.orgDescription} |
| #405 | onChange={(v) => setField("orgDescription", v)} |
| #406 | placeholder="e.g. We are a climate-focused social enterprise working with smallholder farmers across East Africa." |
| #407 | rows={4} |
| #408 | /> |
| #409 | </FormField> |
| #410 | <FormField label="Mission / Focus Area" required> |
| #411 | <TextareaInput |
| #412 | value={form.missionFocus} |
| #413 | onChange={(v) => setField("missionFocus", v)} |
| #414 | placeholder="What is your organisation's core mission?" |
| #415 | rows={3} |
| #416 | /> |
| #417 | </FormField> |
| #418 | <FormField label="Services / Products / Programmes"> |
| #419 | <TextareaInput |
| #420 | value={form.servicesProducts} |
| #421 | onChange={(v) => setField("servicesProducts", v)} |
| #422 | placeholder="Briefly list your main services, products, or programmes." |
| #423 | rows={3} |
| #424 | /> |
| #425 | </FormField> |
| #426 | <FormField label="Number of Employees" hint="Optional"> |
| #427 | <SelectInput |
| #428 | value={form.numEmployees} |
| #429 | onChange={(v) => setField("numEmployees", v)} |
| #430 | options={["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"]} |
| #431 | placeholder="Select range..." |
| #432 | /> |
| #433 | </FormField> |
| #434 | </div> |
| #435 | ); |
| #436 | } |
| #437 | |
| #438 | function Step5({ |
| #439 | form, |
| #440 | setField, |
| #441 | }: { |
| #442 | form: OrgFormData; |
| #443 | setField: <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => void; |
| #444 | }) { |
| #445 | return ( |
| #446 | <div className="reg-step-content"> |
| #447 | <div className="reg-step-intro"> |
| #448 | <h3>Social & digital presence</h3> |
| #449 | <p>Adding your online presence helps increase trust and makes it easier for youth to learn about you.</p> |
| #450 | </div> |
| #451 | <FormField label="Website"> |
| #452 | <TextInput |
| #453 | value={form.socialWebsite} |
| #454 | onChange={(v) => setField("socialWebsite", v)} |
| #455 | placeholder="https://www.example.org" |
| #456 | /> |
| #457 | </FormField> |
| #458 | <FormField label="LinkedIn"> |
| #459 | <TextInput |
| #460 | value={form.socialLinkedin} |
| #461 | onChange={(v) => setField("socialLinkedin", v)} |
| #462 | placeholder="https://linkedin.com/company/your-org" |
| #463 | /> |
| #464 | </FormField> |
| #465 | <FormField label="Instagram"> |
| #466 | <TextInput |
| #467 | value={form.socialInstagram} |
| #468 | onChange={(v) => setField("socialInstagram", v)} |
| #469 | placeholder="https://instagram.com/your-org" |
| #470 | /> |
| #471 | </FormField> |
| #472 | <FormField label="Facebook"> |
| #473 | <TextInput |
| #474 | value={form.socialFacebook} |
| #475 | onChange={(v) => setField("socialFacebook", v)} |
| #476 | placeholder="https://facebook.com/your-org" |
| #477 | /> |
| #478 | </FormField> |
| #479 | <FormField label="X / Twitter"> |
| #480 | <TextInput |
| #481 | value={form.socialTwitter} |
| #482 | onChange={(v) => setField("socialTwitter", v)} |
| #483 | placeholder="https://x.com/your-org" |
| #484 | /> |
| #485 | </FormField> |
| #486 | </div> |
| #487 | ); |
| #488 | } |
| #489 | |
| #490 | function Step6({ |
| #491 | form, |
| #492 | toggleChip, |
| #493 | }: { |
| #494 | form: OrgFormData; |
| #495 | toggleChip: (key: "opportunityTypes", item: string) => void; |
| #496 | }) { |
| #497 | return ( |
| #498 | <div className="reg-step-content"> |
| #499 | <div className="reg-step-intro"> |
| #500 | <h3>What types of opportunities do you offer?</h3> |
| #501 | <p>Select all that apply. You can update these later when posting opportunities.</p> |
| #502 | </div> |
| #503 | <FormField label="Opportunity Types" required> |
| #504 | <ChipSelect |
| #505 | options={OPPORTUNITY_TYPE_OPTIONS} |
| #506 | selected={form.opportunityTypes} |
| #507 | onToggle={(item) => toggleChip("opportunityTypes", item)} |
| #508 | /> |
| #509 | </FormField> |
| #510 | </div> |
| #511 | ); |
| #512 | } |
| #513 | |
| #514 | function Step7({ |
| #515 | form, |
| #516 | toggleChip, |
| #517 | }: { |
| #518 | form: OrgFormData; |
| #519 | toggleChip: (key: "sectors", item: string) => void; |
| #520 | }) { |
| #521 | return ( |
| #522 | <div className="reg-step-content"> |
| #523 | <div className="reg-step-intro"> |
| #524 | <h3>Areas of interest / sectors</h3> |
| #525 | <p>Which sectors does your organisation work in? This helps us match you with relevant youth.</p> |
| #526 | </div> |
| #527 | <FormField label="Sectors" required> |
| #528 | <ChipSelect |
| #529 | options={SECTOR_OPTIONS} |
| #530 | selected={form.sectors} |
| #531 | onToggle={(item) => toggleChip("sectors", item)} |
| #532 | /> |
| #533 | </FormField> |
| #534 | </div> |
| #535 | ); |
| #536 | } |
| #537 | |
| #538 | function Step8({ |
| #539 | form, |
| #540 | setField, |
| #541 | }: { |
| #542 | form: OrgFormData; |
| #543 | setField: <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => void; |
| #544 | }) { |
| #545 | return ( |
| #546 | <div className="reg-step-content"> |
| #547 | <div className="reg-step-intro"> |
| #548 | <h3>Set up your account</h3> |
| #549 | <p>Create your login credentials to access the organisation dashboard.</p> |
| #550 | </div> |
| #551 | <FormField label="Account Email" required hint="This will be used to log in"> |
| #552 | <TextInput |
| #553 | value={form.accountEmail} |
| #554 | onChange={(v) => setField("accountEmail", v)} |
| #555 | placeholder="admin@organisation.org" |
| #556 | type="email" |
| #557 | /> |
| #558 | </FormField> |
| #559 | <FormField label="Password" required> |
| #560 | <TextInput |
| #561 | value={form.accountPassword} |
| #562 | onChange={(v) => setField("accountPassword", v)} |
| #563 | placeholder="Min. 8 characters" |
| #564 | type="password" |
| #565 | /> |
| #566 | </FormField> |
| #567 | <FormField label="Confirm Password" required> |
| #568 | <TextInput |
| #569 | value={form.accountConfirmPassword} |
| #570 | onChange={(v) => setField("accountConfirmPassword", v)} |
| #571 | placeholder="Re-enter password" |
| #572 | type="password" |
| #573 | /> |
| #574 | </FormField> |
| #575 | |
| #576 | <div style={{ marginTop: "1.5rem", padding: "1rem", background: "#f9fafb", borderRadius: "0.75rem", border: "1px solid #e5e7eb" }}> |
| #577 | <label style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", cursor: "pointer" }}> |
| #578 | <input |
| #579 | type="checkbox" |
| #580 | checked={form.acceptTerms} |
| #581 | onChange={(e) => setField("acceptTerms", e.target.checked)} |
| #582 | style={{ marginTop: "0.25rem", width: "1.1rem", height: "1.1rem", accentColor: "var(--green-600)" }} |
| #583 | /> |
| #584 | <span style={{ fontSize: "0.9rem", lineHeight: 1.5, color: "var(--gray-700)" }}> |
| #585 | I confirm that the information provided is accurate. I agree to the{" "} |
| #586 | <a href="#" onClick={(e) => { e.preventDefault(); window.open(window.location.origin + "?page=terms", "_blank"); }} style={{ color: "var(--green-700)", fontWeight: 600 }}>Terms & Conditions</a>{" "} |
| #587 | and{" "} |
| #588 | <a href="#" onClick={(e) => { e.preventDefault(); window.open(window.location.origin + "?page=privacy", "_blank"); }} style={{ color: "var(--green-700)", fontWeight: 600 }}>Privacy Policy</a>{" "} |
| #589 | of FursaLink Africa. |
| #590 | </span> |
| #591 | </label> |
| #592 | </div> |
| #593 | </div> |
| #594 | ); |
| #595 | } |
| #596 | |
| #597 | // ---- Main component ---- |
| #598 | |
| #599 | interface OrgRegistrationProps { |
| #600 | onBack: () => void; |
| #601 | onComplete: () => void; |
| #602 | } |
| #603 | |
| #604 | export default function OrganisationRegistrationPage({ onBack, onComplete }: OrgRegistrationProps) { |
| #605 | const [step, setStep] = useState(1); |
| #606 | const [form, setForm] = useState<OrgFormData>({ ...INITIAL_FORM }); |
| #607 | const [submitted, setSubmitted] = useState(false); |
| #608 | const [, setSubmitting] = useState(false); |
| #609 | const [submitError, setSubmitError] = useState(""); |
| #610 | |
| #611 | const totalSteps = 7; |
| #612 | |
| #613 | const setField = <K extends keyof OrgFormData>(key: K, val: OrgFormData[K]) => { |
| #614 | setForm((prev) => ({ ...prev, [key]: val })); |
| #615 | }; |
| #616 | |
| #617 | const toggleChip = ( |
| #618 | key: "opportunityTypes" | "sectors", |
| #619 | item: string, |
| #620 | ) => { |
| #621 | setForm((prev) => { |
| #622 | const arr = prev[key] as string[]; |
| #623 | return { |
| #624 | ...prev, |
| #625 | [key]: arr.includes(item) ? arr.filter((x) => x !== item) : [...arr, item], |
| #626 | }; |
| #627 | }); |
| #628 | }; |
| #629 | |
| #630 | const emailDomain = form.orgEmail.split("@")[1]?.toLowerCase() || ""; |
| #631 | const isBlockedDomain = BLOCKED_EMAIL_DOMAINS.includes(emailDomain); |
| #632 | |
| #633 | const canProceed = (): boolean => { |
| #634 | switch (step) { |
| #635 | case 1: |
| #636 | return !!(form.orgName && form.orgType && form.sector && form.country && form.regionCity && form.orgEmail && form.orgPhone && !isBlockedDomain); |
| #637 | case 2: |
| #638 | return !!(form.contactName && form.contactPosition && form.contactEmail && form.contactPhone); |
| #639 | case 3: |
| #640 | return !!(form.orgDescription && form.orgDescription.length <= 500 && form.missionFocus); |
| #641 | case 4: |
| #642 | return true; // Social & Digital Presence - optional |
| #643 | case 5: |
| #644 | return form.opportunityTypes.length > 0; |
| #645 | case 6: |
| #646 | return form.sectors.length > 0; |
| #647 | case 7: { |
| #648 | return !!( |
| #649 | form.accountEmail && |
| #650 | form.accountPassword && |
| #651 | form.accountConfirmPassword && |
| #652 | form.accountPassword.length >= 8 && |
| #653 | form.accountPassword === form.accountConfirmPassword && |
| #654 | form.acceptTerms |
| #655 | ); |
| #656 | } |
| #657 | default: |
| #658 | return true; |
| #659 | } |
| #660 | }; |
| #661 | |
| #662 | const handleNext = () => { |
| #663 | if (step < totalSteps) setStep(step + 1); |
| #664 | }; |
| #665 | |
| #666 | const handlePrev = () => { |
| #667 | if (step > 1) setStep(step - 1); |
| #668 | }; |
| #669 | |
| #670 | const handleSubmit = async (e: FormEvent) => { |
| #671 | e.preventDefault(); |
| #672 | setSubmitting(true); |
| #673 | setSubmitError(""); |
| #674 | |
| #675 | try { |
| #676 | // 1. Create auth user |
| #677 | const { data: authData, error: authError } = await supabase.auth.signUp({ |
| #678 | email: form.accountEmail, |
| #679 | password: form.accountPassword, |
| #680 | options: { |
| #681 | emailRedirectTo: window.location.origin, |
| #682 | }, |
| #683 | }); |
| #684 | |
| #685 | if (authError) throw authError; |
| #686 | |
| #687 | const userId = authData.user?.id; |
| #688 | if (!userId) throw new Error("User creation failed — no ID returned."); |
| #689 | |
| #690 | // 2. Insert organisation profile |
| #691 | const { error: profileError } = await supabase.from("organisation_profiles").insert({ |
| #692 | user_id: userId, |
| #693 | org_name: form.orgName, |
| #694 | org_type: form.orgType, |
| #695 | sector: form.sector, |
| #696 | country: form.country, |
| #697 | region_city: form.regionCity, |
| #698 | website: form.website, |
| #699 | org_email: form.orgEmail, |
| #700 | org_phone: form.orgPhone, |
| #701 | contact_name: form.contactName, |
| #702 | contact_position: form.contactPosition, |
| #703 | contact_email: form.contactEmail, |
| #704 | contact_phone: form.contactPhone, |
| #705 | contact_linkedin: form.contactLinkedin, |
| #706 | org_description: form.orgDescription, |
| #707 | mission_focus: form.missionFocus, |
| #708 | services_products: form.servicesProducts, |
| #709 | num_employees: form.numEmployees, |
| #710 | verified: 'pending', |
| #711 | social_website: form.socialWebsite, |
| #712 | social_linkedin: form.socialLinkedin, |
| #713 | social_instagram: form.socialInstagram, |
| #714 | social_facebook: form.socialFacebook, |
| #715 | social_twitter: form.socialTwitter, |
| #716 | opportunity_types: form.opportunityTypes, |
| #717 | sectors: form.sectors, |
| #718 | }); |
| #719 | |
| #720 | if (profileError) throw profileError; |
| #721 | |
| #722 | // 4. Insert into organisation_registrations summary table |
| #723 | const { error: regError } = await supabase.from("organisation_registrations").insert({ |
| #724 | org_name: form.orgName, |
| #725 | email: form.orgEmail, |
| #726 | country: form.country, |
| #727 | org_type: form.orgType, |
| #728 | sector: form.sector, |
| #729 | }); |
| #730 | |
| #731 | if (regError) throw regError; |
| #732 | |
| #733 | setSubmitted(true); |
| #734 | } catch (err: unknown) { |
| #735 | const message = err instanceof Error ? err.message : "Registration failed. Please try again."; |
| #736 | setSubmitError(message); |
| #737 | } finally { |
| #738 | setSubmitting(false); |
| #739 | } |
| #740 | }; |
| #741 | |
| #742 | if (submitted) { |
| #743 | return ( |
| #744 | <div className="reg-page"> |
| #745 | <nav className="nav"> |
| #746 | <div className="nav-inner"> |
| #747 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #748 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #749 | <span className="logo-text">Fursa<span className="logo-highlight">Link</span></span> |
| #750 | </a> |
| #751 | </div> |
| #752 | </nav> |
| #753 | <div className="reg-success"> |
| #754 | <div className="reg-success-card"> |
| #755 | <div className="reg-success-icon">🏢</div> |
| #756 | <h2>Organisation Registered!</h2> |
| #757 | <p> |
| #758 | Welcome to FursaLink, <strong>{form.orgName}</strong>! |
| #759 | </p> |
| #760 | <p className="reg-success-detail"> |
| #761 | Your organisation will be verified via your official email address. A verification link will be sent to <strong>{form.orgEmail}</strong>. |
| #762 | Once verified, you will receive a trusted badge visible to all youth applicants. |
| #763 | </p> |
| #764 | <div className="reg-success-next"> |
| #765 | <h4>What's next?</h4> |
| #766 | <ul> |
| #767 | <li>Check your email for the verification link</li> |
| #768 | <li>Complete your organisation profile</li> |
| #769 | <li>Post your first opportunity</li> |
| #770 | <li>Start receiving applications from youth</li> |
| #771 | </ul> |
| #772 | </div> |
| #773 | <button type="button" className="btn btn-primary btn-lg" onClick={onComplete}> |
| #774 | Go to Dashboard |
| #775 | </button> |
| #776 | </div> |
| #777 | </div> |
| #778 | </div> |
| #779 | ); |
| #780 | } |
| #781 | |
| #782 | return ( |
| #783 | <div className="reg-page"> |
| #784 | <nav className="nav"> |
| #785 | <div className="nav-inner"> |
| #786 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #787 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #788 | <span className="logo-text">Fursa<span className="logo-highlight">Link</span></span> |
| #789 | </a> |
| #790 | <div className="nav-actions"> |
| #791 | <button type="button" className="btn btn-outline btn-sm" onClick={onBack}> |
| #792 | ← Back to Home |
| #793 | </button> |
| #794 | </div> |
| #795 | </div> |
| #796 | </nav> |
| #797 | |
| #798 | <div className="reg-container"> |
| #799 | <aside className="reg-sidebar"> |
| #800 | <div className="reg-sidebar-inner"> |
| #801 | <h2 className="reg-sidebar-title">Organisation Registration</h2> |
| #802 | <p className="reg-sidebar-subtitle">Register your organisation to post opportunities</p> |
| #803 | <div className="reg-progress-steps"> |
| #804 | {STEP_TITLES.map((title, i) => { |
| #805 | const stepNum = i + 1; |
| #806 | const isActive = step === stepNum; |
| #807 | const isCompleted = step > stepNum; |
| #808 | return ( |
| #809 | <div |
| #810 | key={title} |
| #811 | className={`reg-progress-step ${isActive ? "reg-progress-active" : ""} ${isCompleted ? "reg-progress-done" : ""}`} |
| #812 | > |
| #813 | <div className="reg-progress-marker"> |
| #814 | {isCompleted ? "✓" : stepNum} |
| #815 | </div> |
| #816 | <div className="reg-progress-info"> |
| #817 | <span className="reg-progress-label">Step {stepNum}</span> |
| #818 | <span className="reg-progress-title">{title}</span> |
| #819 | </div> |
| #820 | </div> |
| #821 | ); |
| #822 | })} |
| #823 | </div> |
| #824 | <div className="reg-progress-bar-container"> |
| #825 | <div className="reg-progress-bar" style={{ width: `${(step / totalSteps) * 100}%` }} /> |
| #826 | </div> |
| #827 | <p className="reg-progress-pct">{Math.round((step / totalSteps) * 100)}% complete</p> |
| #828 | </div> |
| #829 | </aside> |
| #830 | |
| #831 | <main className="reg-main"> |
| #832 | <form onSubmit={handleSubmit} className="reg-form"> |
| #833 | <div className="reg-form-header"> |
| #834 | <span className="reg-step-badge">Step {step} of {totalSteps}</span> |
| #835 | <h2 className="reg-form-title">{STEP_TITLES[step - 1]}</h2> |
| #836 | </div> |
| #837 | |
| #838 | {step === 1 && <Step1 form={form} setField={setField} isBlockedDomain={isBlockedDomain} />} |
| #839 | {step === 2 && <Step2 form={form} setField={setField} />} |
| #840 | {step === 3 && <Step3 form={form} setField={setField} />} |
| #841 | {step === 4 && <Step5 form={form} setField={setField} />} |
| #842 | {step === 5 && <Step6 form={form} toggleChip={toggleChip} />} |
| #843 | {step === 6 && <Step7 form={form} toggleChip={toggleChip} />} |
| #844 | {step === 7 && <Step8 form={form} setField={setField} />} |
| #845 | |
| #846 | {step === 3 && form.orgDescription.length > 500 && ( |
| #847 | <p className="reg-validation-hint">Description must be 500 characters or less.</p> |
| #848 | )} |
| #849 | {step === 7 && form.accountPassword && form.accountPassword.length < 8 && ( |
| #850 | <p className="reg-validation-hint">Password must be at least 8 characters.</p> |
| #851 | )} |
| #852 | {step === 7 && form.accountConfirmPassword && form.accountPassword !== form.accountConfirmPassword && ( |
| #853 | <p className="reg-validation-hint">Passwords do not match.</p> |
| #854 | )} |
| #855 | |
| #856 | {/* Submit error */} |
| #857 | {submitError && <p className="reg-validation-hint" style={{ color: "#e74c3c" }}>{submitError}</p>} |
| #858 | |
| #859 | <div className="reg-form-actions"> |
| #860 | {step > 1 ? ( |
| #861 | <button type="button" className="btn btn-outline" onClick={handlePrev}>← Previous</button> |
| #862 | ) : ( |
| #863 | <div /> |
| #864 | )} |
| #865 | {step < totalSteps ? ( |
| #866 | <button type="button" className="btn btn-primary" onClick={handleNext} disabled={!canProceed()}> |
| #867 | Continue → |
| #868 | </button> |
| #869 | ) : ( |
| #870 | <button type="submit" className="btn btn-primary" disabled={!canProceed()}> |
| #871 | Complete Registration ✓ |
| #872 | </button> |
| #873 | )} |
| #874 | </div> |
| #875 | </form> |
| #876 | </main> |
| #877 | </div> |
| #878 | </div> |
| #879 | ); |
| #880 | } |
| #881 |