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 playground11m ago| #1 | import { useState, useEffect, type ChangeEvent, type FormEvent } from "react"; |
| #2 | import { supabase } from "./supabaseClient"; |
| #3 | |
| #4 | // ---- Types ---- |
| #5 | |
| #6 | interface UserProfile { |
| #7 | fullName: string; |
| #8 | email: string; |
| #9 | phone: string; |
| #10 | city: string; |
| #11 | country: string; |
| #12 | university: string; |
| #13 | programme: string; |
| #14 | levelOfStudy: string; |
| #15 | yearOfStudy: string; |
| #16 | graduationYear: string; |
| #17 | skills: string[]; |
| #18 | languages: string[]; |
| #19 | digitalSkillsLevel: string; |
| #20 | opportunityTypes: string[]; |
| #21 | careerFields: string[]; |
| #22 | availability: string; |
| #23 | bio: string; |
| #24 | cvUrl: string; |
| #25 | profileCompleteness: number; |
| #26 | avatar: string; |
| #27 | } |
| #28 | |
| #29 | const EMPTY_PROFILE: UserProfile = { |
| #30 | fullName: "", |
| #31 | email: "", |
| #32 | phone: "", |
| #33 | city: "", |
| #34 | country: "", |
| #35 | university: "", |
| #36 | programme: "", |
| #37 | levelOfStudy: "", |
| #38 | yearOfStudy: "", |
| #39 | graduationYear: "", |
| #40 | skills: [], |
| #41 | languages: [], |
| #42 | digitalSkillsLevel: "", |
| #43 | opportunityTypes: [], |
| #44 | careerFields: [], |
| #45 | availability: "", |
| #46 | bio: "", |
| #47 | cvUrl: "", |
| #48 | profileCompleteness: 0, |
| #49 | avatar: "U", |
| #50 | }; |
| #51 | |
| #52 | interface Opportunity { |
| #53 | id: number; |
| #54 | title: string; |
| #55 | organisation: string; |
| #56 | orgVerified: boolean; |
| #57 | orgUserId?: string; |
| #58 | orgLogoUrl?: string; |
| #59 | externalLink?: string; |
| #60 | type: string; |
| #61 | location: string; |
| #62 | locationType: string; |
| #63 | paidStatus: string; |
| #64 | stipendAmount?: string; |
| #65 | duration: string; |
| #66 | deadline: string; |
| #67 | sector: string; |
| #68 | skills: string[]; |
| #69 | description: string; |
| #70 | responsibilities?: string; |
| #71 | learningOutcomes?: string; |
| #72 | expectations?: string; |
| #73 | benefits?: string; |
| #74 | posted: string; |
| #75 | applicants: number; |
| #76 | saved: boolean; |
| #77 | applied: boolean; |
| #78 | } |
| #79 | |
| #80 | const EMPTY_OPPORTUNITIES: Opportunity[] = []; |
| #81 | |
| #82 | interface Application { |
| #83 | id: number; |
| #84 | opportunityTitle: string; |
| #85 | organisation: string; |
| #86 | appliedDate: string; |
| #87 | status: "Submitted" | "Under Review" | "Shortlisted" | "Interview" | "Rejected" | "Accepted"; |
| #88 | nextStep: string; |
| #89 | } |
| #90 | |
| #91 | const EMPTY_APPLICATIONS: Application[] = []; |
| #92 | |
| #93 | interface Notification { |
| #94 | id: string; |
| #95 | type: "opportunity" | "application" | "system" | "message" | "shortlisted" | "selected"; |
| #96 | title: string; |
| #97 | desc: string; |
| #98 | time: string; |
| #99 | read: boolean; |
| #100 | } |
| #101 | |
| #102 | const EMPTY_NOTIFICATIONS: Notification[] = []; |
| #103 | |
| #104 | // ---- Stat Card ---- |
| #105 | |
| #106 | function StatCard({ icon, value, label, color }: { icon: string; value: string | number; label: string; color: string }) { |
| #107 | return ( |
| #108 | <div className="dash-stat-card"> |
| #109 | <div className="dash-stat-icon" style={{ background: color }}>{icon}</div> |
| #110 | <div className="dash-stat-info"> |
| #111 | <span className="dash-stat-value">{value}</span> |
| #112 | <span className="dash-stat-label">{label}</span> |
| #113 | </div> |
| #114 | </div> |
| #115 | ); |
| #116 | } |
| #117 | |
| #118 | // ---- Opportunity Card ---- |
| #119 | |
| #120 | function OpportunityCard({ opp, onSave, onApply, profileSkills = [] }: { opp: Opportunity; onSave: (id: number) => void; onApply: (id: number) => void; profileSkills?: string[] }) { |
| #121 | const [expanded, setExpanded] = useState(false); |
| #122 | |
| #123 | return ( |
| #124 | <div className={`youth-opp-card ${opp.applied ? "youth-opp-applied" : ""}`}> |
| #125 | <div className="youth-opp-header"> |
| #126 | <div className="youth-opp-org"> |
| #127 | <div className="youth-opp-org-avatar"> |
| #128 | {opp.orgLogoUrl ? ( |
| #129 | <img src={opp.orgLogoUrl} alt="" style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }} /> |
| #130 | ) : (opp.organisation[0] || "O")} |
| #131 | </div> |
| #132 | <div> |
| #133 | <span className="youth-opp-org-name"> |
| #134 | {opp.organisation} |
| #135 | {opp.orgVerified && <span className="youth-opp-verified" title="Verified Organisation" style={{ background: "#d1fae5", color: "#065f46", padding: "0.1rem 0.5rem", borderRadius: "9999px", fontSize: "0.65rem", fontWeight: 700, marginLeft: "0.4rem" }}>✓ Verified</span>} |
| #136 | </span> |
| #137 | <span className="youth-opp-posted">Posted {opp.posted}</span> |
| #138 | </div> |
| #139 | </div> |
| #140 | <button |
| #141 | type="button" |
| #142 | className={`youth-opp-save ${opp.saved ? "youth-opp-save-active" : ""}`} |
| #143 | onClick={() => onSave(opp.id)} |
| #144 | title={opp.saved ? "Remove from saved" : "Save opportunity"} |
| #145 | > |
| #146 | {opp.saved ? "★" : "☆"} |
| #147 | </button> |
| #148 | </div> |
| #149 | |
| #150 | <h3 className="youth-opp-title">{opp.title}</h3> |
| #151 | |
| #152 | <div className="youth-opp-tags"> |
| #153 | <span className="youth-opp-tag youth-opp-tag-type">{opp.type}</span> |
| #154 | <span className="youth-opp-tag youth-opp-tag-location">{opp.locationType}</span> |
| #155 | <span className={`youth-opp-tag youth-opp-tag-paid ${opp.paidStatus === "Paid" ? "youth-opp-tag-paid-yes" : ""}`}>{opp.paidStatus}</span> |
| #156 | {opp.deadline && new Date(opp.deadline) < new Date() && ( |
| #157 | <span className="youth-opp-tag" style={{ background: "#fee2e2", color: "#991b1b", fontWeight: 600 }}>Closed</span> |
| #158 | )} |
| #159 | </div> |
| #160 | |
| #161 | <div className="youth-opp-meta"> |
| #162 | <span>📍 {opp.location}</span> |
| #163 | <span>⏱ {opp.duration}</span> |
| #164 | <span>📅 Deadline: {opp.deadline}</span> |
| #165 | </div> |
| #166 | |
| #167 | {expanded && ( |
| #168 | <div className="youth-opp-details" style={{ borderTop: "1px solid var(--gray-200)", marginTop: "0.75rem", paddingTop: "0.75rem" }}> |
| #169 | <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem", marginBottom: "1rem" }}> |
| #170 | <div> |
| #171 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Organisation</span> |
| #172 | <span style={{ fontWeight: 600 }}>{opp.organisation}</span> |
| #173 | </div> |
| #174 | <div> |
| #175 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Type</span> |
| #176 | <span style={{ fontWeight: 600 }}>{opp.type}</span> |
| #177 | </div> |
| #178 | <div> |
| #179 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Location</span> |
| #180 | <span style={{ fontWeight: 600 }}>{opp.location}</span> |
| #181 | </div> |
| #182 | <div> |
| #183 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Duration</span> |
| #184 | <span style={{ fontWeight: 600 }}>{opp.duration}</span> |
| #185 | </div> |
| #186 | <div> |
| #187 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Payment</span> |
| #188 | <span style={{ fontWeight: 600 }}> |
| #189 | {opp.paidStatus} |
| #190 | {opp.stipendAmount && ` — ${opp.stipendAmount}`} |
| #191 | </span> |
| #192 | </div> |
| #193 | <div> |
| #194 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Deadline</span> |
| #195 | <span style={{ fontWeight: 600 }}>{opp.deadline}</span> |
| #196 | </div> |
| #197 | <div> |
| #198 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Work Type</span> |
| #199 | <span style={{ fontWeight: 600 }}>{opp.locationType}</span> |
| #200 | </div> |
| #201 | <div> |
| #202 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block" }}>Applicants</span> |
| #203 | <span style={{ fontWeight: 600 }}>{opp.applicants}</span> |
| #204 | </div> |
| #205 | </div> |
| #206 | {opp.responsibilities && ( |
| #207 | <div style={{ marginBottom: "1rem" }}> |
| #208 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block", marginBottom: "0.35rem", fontWeight: 600 }}>Responsibilities</span> |
| #209 | <p style={{ margin: 0, lineHeight: "1.6", color: "var(--gray-700)" }}>{opp.responsibilities}</p> |
| #210 | </div> |
| #211 | )} |
| #212 | {opp.learningOutcomes && ( |
| #213 | <div style={{ marginBottom: "1rem" }}> |
| #214 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block", marginBottom: "0.35rem", fontWeight: 600 }}>Learning Outcomes</span> |
| #215 | <p style={{ margin: 0, lineHeight: "1.6", color: "var(--gray-700)" }}>{opp.learningOutcomes}</p> |
| #216 | </div> |
| #217 | )} |
| #218 | {opp.expectations && ( |
| #219 | <div style={{ marginBottom: "1rem" }}> |
| #220 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block", marginBottom: "0.35rem", fontWeight: 600 }}>Expectations</span> |
| #221 | <p style={{ margin: 0, lineHeight: "1.6", color: "var(--gray-700)" }}>{opp.expectations}</p> |
| #222 | </div> |
| #223 | )} |
| #224 | {opp.benefits && ( |
| #225 | <div style={{ marginBottom: "1rem" }}> |
| #226 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block", marginBottom: "0.35rem", fontWeight: 600 }}>Benefits</span> |
| #227 | <p style={{ margin: 0, lineHeight: "1.6", color: "var(--gray-700)" }}>{opp.benefits}</p> |
| #228 | </div> |
| #229 | )} |
| #230 | {!opp.responsibilities && !opp.learningOutcomes && !opp.expectations && !opp.benefits && ( |
| #231 | <div style={{ marginBottom: "1rem" }}> |
| #232 | <span style={{ fontSize: "0.75rem", color: "var(--gray-500)", display: "block", marginBottom: "0.35rem" }}>Description</span> |
| #233 | <p style={{ margin: 0, lineHeight: "1.6", color: "var(--gray-700)" }}>{opp.description || "No description provided."}</p> |
| #234 | </div> |
| #235 | )} |
| #236 | <div className="youth-opp-skills" style={{ marginBottom: "0.75rem" }}> |
| #237 | <span className="youth-opp-skills-label">Skills:</span> |
| #238 | {opp.skills.length > 0 ? opp.skills.map((s) => ( |
| #239 | <span key={s} className="dash-skill-tag">{s}</span> |
| #240 | )) : <span style={{ color: "var(--gray-400)", fontSize: "0.85rem" }}>None specified</span>} |
| #241 | </div> |
| #242 | <div className="youth-opp-match"> |
| #243 | <span className="youth-opp-match-label">Match Score:</span> |
| #244 | <div className="youth-opp-match-bar"> |
| #245 | <div className="youth-opp-match-fill" style={{ width: `${getMatchScore(opp, profileSkills)}%` }} /> |
| #246 | </div> |
| #247 | <span className="youth-opp-match-pct">{getMatchScore(opp, profileSkills)}%</span> |
| #248 | </div> |
| #249 | </div> |
| #250 | )} |
| #251 | |
| #252 | <div className="youth-opp-actions"> |
| #253 | <button type="button" className="btn btn-outline btn-sm" onClick={() => setExpanded(!expanded)}> |
| #254 | {expanded ? "Show Less" : "View Details"} |
| #255 | </button> |
| #256 | {opp.applied ? ( |
| #257 | <span className="youth-opp-applied-badge">✓ Applied</span> |
| #258 | ) : opp.externalLink ? ( |
| #259 | <a href={opp.externalLink} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm" style={{ textDecoration: "none" }}> |
| #260 | Apply Externally ↗ |
| #261 | </a> |
| #262 | ) : ( |
| #263 | <button type="button" className="btn btn-primary btn-sm" onClick={() => onApply(opp.id)}> |
| #264 | Apply Now |
| #265 | </button> |
| #266 | )} |
| #267 | <span className="youth-opp-applicants">{opp.applicants} applicants</span> |
| #268 | </div> |
| #269 | </div> |
| #270 | ); |
| #271 | } |
| #272 | |
| #273 | function getMatchScore(opp: Opportunity, profileSkills: string[] = []): number { |
| #274 | const matchingSkills = opp.skills.filter((s) => profileSkills.includes(s)).length; |
| #275 | const totalSkills = opp.skills.length; |
| #276 | if (totalSkills === 0) return 70; |
| #277 | const base = Math.round((matchingSkills / totalSkills) * 100); |
| #278 | return Math.max(60, Math.min(98, base + 30)); |
| #279 | } |
| #280 | |
| #281 | // ---- Edit Profile Modal ---- |
| #282 | |
| #283 | const SKILLS_OPTIONS = ["Programming", "Data Analysis", "Graphic Design", "Project Management", "Social Media Management", "Accounting", "Research", "Writing", "Translation", "Marketing", "Community Mobilisation", "Public Speaking", "Photography", "Video Editing", "Customer Service", "Sales"]; |
| #284 | const LANGUAGE_OPTIONS = ["English", "French", "Arabic", "Portuguese", "Swahili", "Amharic", "Hausa", "Yoruba", "Zulu"]; |
| #285 | const OPPORTUNITY_TYPE_OPTIONS = ["Internship", "Volunteering", "Graduate Trainee", "Field Placement", "Research Assistantship", "Fellowship", "Remote Opportunity"]; |
| #286 | const CAREER_FIELD_OPTIONS = ["Technology", "Finance & Accounting", "Health", "Education", "Climate & Environment", "Media & Communications", "Engineering", "Development", "Humanitarian Work", "Law & Justice", "Energy"]; |
| #287 | |
| #288 | function FormField({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) { |
| #289 | return ( |
| #290 | <div className="reg-field"> |
| #291 | <label className="reg-label"> |
| #292 | {label} |
| #293 | {required && <span className="reg-required">*</span>} |
| #294 | </label> |
| #295 | {children} |
| #296 | </div> |
| #297 | ); |
| #298 | } |
| #299 | |
| #300 | function YouthEditProfileModal({ profile, onClose, onSave }: { profile: UserProfile; onClose: () => void; onSave: (p: UserProfile) => void }) { |
| #301 | const [form, setForm] = useState<UserProfile>({ ...profile }); |
| #302 | |
| #303 | const setField = <K extends keyof UserProfile>(key: K, val: UserProfile[K]) => { |
| #304 | setForm((prev) => ({ ...prev, [key]: val })); |
| #305 | }; |
| #306 | |
| #307 | const toggleItem = (key: "skills" | "languages" | "opportunityTypes" | "careerFields", item: string) => { |
| #308 | setForm((prev) => ({ |
| #309 | ...prev, |
| #310 | [key]: prev[key].includes(item) ? prev[key].filter((s) => s !== item) : [...prev[key], item], |
| #311 | })); |
| #312 | }; |
| #313 | |
| #314 | const handleSubmit = (e: FormEvent) => { |
| #315 | e.preventDefault(); |
| #316 | onSave(form); |
| #317 | }; |
| #318 | |
| #319 | return ( |
| #320 | <div className="dash-modal-overlay" onClick={onClose}> |
| #321 | <div className="dash-modal dash-modal-lg" onClick={(e) => e.stopPropagation()}> |
| #322 | <div className="dash-modal-header"> |
| #323 | <h2>Edit Profile</h2> |
| #324 | <button type="button" className="dash-modal-close" onClick={onClose}>✕</button> |
| #325 | </div> |
| #326 | <form onSubmit={handleSubmit} className="dash-modal-body"> |
| #327 | {/* Personal Info */} |
| #328 | <h3 className="reg-sub-heading" style={{ marginTop: 0 }}>Personal Information</h3> |
| #329 | <div className="reg-grid-2"> |
| #330 | <FormField label="Full Name" required> |
| #331 | <input type="text" className="reg-input" value={form.fullName} onChange={(e) => setField("fullName", e.target.value)} /> |
| #332 | </FormField> |
| #333 | <FormField label="Email" required> |
| #334 | <input type="email" className="reg-input" value={form.email} onChange={(e) => setField("email", e.target.value)} /> |
| #335 | </FormField> |
| #336 | <FormField label="Phone"> |
| #337 | <input type="text" className="reg-input" value={form.phone} onChange={(e) => setField("phone", e.target.value)} /> |
| #338 | </FormField> |
| #339 | <FormField label="City"> |
| #340 | <input type="text" className="reg-input" value={form.city} onChange={(e) => setField("city", e.target.value)} /> |
| #341 | </FormField> |
| #342 | <FormField label="Country"> |
| #343 | <input type="text" className="reg-input" value={form.country} onChange={(e) => setField("country", e.target.value)} /> |
| #344 | </FormField> |
| #345 | </div> |
| #346 | <FormField label="Bio"> |
| #347 | <textarea className="reg-input reg-textarea" value={form.bio} onChange={(e) => setField("bio", e.target.value)} rows={3} placeholder="Tell organisations about yourself..." /> |
| #348 | </FormField> |
| #349 | |
| #350 | {/* Education */} |
| #351 | <h3 className="reg-sub-heading">Education</h3> |
| #352 | <div className="reg-grid-2"> |
| #353 | <FormField label="University"> |
| #354 | <input type="text" className="reg-input" value={form.university} onChange={(e) => setField("university", e.target.value)} /> |
| #355 | </FormField> |
| #356 | <FormField label="Programme"> |
| #357 | <input type="text" className="reg-input" value={form.programme} onChange={(e) => setField("programme", e.target.value)} /> |
| #358 | </FormField> |
| #359 | <FormField label="Level of Study"> |
| #360 | <select className="reg-input" value={form.levelOfStudy} onChange={(e) => setField("levelOfStudy", e.target.value)}> |
| #361 | <option>Bachelor's</option> |
| #362 | <option>Master's</option> |
| #363 | <option>PhD</option> |
| #364 | <option>Diploma</option> |
| #365 | <option>Certificate</option> |
| #366 | </select> |
| #367 | </FormField> |
| #368 | <FormField label="Year of Study"> |
| #369 | <select className="reg-input" value={form.yearOfStudy} onChange={(e) => setField("yearOfStudy", e.target.value)}> |
| #370 | <option>1st Year</option> |
| #371 | <option>2nd Year</option> |
| #372 | <option>3rd Year</option> |
| #373 | <option>4th Year</option> |
| #374 | <option>5th Year</option> |
| #375 | <option>Graduated</option> |
| #376 | </select> |
| #377 | </FormField> |
| #378 | <FormField label="Graduation Year"> |
| #379 | <input type="text" className="reg-input" value={form.graduationYear} onChange={(e) => setField("graduationYear", e.target.value)} /> |
| #380 | </FormField> |
| #381 | <FormField label="Digital Skills Level"> |
| #382 | <select className="reg-input" value={form.digitalSkillsLevel} onChange={(e) => setField("digitalSkillsLevel", e.target.value)}> |
| #383 | <option>Beginner</option> |
| #384 | <option>Intermediate</option> |
| #385 | <option>Advanced</option> |
| #386 | <option>Expert</option> |
| #387 | </select> |
| #388 | </FormField> |
| #389 | </div> |
| #390 | <FormField label="Availability"> |
| #391 | <select className="reg-input" value={form.availability} onChange={(e) => setField("availability", e.target.value)}> |
| #392 | <option>Full-time</option> |
| #393 | <option>Part-time</option> |
| #394 | <option>Weekends only</option> |
| #395 | <option>Flexible</option> |
| #396 | </select> |
| #397 | </FormField> |
| #398 | |
| #399 | {/* Skills */} |
| #400 | <h3 className="reg-sub-heading">Skills</h3> |
| #401 | <FormField label="Select your skills"> |
| #402 | <div className="reg-chips"> |
| #403 | {SKILLS_OPTIONS.map((skill) => ( |
| #404 | <button |
| #405 | key={skill} |
| #406 | type="button" |
| #407 | className={`reg-chip ${form.skills.includes(skill) ? "reg-chip-active" : ""}`} |
| #408 | onClick={() => toggleItem("skills", skill)} |
| #409 | > |
| #410 | {form.skills.includes(skill) && <span className="reg-chip-check">✓</span>} |
| #411 | {skill} |
| #412 | </button> |
| #413 | ))} |
| #414 | </div> |
| #415 | </FormField> |
| #416 | |
| #417 | {/* Languages */} |
| #418 | <h3 className="reg-sub-heading">Languages</h3> |
| #419 | <FormField label="Select languages you speak"> |
| #420 | <div className="reg-chips"> |
| #421 | {LANGUAGE_OPTIONS.map((lang) => ( |
| #422 | <button |
| #423 | key={lang} |
| #424 | type="button" |
| #425 | className={`reg-chip ${form.languages.includes(lang) ? "reg-chip-active" : ""}`} |
| #426 | onClick={() => toggleItem("languages", lang)} |
| #427 | > |
| #428 | {form.languages.includes(lang) && <span className="reg-chip-check">✓</span>} |
| #429 | {lang} |
| #430 | </button> |
| #431 | ))} |
| #432 | </div> |
| #433 | </FormField> |
| #434 | |
| #435 | {/* Interests */} |
| #436 | <h3 className="reg-sub-heading">Interests</h3> |
| #437 | <FormField label="Opportunity types you're interested in"> |
| #438 | <div className="reg-chips"> |
| #439 | {OPPORTUNITY_TYPE_OPTIONS.map((t) => ( |
| #440 | <button |
| #441 | key={t} |
| #442 | type="button" |
| #443 | className={`reg-chip ${form.opportunityTypes.includes(t) ? "reg-chip-active" : ""}`} |
| #444 | onClick={() => toggleItem("opportunityTypes", t)} |
| #445 | > |
| #446 | {form.opportunityTypes.includes(t) && <span className="reg-chip-check">✓</span>} |
| #447 | {t} |
| #448 | </button> |
| #449 | ))} |
| #450 | </div> |
| #451 | </FormField> |
| #452 | <FormField label="Career fields of interest"> |
| #453 | <div className="reg-chips"> |
| #454 | {CAREER_FIELD_OPTIONS.map((f) => ( |
| #455 | <button |
| #456 | key={f} |
| #457 | type="button" |
| #458 | className={`reg-chip ${form.careerFields.includes(f) ? "reg-chip-active" : ""}`} |
| #459 | onClick={() => toggleItem("careerFields", f)} |
| #460 | > |
| #461 | {form.careerFields.includes(f) && <span className="reg-chip-check">✓</span>} |
| #462 | {f} |
| #463 | </button> |
| #464 | ))} |
| #465 | </div> |
| #466 | </FormField> |
| #467 | |
| #468 | <div className="reg-form-actions"> |
| #469 | <button type="button" className="btn btn-outline" onClick={onClose}>Cancel</button> |
| #470 | <button type="submit" className="btn btn-primary">Save Changes</button> |
| #471 | </div> |
| #472 | </form> |
| #473 | </div> |
| #474 | </div> |
| #475 | ); |
| #476 | } |
| #477 | |
| #478 | // ---- Main Dashboard ---- |
| #479 | |
| #480 | interface YouthDashboardProps { |
| #481 | userId: string | null; |
| #482 | onBack: () => void; |
| #483 | } |
| #484 | |
| #485 | export default function YouthDashboard({ userId, onBack }: YouthDashboardProps) { |
| #486 | const [activeTab, setActiveTab] = useState<"overview" | "opportunities" | "applications" | "saved" | "notifications" | "profile">("overview"); |
| #487 | const [opportunities, setOpportunities] = useState(EMPTY_OPPORTUNITIES); |
| #488 | const [searchQuery, setSearchQuery] = useState(""); |
| #489 | const [filterType, setFilterType] = useState("All"); |
| #490 | const [filterLocation, setFilterLocation] = useState("All"); |
| #491 | const [filterPaid, setFilterPaid] = useState("All"); |
| #492 | const [filterCountry, setFilterCountry] = useState("All"); |
| #493 | const [filterRegion, setFilterRegion] = useState("All"); |
| #494 | const [showApplyModal, setShowApplyModal] = useState<number | null>(null); |
| #495 | const [applyError, setApplyError] = useState<string | null>(null); |
| #496 | const [cvUploading, setCvUploading] = useState(false); |
| #497 | const [cvMessage, setCvMessage] = useState<string | null>(null); |
| #498 | const [sidebarOpen, setSidebarOpen] = useState(false); |
| #499 | const [showEditProfile, setShowEditProfile] = useState(false); |
| #500 | const [profile, setProfile] = useState<UserProfile>(EMPTY_PROFILE); |
| #501 | const [applications, setApplications] = useState(EMPTY_APPLICATIONS); |
| #502 | const [notifications, setNotifications] = useState(EMPTY_NOTIFICATIONS); |
| #503 | const [loading, setLoading] = useState(!!userId); |
| #504 | const [isPremium, setIsPremium] = useState(false); |
| #505 | const [showUpgradeModal, setShowUpgradeModal] = useState(false); |
| #506 | const [coverLetterFile, setCoverLetterFile] = useState<File | null>(null); |
| #507 | const [transcriptFile, setTranscriptFile] = useState<File | null>(null); |
| #508 | const [docUploading, setDocUploading] = useState(false); |
| #509 | const unreadCount = notifications.filter((n) => !n.read).length; |
| #510 | const savedOpps = opportunities.filter((o) => o.saved); |
| #511 | |
| #512 | const getMonthlyApplicationCount = () => { |
| #513 | const now = new Date(); |
| #514 | const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); |
| #515 | return applications.filter((app) => { |
| #516 | const appliedDate = new Date(app.appliedDate); |
| #517 | return appliedDate >= firstDayOfMonth; |
| #518 | }).length; |
| #519 | }; |
| #520 | const monthlyCount = getMonthlyApplicationCount(); |
| #521 | |
| #522 | // Fetch real data from Supabase when userId is available |
| #523 | useEffect(() => { |
| #524 | if (!userId) { |
| #525 | setLoading(false); |
| #526 | return; |
| #527 | } |
| #528 | |
| #529 | async function fetchData() { |
| #530 | try { |
| #531 | // Fetch youth profile |
| #532 | const { data: profileData } = await supabase |
| #533 | .from("youth_profiles") |
| #534 | .select("*") |
| #535 | .eq("user_id", userId) |
| #536 | .single(); |
| #537 | |
| #538 | if (profileData) { |
| #539 | const name = profileData.full_name || ""; |
| #540 | const initials = name.split(" ").map((n: string) => n[0]).join("").toUpperCase().slice(0, 2) || "U"; |
| #541 | setIsPremium(profileData.premium || false); |
| #542 | setProfile({ |
| #543 | fullName: name, |
| #544 | email: profileData.email || "", |
| #545 | phone: profileData.phone || "", |
| #546 | city: profileData.city || "", |
| #547 | country: profileData.country_of_residence || "", |
| #548 | university: profileData.university || "", |
| #549 | programme: profileData.programme || "", |
| #550 | levelOfStudy: profileData.level_of_study || "", |
| #551 | yearOfStudy: profileData.year_of_study || "", |
| #552 | graduationYear: profileData.graduation_year || "", |
| #553 | skills: profileData.skills || [], |
| #554 | languages: profileData.languages || [], |
| #555 | digitalSkillsLevel: profileData.digital_skills_level || "", |
| #556 | opportunityTypes: profileData.opportunity_types || [], |
| #557 | careerFields: profileData.career_fields || [], |
| #558 | availability: Array.isArray(profileData.availability_type) |
| #559 | ? profileData.availability_type[0] || "Full-time" |
| #560 | : typeof profileData.availability_type === "string" |
| #561 | ? profileData.availability_type.replace(/[{}"]/g, "").split(",")[0]?.trim() || "Full-time" |
| #562 | : "Full-time", |
| #563 | bio: profileData.experience_description || "", |
| #564 | cvUrl: profileData.cv_url || "", |
| #565 | profileCompleteness: calculateCompleteness(profileData), |
| #566 | avatar: initials, |
| #567 | }); |
| #568 | } |
| #569 | |
| #570 | // Fetch opportunities |
| #571 | const { data: oppsData } = await supabase |
| #572 | .from("opportunities") |
| #573 | .select("*") |
| #574 | .eq("status", "Active") |
| #575 | .order("posted_at", { ascending: false }); |
| #576 | |
| #577 | console.log("Fetched opportunities:", oppsData); |
| #578 | if (oppsData && oppsData[0]) { |
| #579 | console.log("First opportunity fields:", Object.keys(oppsData[0])); |
| #580 | } |
| #581 | |
| #582 | // Fetch verified org statuses |
| #583 | const verifiedMap: Record<string, boolean> = {}; |
| #584 | const verifiedNameMap: Record<string, boolean> = {}; |
| #585 | const logoMap: Record<string, string> = {}; |
| #586 | if (oppsData && oppsData.length > 0) { |
| #587 | const orgUserIds = [...new Set(oppsData.map((o: Record<string, unknown>) => o.org_user_id).filter(Boolean))]; |
| #588 | console.log("Org user IDs from opportunities:", orgUserIds); |
| #589 | |
| #590 | const { data: orgProfiles, error: orgErr } = await supabase |
| #591 | .from("organisation_profiles") |
| #592 | .select("user_id, verified, org_name, logo_url"); |
| #593 | console.log("All org profiles:", orgProfiles, "error:", orgErr); |
| #594 | |
| #595 | if (orgProfiles) { |
| #596 | orgProfiles.forEach((p: Record<string, unknown>) => { |
| #597 | if (p.user_id) { |
| #598 | verifiedMap[p.user_id as string] = p.verified === true; |
| #599 | logoMap[p.user_id as string] = (p.logo_url as string) || ""; |
| #600 | } |
| #601 | if (p.org_name) verifiedNameMap[(p.org_name as string).toLowerCase()] = p.verified === true; |
| #602 | }); |
| #603 | } |
| #604 | } |
| #605 | console.log("Verified map:", verifiedMap); |
| #606 | console.log("Verified name map:", verifiedNameMap); |
| #607 | |
| #608 | if (oppsData && oppsData.length > 0) { |
| #609 | setOpportunities(oppsData.map((o: Record<string, unknown>) => { |
| #610 | const orgUserId = (o.org_user_id as string) || ""; |
| #611 | return { |
| #612 | id: o.id as number, |
| #613 | title: (o.title as string) || "", |
| #614 | organisation: (o.org_name as string) || "", |
| #615 | orgVerified: verifiedMap[orgUserId] || verifiedNameMap[(o.org_name as string || "").toLowerCase()] || false, |
| #616 | orgUserId: orgUserId, |
| #617 | orgLogoUrl: logoMap[orgUserId] || "", |
| #618 | type: (o.type as string) || "", |
| #619 | location: (o.location as string) || "", |
| #620 | locationType: (o.location_type as string) || "", |
| #621 | paidStatus: (o.paid_status as string) || "", |
| #622 | stipendAmount: (o.stipend_amount as string) || "", |
| #623 | duration: (o.duration as string) || "", |
| #624 | deadline: (o.deadline as string) || "", |
| #625 | sector: (o.sector as string) || "", |
| #626 | skills: (o.skills as string[]) || [], |
| #627 | description: (o.description as string) || "", |
| #628 | responsibilities: (o.responsibilities as string) || "", |
| #629 | learningOutcomes: (o.learning_outcomes as string) || "", |
| #630 | expectations: (o.expectations as string) || "", |
| #631 | benefits: (o.benefits as string) || "", |
| #632 | posted: (o.posted_at as string)?.slice(0, 10) || "", |
| #633 | applicants: (o.applicants_count as number) || 0, |
| #634 | externalLink: (o.external_link as string) || "", |
| #635 | saved: false, |
| #636 | applied: false, |
| #637 | }; |
| #638 | })); |
| #639 | } |
| #640 | |
| #641 | // Fetch user's applications |
| #642 | const { data: appsData } = await supabase |
| #643 | .from("youth_applications") |
| #644 | .select("*") |
| #645 | .eq("user_id", userId) |
| #646 | .order("applied_at", { ascending: false }); |
| #647 | |
| #648 | if (appsData && appsData.length > 0) { |
| #649 | setApplications(appsData.map((a: Record<string, unknown>) => ({ |
| #650 | id: a.id as number, |
| #651 | opportunityTitle: (a.opportunity_title as string) || "", |
| #652 | organisation: (a.organisation as string) || "", |
| #653 | appliedDate: (a.applied_at as string)?.slice(0, 10) || "", |
| #654 | status: (a.status as Application["status"]) || "Submitted", |
| #655 | nextStep: (a.next_step as string) || "", |
| #656 | }))); |
| #657 | |
| #658 | // Mark already-applied opportunities |
| #659 | const appliedIds = new Set(appsData.map((a: Record<string, unknown>) => a.opportunity_id)); |
| #660 | setOpportunities((prev) => |
| #661 | prev.map((o) => (appliedIds.has(o.id) ? { ...o, applied: true } : o)) |
| #662 | ); |
| #663 | } |
| #664 | |
| #665 | // Fetch saved opportunities |
| #666 | const { data: savedData } = await supabase |
| #667 | .from("youth_saved_opportunities") |
| #668 | .select("opportunity_id") |
| #669 | .eq("user_id", userId); |
| #670 | |
| #671 | if (savedData && savedData.length > 0) { |
| #672 | const savedIds = new Set(savedData.map((s: Record<string, unknown>) => s.opportunity_id)); |
| #673 | setOpportunities((prev) => |
| #674 | prev.map((o) => (savedIds.has(String(o.id)) ? { ...o, saved: true } : o)) |
| #675 | ); |
| #676 | } |
| #677 | |
| #678 | // Fetch notifications |
| #679 | const { data: notifsData } = await supabase |
| #680 | .from("youth_notifications") |
| #681 | .select("*") |
| #682 | .eq("user_id", userId) |
| #683 | .order("created_at", { ascending: false }); |
| #684 | |
| #685 | if (notifsData && notifsData.length > 0) { |
| #686 | setNotifications(notifsData.map((n: Record<string, unknown>) => ({ |
| #687 | id: String(n.id), |
| #688 | type: (n.type as Notification["type"]) || "system", |
| #689 | title: (n.title as string) || "", |
| #690 | desc: (n.message as string) || "", |
| #691 | time: (n.created_at as string)?.slice(0, 10) || "", |
| #692 | read: (n.read as boolean) || false, |
| #693 | }))); |
| #694 | } |
| #695 | } catch (err) { |
| #696 | console.error("Error fetching youth dashboard data:", err); |
| #697 | // Fall back to mock data |
| #698 | } finally { |
| #699 | setLoading(false); |
| #700 | } |
| #701 | } |
| #702 | |
| #703 | fetchData(); |
| #704 | }, [userId]); |
| #705 | |
| #706 | function calculateCompleteness(data: Record<string, unknown>): number { |
| #707 | const fields = ["full_name", "email", "phone", "city", "university", "programme", "level_of_study", "year_of_study", "graduation_year", "skills", "languages", "digital_skills_level"]; |
| #708 | const filled = fields.filter((f) => { |
| #709 | const val = data[f]; |
| #710 | if (Array.isArray(val)) return val.length > 0; |
| #711 | return !!val; |
| #712 | }).length; |
| #713 | return Math.round((filled / fields.length) * 100); |
| #714 | } |
| #715 | |
| #716 | const filteredOpps = opportunities.filter((opp) => { |
| #717 | const matchesSearch = !searchQuery || |
| #718 | opp.title.toLowerCase().includes(searchQuery.toLowerCase()) || |
| #719 | opp.organisation.toLowerCase().includes(searchQuery.toLowerCase()) || |
| #720 | opp.sector.toLowerCase().includes(searchQuery.toLowerCase()); |
| #721 | const matchesType = filterType === "All" || opp.type === filterType; |
| #722 | const matchesLocation = filterLocation === "All" || opp.locationType === filterLocation; |
| #723 | const matchesPaid = filterPaid === "All" || opp.paidStatus === filterPaid; |
| #724 | const locationParts = opp.location.split(",").map((s) => s.trim()); |
| #725 | const oppRegion = locationParts[0] || ""; |
| #726 | const oppCountry = locationParts.length > 1 ? locationParts[locationParts.length - 1] : opp.location; |
| #727 | const matchesCountry = filterCountry === "All" || oppCountry.toLowerCase() === filterCountry.toLowerCase(); |
| #728 | const matchesRegion = filterRegion === "All" || oppRegion.toLowerCase() === filterRegion.toLowerCase(); |
| #729 | return matchesSearch && matchesType && matchesLocation && matchesPaid && matchesCountry && matchesRegion; |
| #730 | }); |
| #731 | |
| #732 | const handleSave = async (id: number) => { |
| #733 | const opp = opportunities.find((o) => o.id === id); |
| #734 | if (!opp || !userId) return; |
| #735 | |
| #736 | const newSaved = !opp.saved; |
| #737 | setOpportunities((prev) => |
| #738 | prev.map((o) => (o.id === id ? { ...o, saved: newSaved } : o)) |
| #739 | ); |
| #740 | |
| #741 | if (newSaved) { |
| #742 | await supabase.from("youth_saved_opportunities").upsert({ |
| #743 | user_id: userId, |
| #744 | opportunity_id: String(id), |
| #745 | }, { onConflict: "user_id,opportunity_id" }); |
| #746 | } else { |
| #747 | await supabase.from("youth_saved_opportunities") |
| #748 | .delete() |
| #749 | .eq("user_id", userId) |
| #750 | .eq("opportunity_id", String(id)); |
| #751 | } |
| #752 | }; |
| #753 | |
| #754 | const handleApply = (id: number) => { |
| #755 | const opp = opportunities.find((o) => o.id === id); |
| #756 | if (opp?.externalLink) { |
| #757 | window.open(opp.externalLink, "_blank"); |
| #758 | return; |
| #759 | } |
| #760 | setShowApplyModal(id); |
| #761 | }; |
| #762 | |
| #763 | const handleCvUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| #764 | const file = e.target.files?.[0]; |
| #765 | if (!file || !userId) return; |
| #766 | |
| #767 | setCvUploading(true); |
| #768 | setCvMessage(null); |
| #769 | |
| #770 | try { |
| #771 | const filePath = `cv/${userId}/${Date.now()}_${file.name}`; |
| #772 | |
| #773 | const { error: uploadError } = await supabase.storage |
| #774 | .from("documents") |
| #775 | .upload(filePath, file, { upsert: true }); |
| #776 | |
| #777 | if (uploadError) { |
| #778 | setCvMessage("Upload failed: " + uploadError.message); |
| #779 | setCvUploading(false); |
| #780 | return; |
| #781 | } |
| #782 | |
| #783 | const { data: urlData } = supabase.storage |
| #784 | .from("documents") |
| #785 | .getPublicUrl(filePath); |
| #786 | |
| #787 | const cvUrl = urlData.publicUrl; |
| #788 | |
| #789 | // Update profile with CV URL |
| #790 | const { error: updateError } = await supabase |
| #791 | .from("youth_profiles") |
| #792 | .update({ cv_url: cvUrl }) |
| #793 | .eq("user_id", userId); |
| #794 | |
| #795 | if (updateError) { |
| #796 | setCvMessage("Upload succeeded but failed to save reference: " + updateError.message); |
| #797 | } else { |
| #798 | setProfile((prev) => ({ ...prev, cvUrl })); |
| #799 | setCvMessage("CV uploaded successfully!"); |
| #800 | } |
| #801 | } catch (err) { |
| #802 | setCvMessage("Upload failed. Please try again."); |
| #803 | } finally { |
| #804 | setCvUploading(false); |
| #805 | } |
| #806 | }; |
| #807 | |
| #808 | const confirmApply = async () => { |
| #809 | if (!showApplyModal) return; |
| #810 | |
| #811 | // Check freemium limit |
| #812 | if (!isPremium && monthlyCount >= 2) { |
| #813 | setShowUpgradeModal(true); |
| #814 | setShowApplyModal(null); |
| #815 | return; |
| #816 | } |
| #817 | |
| #818 | const opp = opportunities.find((o) => o.id === showApplyModal); |
| #819 | if (!opp || !userId) { |
| #820 | setShowApplyModal(null); |
| #821 | return; |
| #822 | } |
| #823 | |
| #824 | // Upload optional documents |
| #825 | let coverLetterUrl = ""; |
| #826 | let transcriptUrl = ""; |
| #827 | |
| #828 | if (coverLetterFile) { |
| #829 | setDocUploading(true); |
| #830 | const filePath = `applications/${userId}/cover_letter_${Date.now()}_${coverLetterFile.name}`; |
| #831 | const { error: upErr } = await supabase.storage.from("documents").upload(filePath, coverLetterFile); |
| #832 | if (!upErr) { |
| #833 | const { data: urlData } = supabase.storage.from("documents").getPublicUrl(filePath); |
| #834 | coverLetterUrl = urlData.publicUrl; |
| #835 | } |
| #836 | } |
| #837 | |
| #838 | if (transcriptFile) { |
| #839 | setDocUploading(true); |
| #840 | const filePath = `applications/${userId}/transcript_${Date.now()}_${transcriptFile.name}`; |
| #841 | const { error: upErr } = await supabase.storage.from("documents").upload(filePath, transcriptFile); |
| #842 | if (!upErr) { |
| #843 | const { data: urlData } = supabase.storage.from("documents").getPublicUrl(filePath); |
| #844 | transcriptUrl = urlData.publicUrl; |
| #845 | } |
| #846 | } |
| #847 | |
| #848 | setDocUploading(false); |
| #849 | |
| #850 | // Insert application into Supabase |
| #851 | const { error } = await supabase.from("youth_applications").insert({ |
| #852 | user_id: userId, |
| #853 | opportunity_id: String(opp.id), |
| #854 | opportunity_title: opp.title, |
| #855 | organisation: opp.organisation, |
| #856 | org_user_id: opp.orgUserId || null, |
| #857 | status: "Submitted", |
| #858 | applied_date: new Date().toISOString().slice(0, 10), |
| #859 | cover_letter_url: coverLetterUrl || null, |
| #860 | transcript_url: transcriptUrl || null, |
| #861 | }); |
| #862 | |
| #863 | if (error) { |
| #864 | console.error("Application insert error:", error); |
| #865 | } |
| #866 | |
| #867 | if (!error) { |
| #868 | // Update opportunity applied status locally |
| #869 | setOpportunities((prev) => |
| #870 | prev.map((o) => (o.id === showApplyModal ? { ...o, applied: true, applicants: o.applicants + 1 } : o)) |
| #871 | ); |
| #872 | |
| #873 | // Add to local applications list |
| #874 | setApplications((prev) => [ |
| #875 | { |
| #876 | id: Date.now(), |
| #877 | opportunityTitle: opp.title, |
| #878 | organisation: opp.organisation, |
| #879 | appliedDate: new Date().toISOString().slice(0, 10), |
| #880 | status: "Submitted", |
| #881 | nextStep: "", |
| #882 | }, |
| #883 | ...prev, |
| #884 | ]); |
| #885 | } else { |
| #886 | setApplyError(error.message || "Failed to submit application. Please try again."); |
| #887 | return; |
| #888 | } |
| #889 | |
| #890 | setApplyError(null); |
| #891 | setCoverLetterFile(null); |
| #892 | setTranscriptFile(null); |
| #893 | setShowApplyModal(null); |
| #894 | }; |
| #895 | |
| #896 | return ( |
| #897 | <div className="dash-page"> |
| #898 | {/* Top Nav */} |
| #899 | <nav className="nav"> |
| #900 | <div className="nav-inner"> |
| #901 | <div className="dash-nav-left"> |
| #902 | <button type="button" className="dash-sidebar-toggle" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Toggle menu"> |
| #903 | {sidebarOpen ? "✕" : "☰"} |
| #904 | </button> |
| #905 | <a href="#" className="logo" onClick={(e) => { e.preventDefault(); onBack(); }}> |
| #906 | <span className="logo-icon"><img src="https://i.imgur.com/FT8aHGw.png" alt="FursaLink" /></span> |
| #907 | <span className="logo-text">Fursa<span className="logo-highlight">Link</span></span> |
| #908 | </a> |
| #909 | </div> |
| #910 | <div className="dash-nav-tabs"> |
| #911 | {[ |
| #912 | { key: "overview" as const, label: "Overview" }, |
| #913 | { key: "opportunities" as const, label: "Opportunities" }, |
| #914 | { key: "applications" as const, label: "My Applications" }, |
| #915 | { key: "saved" as const, label: "Saved" }, |
| #916 | { key: "notifications" as const, label: `Notifications${unreadCount > 0 ? ` (${unreadCount})` : ""}` }, |
| #917 | { key: "profile" as const, label: "Profile" }, |
| #918 | ].map((tab) => ( |
| #919 | <button |
| #920 | key={tab.key} |
| #921 | className={`dash-nav-tab ${activeTab === tab.key ? "dash-nav-tab-active" : ""}`} |
| #922 | onClick={() => setActiveTab(tab.key)} |
| #923 | > |
| #924 | {tab.label} |
| #925 | </button> |
| #926 | ))} |
| #927 | </div> |
| #928 | <div className="nav-actions"> |
| #929 | <div className="youth-nav-avatar" title={profile.fullName}>{profile.avatar}</div> |
| #930 | <button type="button" className="btn btn-outline btn-sm" onClick={onBack}> |
| #931 | Sign Out |
| #932 | </button> |
| #933 | </div> |
| #934 | </div> |
| #935 | </nav> |
| #936 | |
| #937 | {/* Mobile sidebar */} |
| #938 | {sidebarOpen && ( |
| #939 | <div className="dash-mobile-overlay" onClick={() => setSidebarOpen(false)} /> |
| #940 | )} |
| #941 | <aside className={`dash-sidebar ${sidebarOpen ? "dash-sidebar-open" : ""}`}> |
| #942 | <div className="dash-sidebar-user"> |
| #943 | <div className="youth-profile-avatar-lg">{profile.avatar}</div> |
| #944 | <div> |
| #945 | <span className="dash-sidebar-name">{profile.fullName}</span> |
| #946 | <span className="dash-sidebar-sub">{profile.programme}</span> |
| #947 | </div> |
| #948 | </div> |
| #949 | <nav className="dash-sidebar-nav"> |
| #950 | {[ |
| #951 | { key: "overview" as const, label: "Overview", icon: "📊" }, |
| #952 | { key: "opportunities" as const, label: "Opportunities", icon: "🔍" }, |
| #953 | { key: "applications" as const, label: "My Applications", icon: "📄" }, |
| #954 | { key: "saved" as const, label: "Saved", icon: "★" }, |
| #955 | { key: "notifications" as const, label: "Notifications", icon: "🔔", badge: unreadCount }, |
| #956 | { key: "profile" as const, label: "Profile", icon: "👤" }, |
| #957 | ].map((tab) => ( |
| #958 | <button |
| #959 | key={tab.key} |
| #960 | className={`dash-sidebar-link ${activeTab === tab.key ? "dash-sidebar-link-active" : ""}`} |
| #961 | onClick={() => { setActiveTab(tab.key); setSidebarOpen(false); }} |
| #962 | > |
| #963 | <span className="dash-sidebar-icon">{tab.icon}</span> |
| #964 | <span>{tab.label}</span> |
| #965 | {tab.badge && tab.badge > 0 && <span className="dash-sidebar-badge">{tab.badge}</span>} |
| #966 | </button> |
| #967 | ))} |
| #968 | {!isPremium && ( |
| #969 | <button |
| #970 | type="button" |
| #971 | className="dash-sidebar-link" |
| #972 | style={{ background: "var(--green-50)", color: "var(--green-700)", fontWeight: 600, marginTop: "0.5rem", borderRadius: "0.5rem", border: "1px solid var(--green-200)" }} |
| #973 | onClick={() => { setShowUpgradeModal(true); setSidebarOpen(false); }} |
| #974 | > |
| #975 | <span className="dash-sidebar-icon">🚀</span> |
| #976 | <span>Upgrade for Unlimited</span> |
| #977 | </button> |
| #978 | )} |
| #979 | </nav> |
| #980 | <div className="dash-sidebar-footer"> |
| #981 | <button type="button" className="dash-sidebar-link" onClick={onBack}> |
| #982 | <span className="dash-sidebar-icon">🚪</span> |
| #983 | <span>Sign Out</span> |
| #984 | </button> |
| #985 | </div> |
| #986 | </aside> |
| #987 | |
| #988 | <div className={`dash-container ${sidebarOpen ? "" : ""}`}> |
| #989 | {/* Welcome Header */} |
| #990 | <div className="dash-header"> |
| #991 | <div> |
| #992 | <h1>Welcome back, {profile.fullName.split(" ")[0]}!</h1> |
| #993 | <p className="dash-org-meta"> |
| #994 | {profile.programme} · {profile.university} · {profile.city}, {profile.country} |
| #995 | </p> |
| #996 | </div> |
| #997 | <div className="youth-profile-completeness"> |
| #998 | <span className="youth-pc-label">Profile completeness</span> |
| #999 | <div className="youth-pc-bar"> |
| #1000 | <div className="youth-pc-fill" style={{ width: `${profile.profileCompleteness}%` }} /> |
| #1001 | </div> |
| #1002 | <span className="youth-pc-pct">{profile.profileCompleteness}%</span> |
| #1003 | </div> |
| #1004 | </div> |
| #1005 | |
| #1006 | {/* Overview Tab */} |
| #1007 | {activeTab === "overview" && ( |
| #1008 | <div className="dash-content"> |
| #1009 | <div className="dash-stats-grid"> |
| #1010 | <StatCard icon="📄" value={applications.length} label="Applications" color="var(--green-100)" /> |
| #1011 | <StatCard icon="⭐" value={applications.filter((a) => a.status === "Shortlisted" || a.status === "Interview").length} label="Shortlisted" color="var(--gold-100, #fef9c3)" /> |
| #1012 | <StatCard icon="✓" value={applications.filter((a) => a.status === "Accepted").length} label="Accepted" color="var(--blue-100, #dbeafe)" /> |
| #1013 | <StatCard icon="★" value={savedOpps.length} label="Saved" color="var(--purple-100, #f3e8ff)" /> |
| #1014 | </div> |
| #1015 | |
| #1016 | <div className="dash-grid-2"> |
| #1017 | <div className="dash-card"> |
| #1018 | <h3 className="dash-card-title">Recommended for You</h3> |
| #1019 | <div className="youth-recommended-list"> |
| #1020 | {opportunities.filter((o) => !o.applied).slice(0, 3).map((opp) => ( |
| #1021 | <div key={opp.id} className="youth-recommended-row"> |
| #1022 | <div className="youth-rec-avatar">{opp.organisation[0]}</div> |
| #1023 | <div className="youth-rec-info"> |
| #1024 | <span className="youth-rec-title">{opp.title}</span> |
| #1025 | <span className="youth-rec-meta">{opp.organisation} · {opp.location}</span> |
| #1026 | </div> |
| #1027 | <div className="youth-rec-match"> |
| #1028 | <span className="youth-rec-match-pct">{getMatchScore(opp, profile.skills)}%</span> |
| #1029 | <span className="youth-rec-match-label">match</span> |
| #1030 | </div> |
| #1031 | </div> |
| #1032 | ))} |
| #1033 | </div> |
| #1034 | <button |
| #1035 | type="button" |
| #1036 | className="btn btn-outline btn-sm" |
| #1037 | style={{ marginTop: "1rem" }} |
| #1038 | onClick={() => setActiveTab("opportunities")} |
| #1039 | > |
| #1040 | Browse All Opportunities |
| #1041 | </button> |
| #1042 | </div> |
| #1043 | |
| #1044 | <div className="dash-card"> |
| #1045 | <h3 className="dash-card-title">Recent Applications</h3> |
| #1046 | <div className="youth-app-list"> |
| #1047 | {applications.slice(0, 4).map((app) => ( |
| #1048 | <div key={app.id} className="youth-app-row"> |
| #1049 | <div className="youth-app-info"> |
| #1050 | <span className="youth-app-title">{app.opportunityTitle}</span> |
| #1051 | <span className="youth-app-meta">{app.organisation} · Applied {app.appliedDate}</span> |
| #1052 | </div> |
| #1053 | <span className={`dash-status dash-status-${app.status.toLowerCase().replace(" ", "")}`}>{app.status}</span> |
| #1054 | </div> |
| #1055 | ))} |
| #1056 | </div> |
| #1057 | <button |
| #1058 | type="button" |
| #1059 | className="btn btn-outline btn-sm" |
| #1060 | style={{ marginTop: "1rem" }} |
| #1061 | onClick={() => setActiveTab("applications")} |
| #1062 | > |
| #1063 | View All Applications |
| #1064 | </button> |
| #1065 | </div> |
| #1066 | </div> |
| #1067 | |
| #1068 | {/* Quick Actions */} |
| #1069 | <div className="dash-card"> |
| #1070 | <h3 className="dash-card-title">Quick Actions</h3> |
| #1071 | <div className="youth-quick-actions"> |
| #1072 | <button type="button" className="youth-quick-action" onClick={() => setActiveTab("opportunities")}> |
| #1073 | <span className="youth-qa-icon">🔍</span> |
| #1074 | <span className="youth-qa-label">Search Opportunities</span> |
| #1075 | </button> |
| #1076 | <button type="button" className="youth-quick-action" onClick={() => setActiveTab("profile")}> |
| #1077 | <span className="youth-qa-icon">✏️</span> |
| #1078 | <span className="youth-qa-label">Edit Profile</span> |
| #1079 | </button> |
| #1080 | <button type="button" className="youth-quick-action" onClick={() => setActiveTab("saved")}> |
| #1081 | <span className="youth-qa-icon">★</span> |
| #1082 | <span className="youth-qa-label">Saved Opportunities</span> |
| #1083 | </button> |
| #1084 | <button type="button" className="youth-quick-action" onClick={() => setActiveTab("notifications")}> |
| #1085 | <span className="youth-qa-icon">🔔</span> |
| #1086 | <span className="youth-qa-label">Notifications</span> |
| #1087 | {unreadCount > 0 && <span className="youth-qa-badge">{unreadCount}</span>} |
| #1088 | </button> |
| #1089 | </div> |
| #1090 | </div> |
| #1091 | </div> |
| #1092 | )} |
| #1093 | |
| #1094 | {/* Opportunities Tab */} |
| #1095 | {activeTab === "opportunities" && ( |
| #1096 | <div className="dash-content"> |
| #1097 | {!isPremium && ( |
| #1098 | <div style={{ |
| #1099 | background: monthlyCount >= 2 ? '#fef2f2' : '#f0fdf4', |
| #1100 | border: `1px solid ${monthlyCount >= 2 ? '#fecaca' : '#bbf7d0'}`, |
| #1101 | borderRadius: '0.75rem', |
| #1102 | padding: '0.75rem 1.25rem', |
| #1103 | marginBottom: '1rem', |
| #1104 | display: 'flex', |
| #1105 | justifyContent: 'space-between', |
| #1106 | alignItems: 'center', |
| #1107 | flexWrap: 'wrap', |
| #1108 | gap: '0.5rem' |
| #1109 | }}> |
| #1110 | <span style={{fontSize:'0.88rem', color: monthlyCount >= 2 ? '#991b1b' : '#15803d'}}> |
| #1111 | {monthlyCount >= 2 |
| #1112 | ? '🔒 You have used all 2 free applications this month' |
| #1113 | : `✅ Free plan: ${monthlyCount}/2 applications used this month`} |
| #1114 | </span> |
| #1115 | <button className="btn btn-primary btn-sm" onClick={() => setShowUpgradeModal(true)}> |
| #1116 | Upgrade for Unlimited |
| #1117 | </button> |
| #1118 | </div> |
| #1119 | )} |
| #1120 | <div className="dash-card"> |
| #1121 | <div className="youth-search-bar"> |
| #1122 | <div className="youth-search-input-wrap"> |
| #1123 | <span className="youth-search-icon">🔍</span> |
| #1124 | <input |
| #1125 | type="text" |
| #1126 | className="youth-search-input" |
| #1127 | placeholder="Search by title, organisation, or sector..." |
| #1128 | value={searchQuery} |
| #1129 | onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)} |
| #1130 | /> |
| #1131 | {searchQuery && ( |
| #1132 | <button type="button" className="youth-search-clear" onClick={() => setSearchQuery("")}>✕</button> |
| #1133 | )} |
| #1134 | </div> |
| #1135 | </div> |
| #1136 | |
| #1137 | <div className="youth-filters"> |
| #1138 | <div className="youth-filter-group"> |
| #1139 | <label className="youth-filter-label">Type</label> |
| #1140 | <select className="reg-input" value={filterType} onChange={(e) => setFilterType(e.target.value)}> |
| #1141 | <option value="All">All Types</option> |
| #1142 | <option value="Internship">Internship</option> |
| #1143 | <option value="Volunteering">Volunteering</option> |
| #1144 | <option value="Graduate Trainee Programme">Graduate Trainee</option> |
| #1145 | <option value="Research Assistantship">Research</option> |
| #1146 | <option value="Remote Opportunity">Remote</option> |
| #1147 | </select> |
| #1148 | </div> |
| #1149 | <div className="youth-filter-group"> |
| #1150 | <label className="youth-filter-label">Location</label> |
| #1151 | <select className="reg-input" value={filterLocation} onChange={(e) => setFilterLocation(e.target.value)}> |
| #1152 | <option value="All">All</option> |
| #1153 | <option value="Remote">Remote</option> |
| #1154 | <option value="On-site">On-site</option> |
| #1155 | <option value="Hybrid">Hybrid</option> |
| #1156 | </select> |
| #1157 | </div> |
| #1158 | <div className="youth-filter-group"> |
| #1159 | <label className="youth-filter-label">Payment</label> |
| #1160 | <select className="reg-input" value={filterPaid} onChange={(e) => setFilterPaid(e.target.value)}> |
| #1161 | <option value="All">All</option> |
| #1162 | <option value="Paid">Paid</option> |
| #1163 | <option value="Stipend">Stipend</option> |
| #1164 | <option value="Unpaid">Unpaid</option> |
| #1165 | </select> |
| #1166 | </div> |
| #1167 | <div className="youth-filter-group"> |
| #1168 | <label className="youth-filter-label">Country</label> |
| #1169 | <select className="reg-input" value={filterCountry} onChange={(e) => setFilterCountry(e.target.value)}> |
| #1170 | <option value="All">All Countries</option> |
| #1171 | <option value="Kenya">Kenya</option> |
| #1172 | <option value="Nigeria">Nigeria</option> |
| #1173 | <option value="Ghana">Ghana</option> |
| #1174 | <option value="South Africa">South Africa</option> |
| #1175 | <option value="Tanzania">Tanzania</option> |
| #1176 | <option value="Uganda">Uganda</option> |
| #1177 | <option value="Ethiopia">Ethiopia</option> |
| #1178 | <option value="Rwanda">Rwanda</option> |
| #1179 | <option value="Senegal">Senegal</option> |
| #1180 | </select> |
| #1181 | </div> |
| #1182 | <div className="youth-filter-group"> |
| #1183 | <label className="youth-filter-label">Region / City</label> |
| #1184 | <input |
| #1185 | type="text" |
| #1186 | className="reg-input" |
| #1187 | placeholder="e.g. Lagos, Nairobi" |
| #1188 | value={filterRegion === "All" ? "" : filterRegion} |
| #1189 | onChange={(e) => setFilterRegion(e.target.value || "All")} |
| #1190 | /> |
| #1191 | </div> |
| #1192 | </div> |
| #1193 | |
| #1194 | <p className="youth-results-count">{filteredOpps.length} opportunities found</p> |
| #1195 | </div> |
| #1196 | |
| #1197 | <div className="youth-opp-grid"> |
| #1198 | {isPremium ? ( |
| #1199 | filteredOpps.map((opp) => ( |
| #1200 | <OpportunityCard key={opp.id} opp={opp} onSave={handleSave} onApply={handleApply} profileSkills={profile.skills} /> |
| #1201 | )) |
| #1202 | ) : monthlyCount >= 2 ? ( |
| #1203 | <div style={{ |
| #1204 | gridColumn: "1 / -1", |
| #1205 | background: "#fef2f2", |
| #1206 | border: "2px dashed #fca5a5", |
| #1207 | borderRadius: "1rem", |
| #1208 | padding: "2.5rem", |
| #1209 | textAlign: "center", |
| #1210 | }}> |
| #1211 | <div style={{ fontSize: "2.5rem", marginBottom: "0.75rem" }}>🔒</div> |
| #1212 | <h3 style={{ marginBottom: "0.5rem", color: "#991b1b" }}> |
| #1213 | You have used all 2 free applications this month |
| #1214 | </h3> |
| #1215 | <p style={{ color: "var(--gray-500)", marginBottom: "1.5rem", fontSize: "0.9rem" }}> |
| #1216 | Opportunities are hidden until next month. Upgrade to Premium now to browse and apply to all listings. |
| #1217 | </p> |
| #1218 | <button type="button" className="btn btn-primary" onClick={() => setShowUpgradeModal(true)}> |
| #1219 | 🚀 Upgrade for Unlimited — 2,000 TZS/month |
| #1220 | </button> |
| #1221 | </div> |
| #1222 | ) : ( |
| #1223 | <> |
| #1224 | {filteredOpps.slice(0, 3).map((opp) => ( |
| #1225 | <OpportunityCard key={opp.id} opp={opp} onSave={handleSave} onApply={handleApply} profileSkills={profile.skills} /> |
| #1226 | ))} |
| #1227 | {filteredOpps.length === 0 && ( |
| #1228 | <div className="youth-empty"> |
| #1229 | <div className="youth-empty-icon">🔍</div> |
| #1230 | <h3>No opportunities found</h3> |
| #1231 | <p>Try adjusting your search or filters</p> |
| #1232 | </div> |
| #1233 | )} |
| #1234 | {filteredOpps.length > 3 && ( |
| #1235 | <div style={{ |
| #1236 | gridColumn: "1 / -1", |
| #1237 | background: "#f0fdf4", |
| #1238 | border: "2px dashed var(--green-300)", |
| #1239 | borderRadius: "1rem", |
| #1240 | padding: "2rem", |
| #1241 | textAlign: "center", |
| #1242 | }}> |
| #1243 | <div style={{ fontSize: "2rem", marginBottom: "0.75rem" }}>🔒</div> |
| #1244 | <h3 style={{ marginBottom: "0.5rem", color: "var(--gray-800)" }}> |
| #1245 | {filteredOpps.length - 3} more opportunities available |
| #1246 | </h3> |
| #1247 | <p style={{ color: "var(--gray-500)", marginBottom: "1rem", fontSize: "0.9rem" }}> |
| #1248 | Free users can view up to 3 opportunities. Upgrade to Premium to unlock all listings. |
| #1249 | </p> |
| #1250 | <button type="button" className="btn btn-primary" onClick={() => setShowUpgradeModal(true)}> |
| #1251 | 🚀 Upgrade for Unlimited — 2,000 TZS/month |
| #1252 | </button> |
| #1253 | </div> |
| #1254 | )} |
| #1255 | </> |
| #1256 | )} |
| #1257 | </div> |
| #1258 | </div> |
| #1259 | )} |
| #1260 | |
| #1261 | {/* Applications Tab */} |
| #1262 | {activeTab === "applications" && ( |
| #1263 | <div className="dash-content"> |
| #1264 | <div className="dash-card"> |
| #1265 | <div className="dash-card-header"> |
| #1266 | <h3 className="dash-card-title">My Applications</h3> |
| #1267 | <div className="youth-app-stats"> |
| #1268 | <span className="youth-app-stat">Total: {applications.length}</span> |
| #1269 | <span className="youth-app-stat">Active: {applications.filter((a) => !["Rejected", "Accepted"].includes(a.status)).length}</span> |
| #1270 | </div> |
| #1271 | </div> |
| #1272 | <div className="youth-app-timeline"> |
| #1273 | {applications.map((app) => ( |
| #1274 | <div key={app.id} className={`youth-timeline-item youth-timeline-${app.status.toLowerCase().replace(" ", "")}`}> |
| #1275 | <div className="youth-timeline-marker" /> |
| #1276 | <div className="youth-timeline-content"> |
| #1277 | <div className="youth-timeline-header"> |
| #1278 | <div> |
| #1279 | <h4 className="youth-timeline-title">{app.opportunityTitle}</h4> |
| #1280 | <span className="youth-timeline-org">{app.organisation}</span> |
| #1281 | </div> |
| #1282 | <span className={`dash-status dash-status-${app.status.toLowerCase().replace(" ", "")}`}>{app.status}</span> |
| #1283 | </div> |
| #1284 | <div className="youth-timeline-meta"> |
| #1285 | <span>Applied: {app.appliedDate}</span> |
| #1286 | {app.nextStep && <span className="youth-timeline-next">{app.nextStep}</span>} |
| #1287 | </div> |
| #1288 | </div> |
| #1289 | </div> |
| #1290 | ))} |
| #1291 | </div> |
| #1292 | </div> |
| #1293 | </div> |
| #1294 | )} |
| #1295 | |
| #1296 | {/* Saved Tab */} |
| #1297 | {activeTab === "saved" && ( |
| #1298 | <div className="dash-content"> |
| #1299 | <div className="dash-card"> |
| #1300 | <h3 className="dash-card-title">Saved Opportunities ({savedOpps.length})</h3> |
| #1301 | </div> |
| #1302 | {savedOpps.length > 0 ? ( |
| #1303 | <div className="youth-opp-grid"> |
| #1304 | {savedOpps.map((opp) => ( |
| #1305 | <OpportunityCard key={opp.id} opp={opp} onSave={handleSave} onApply={handleApply} profileSkills={profile.skills} /> |
| #1306 | ))} |
| #1307 | </div> |
| #1308 | ) : ( |
| #1309 | <div className="dash-card"> |
| #1310 | <div className="youth-empty"> |
| #1311 | <div className="youth-empty-icon">★</div> |
| #1312 | <h3>No saved opportunities yet</h3> |
| #1313 | <p>Bookmark opportunities you're interested in to review later</p> |
| #1314 | <button type="button" className="btn btn-primary" style={{ marginTop: "1rem" }} onClick={() => setActiveTab("opportunities")}> |
| #1315 | Browse Opportunities |
| #1316 | </button> |
| #1317 | </div> |
| #1318 | </div> |
| #1319 | )} |
| #1320 | </div> |
| #1321 | )} |
| #1322 | |
| #1323 | {/* Notifications Tab */} |
| #1324 | {activeTab === "notifications" && ( |
| #1325 | <div className="dash-content"> |
| #1326 | <div className="dash-card"> |
| #1327 | <div className="dash-card-header"> |
| #1328 | <h3 className="dash-card-title">Notifications</h3> |
| #1329 | <div style={{ display: "flex", gap: "0.5rem" }}> |
| #1330 | <button |
| #1331 | type="button" |
| #1332 | className="btn btn-outline btn-sm" |
| #1333 | onClick={async () => { |
| #1334 | if (!userId) return; |
| #1335 | await supabase.from("youth_notifications").update({ read: true }).eq("user_id", userId).eq("read", false); |
| #1336 | setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); |
| #1337 | }} |
| #1338 | > |
| #1339 | Mark all as read |
| #1340 | </button> |
| #1341 | <button |
| #1342 | type="button" |
| #1343 | className="btn btn-outline btn-sm" |
| #1344 | style={{ color: "#dc2626", borderColor: "#fca5a5" }} |
| #1345 | onClick={async () => { |
| #1346 | if (!userId) return; |
| #1347 | await supabase.from("youth_notifications").delete().eq("user_id", userId); |
| #1348 | setNotifications([]); |
| #1349 | }} |
| #1350 | > |
| #1351 | Delete all |
| #1352 | </button> |
| #1353 | </div> |
| #1354 | </div> |
| #1355 | <div className="youth-notif-list"> |
| #1356 | {notifications.length === 0 && ( |
| #1357 | <p style={{ padding: "2rem", textAlign: "center", color: "#9ca3af" }}>No notifications</p> |
| #1358 | )} |
| #1359 | {notifications.map((notif) => ( |
| #1360 | <div |
| #1361 | key={notif.id} |
| #1362 | className={`youth-notif-item ${notif.read ? "" : "youth-notif-unread"}`} |
| #1363 | style={{ cursor: "pointer" }} |
| #1364 | onClick={async () => { |
| #1365 | if (!notif.read) { |
| #1366 | await supabase.from("youth_notifications").update({ read: true }).eq("id", notif.id); |
| #1367 | setNotifications((prev) => prev.map((n) => (n.id === notif.id ? { ...n, read: true } : n))); |
| #1368 | } |
| #1369 | }} |
| #1370 | > |
| #1371 | <div className="youth-notif-icon"> |
| #1372 | {notif.type === "application" ? "📄" : notif.type === "opportunity" ? "🔍" : notif.type === "message" ? "✉️" : notif.type === "shortlisted" ? "⭐" : notif.type === "selected" ? "✅" : "🔔"} |
| #1373 | </div> |
| #1374 | <div className="youth-notif-content"> |
| #1375 | <h4 className="youth-notif-title">{notif.title}</h4> |
| #1376 | <p className="youth-notif-desc">{notif.desc}</p> |
| #1377 | <span className="youth-notif-time">{notif.time}</span> |
| #1378 | </div> |
| #1379 | <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}> |
| #1380 | {!notif.read && <div className="youth-notif-dot" />} |
| #1381 | <button |
| #1382 | type="button" |
| #1383 | style={{ background: "none", border: "none", cursor: "pointer", color: "#9ca3af", fontSize: "1rem", padding: "0.25rem" }} |
| #1384 | title="Delete notification" |
| #1385 | onClick={async (e) => { |
| #1386 | e.stopPropagation(); |
| #1387 | const { error: delErr } = await supabase.from("youth_notifications").delete().eq("id", notif.id); |
| #1388 | if (delErr) { |
| #1389 | console.error("Failed to delete notification:", delErr); |
| #1390 | } else { |
| #1391 | setNotifications((prev) => prev.filter((n) => n.id !== notif.id)); |
| #1392 | } |
| #1393 | }} |
| #1394 | > |
| #1395 | ✕ |
| #1396 | </button> |
| #1397 | </div> |
| #1398 | </div> |
| #1399 | ))} |
| #1400 | </div> |
| #1401 | </div> |
| #1402 | </div> |
| #1403 | )} |
| #1404 | |
| #1405 | {/* Profile Tab */} |
| #1406 | {activeTab === "profile" && ( |
| #1407 | <div className="dash-content"> |
| #1408 | <div className="dash-grid-2"> |
| #1409 | <div className="dash-card"> |
| #1410 | <div className="youth-profile-header"> |
| #1411 | <div className="youth-profile-avatar-lg">{profile.avatar}</div> |
| #1412 | <div> |
| #1413 | <h2 className="youth-profile-name">{profile.fullName}</h2> |
| #1414 | <p className="youth-profile-tagline">{profile.programme} · {profile.university}</p> |
| #1415 | <p className="youth-profile-location">📍 {profile.city}, {profile.country}</p> |
| #1416 | {profile.availability && ( |
| #1417 | <span style={{ display: "inline-block", background: profile.availability === "Full-time" ? "#d1fae5" : "#fef3c7", color: profile.availability === "Full-time" ? "#065f46" : "#92400e", padding: "0.2rem 0.75rem", borderRadius: "9999px", fontSize: "0.8rem", fontWeight: 600, marginTop: "0.5rem" }}> |
| #1418 | {profile.availability} |
| #1419 | </span> |
| #1420 | )} |
| #1421 | </div> |
| #1422 | </div> |
| #1423 | <p className="youth-profile-bio">{profile.bio}</p> |
| #1424 | |
| #1425 | <h4 className="reg-sub-heading" style={{ marginTop: "1.5rem" }}>Contact Information</h4> |
| #1426 | <div className="youth-profile-detail"> |
| #1427 | <span className="youth-pd-label">Email</span> |
| #1428 | <span className="youth-pd-value">{profile.email}</span> |
| #1429 | </div> |
| #1430 | <div className="youth-profile-detail"> |
| #1431 | <span className="youth-pd-label">Phone</span> |
| #1432 | <span className="youth-pd-value">{profile.phone}</span> |
| #1433 | </div> |
| #1434 | </div> |
| #1435 | |
| #1436 | <div> |
| #1437 | <div className="dash-card"> |
| #1438 | <h3 className="dash-card-title">Education</h3> |
| #1439 | <div className="youth-profile-detail"> |
| #1440 | <span className="youth-pd-label">University</span> |
| #1441 | <span className="youth-pd-value">{profile.university}</span> |
| #1442 | </div> |
| #1443 | <div className="youth-profile-detail"> |
| #1444 | <span className="youth-pd-label">Programme</span> |
| #1445 | <span className="youth-pd-value">{profile.programme}</span> |
| #1446 | </div> |
| #1447 | <div className="youth-profile-detail"> |
| #1448 | <span className="youth-pd-label">Level</span> |
| #1449 | <span className="youth-pd-value">{profile.levelOfStudy}</span> |
| #1450 | </div> |
| #1451 | <div className="youth-profile-detail"> |
| #1452 | <span className="youth-pd-label">Year</span> |
| #1453 | <span className="youth-pd-value">{profile.yearOfStudy}</span> |
| #1454 | </div> |
| #1455 | <div className="youth-profile-detail"> |
| #1456 | <span className="youth-pd-label">Graduation</span> |
| #1457 | <span className="youth-pd-value">{profile.graduationYear}</span> |
| #1458 | </div> |
| #1459 | </div> |
| #1460 | |
| #1461 | <div className="dash-card" style={{ marginTop: "1.5rem" }}> |
| #1462 | <h3 className="dash-card-title">Skills</h3> |
| #1463 | <div className="dash-skill-tags" style={{ marginBottom: "1rem" }}> |
| #1464 | {profile.skills.map((s) => ( |
| #1465 | <span key={s} className="dash-skill-tag">{s}</span> |
| #1466 | ))} |
| #1467 | </div> |
| #1468 | <h3 className="dash-card-title">Languages</h3> |
| #1469 | <div className="dash-skill-tags" style={{ marginBottom: "1rem" }}> |
| #1470 | {profile.languages.map((l) => ( |
| #1471 | <span key={l} className="dash-skill-tag">{l}</span> |
| #1472 | ))} |
| #1473 | </div> |
| #1474 | <div className="youth-profile-detail"> |
| #1475 | <span className="youth-pd-label">Digital Skills</span> |
| #1476 | <span className="youth-pd-value">{profile.digitalSkillsLevel}</span> |
| #1477 | </div> |
| #1478 | <div className="youth-profile-detail"> |
| #1479 | <span className="youth-pd-label">Availability</span> |
| #1480 | <span className="youth-pd-value">{profile.availability}</span> |
| #1481 | </div> |
| #1482 | </div> |
| #1483 | </div> |
| #1484 | </div> |
| #1485 | |
| #1486 | <div className="dash-card" style={{ marginTop: "1.5rem" }}> |
| #1487 | <h3 className="dash-card-title">Interested In</h3> |
| #1488 | <div className="youth-profile-section"> |
| #1489 | <h4 className="youth-ps-label">Opportunity Types</h4> |
| #1490 | <div className="dash-skill-tags"> |
| #1491 | {profile.opportunityTypes.map((t) => ( |
| #1492 | <span key={t} className="dash-skill-tag">{t}</span> |
| #1493 | ))} |
| #1494 | </div> |
| #1495 | </div> |
| #1496 | <div className="youth-profile-section"> |
| #1497 | <h4 className="youth-ps-label">Career Fields</h4> |
| #1498 | <div className="dash-skill-tags"> |
| #1499 | {profile.careerFields.map((f) => ( |
| #1500 | <span key={f} className="dash-skill-tag">{f}</span> |
| #1501 | ))} |
| #1502 | </div> |
| #1503 | </div> |
| #1504 | </div> |
| #1505 | |
| #1506 | <div className="dash-card" style={{ marginTop: "1.5rem" }}> |
| #1507 | <h3 className="dash-card-title">CV / Resume</h3> |
| #1508 | {profile.cvUrl ? ( |
| #1509 | <div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap" }}> |
| #1510 | <span style={{ background: "#d1fae5", color: "#065f46", padding: "0.25rem 0.75rem", borderRadius: "9999px", fontSize: "0.875rem", fontWeight: 600 }}>✅ CV Uploaded</span> |
| #1511 | <a href={profile.cvUrl} target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-sm">View CV</a> |
| #1512 | <label className="btn btn-outline btn-sm" style={{ cursor: "pointer", margin: 0 }}> |
| #1513 | {cvUploading ? "Uploading..." : "Replace CV"} |
| #1514 | <input type="file" accept=".pdf,.doc,.docx" style={{ display: "none" }} onChange={handleCvUpload} disabled={cvUploading} /> |
| #1515 | </label> |
| #1516 | </div> |
| #1517 | ) : ( |
| #1518 | <div style={{ display: "flex", alignItems: "center", gap: "0.75rem", flexWrap: "wrap" }}> |
| #1519 | <span style={{ color: "#9ca3af", fontSize: "0.875rem" }}>No CV uploaded yet</span> |
| #1520 | <label className="btn btn-primary btn-sm" style={{ cursor: "pointer", margin: 0 }}> |
| #1521 | {cvUploading ? "Uploading..." : "Upload CV"} |
| #1522 | <input type="file" accept=".pdf,.doc,.docx" style={{ display: "none" }} onChange={handleCvUpload} disabled={cvUploading} /> |
| #1523 | </label> |
| #1524 | </div> |
| #1525 | )} |
| #1526 | {cvMessage && ( |
| #1527 | <div style={{ marginTop: "0.75rem", padding: "0.75rem", borderRadius: "0.5rem", fontSize: "0.875rem", background: cvMessage.includes("success") ? "#d1fae5" : "#fee2e2", color: cvMessage.includes("success") ? "#065f46" : "#991b1b" }}> |
| #1528 | {cvMessage} |
| #1529 | </div> |
| #1530 | )} |
| #1531 | </div> |
| #1532 | <div style={{ marginTop: "1.5rem", display: "flex", gap: "1rem", flexWrap: "wrap" }}> |
| #1533 | <button type="button" className="btn btn-primary" onClick={() => setShowEditProfile(true)}>Edit Profile</button> |
| #1534 | </div> |
| #1535 | </div> |
| #1536 | )} |
| #1537 | </div> |
| #1538 | |
| #1539 | {/* Apply Modal */} |
| #1540 | {showApplyModal && ( |
| #1541 | <div className="dash-modal-overlay" onClick={() => { setShowApplyModal(null); setApplyError(null); }}> |
| #1542 | <div className="dash-modal" onClick={(e) => e.stopPropagation()}> |
| #1543 | <div className="dash-modal-header"> |
| #1544 | <h2>Confirm Application</h2> |
| #1545 | <button type="button" className="dash-modal-close" onClick={() => { setShowApplyModal(null); setApplyError(null); }}>✕</button> |
| #1546 | </div> |
| #1547 | <div className="dash-modal-body"> |
| #1548 | <p>You are about to apply to <strong>{opportunities.find((o) => o.id === showApplyModal)?.title}</strong> at <strong>{opportunities.find((o) => o.id === showApplyModal)?.organisation}</strong>.</p> |
| #1549 | <p style={{ marginTop: "1rem", color: "var(--gray-500)" }}>Your profile and CV will be shared with the organisation. You can track the status of your application in the "My Applications" tab.</p> |
| #1550 | {!profile.cvUrl && ( |
| #1551 | <div style={{ marginTop: "1rem", padding: "0.75rem", background: "#fef3c7", color: "#92400e", borderRadius: "0.5rem", fontSize: "0.875rem" }}> |
| #1552 | 📄 <strong>Don't forget to upload your CV!</strong> A strong CV increases your chances of getting shortlisted. Go to your Profile tab to upload one before applying. |
| #1553 | </div> |
| #1554 | )} |
| #1555 | <div style={{ marginTop: "1rem" }}> |
| #1556 | <p style={{ fontSize: "0.85rem", fontWeight: 600, marginBottom: "0.5rem" }}>Optional Documents</p> |
| #1557 | <div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}> |
| #1558 | <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 1rem", border: "1px solid #d1d5db", borderRadius: "0.5rem", cursor: "pointer", fontSize: "0.85rem", background: coverLetterFile ? "#f0fdf4" : "#fff" }}> |
| #1559 | 📝 {coverLetterFile ? coverLetterFile.name : "Cover Letter"} |
| #1560 | <input type="file" accept=".pdf,.doc,.docx" style={{ display: "none" }} onChange={(e) => setCoverLetterFile(e.target.files?.[0] || null)} /> |
| #1561 | </label> |
| #1562 | <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 1rem", border: "1px solid #d1d5db", borderRadius: "0.5rem", cursor: "pointer", fontSize: "0.85rem", background: transcriptFile ? "#f0fdf4" : "#fff" }}> |
| #1563 | 📄 {transcriptFile ? transcriptFile.name : "Academic Transcript"} |
| #1564 | <input type="file" accept=".pdf,.doc,.docx" style={{ display: "none" }} onChange={(e) => setTranscriptFile(e.target.files?.[0] || null)} /> |
| #1565 | </label> |
| #1566 | </div> |
| #1567 | </div> |
| #1568 | {applyError && ( |
| #1569 | <div style={{ marginTop: "1rem", padding: "0.75rem", background: "#fee2e2", color: "#991b1b", borderRadius: "0.5rem", fontSize: "0.875rem" }}> |
| #1570 | ⚠️ {applyError} |
| #1571 | </div> |
| #1572 | )} |
| #1573 | <div className="reg-form-actions" style={{ marginTop: "2rem" }}> |
| #1574 | <button type="button" className="btn btn-outline" onClick={() => { setShowApplyModal(null); setApplyError(null); }}>Cancel</button> |
| #1575 | <button type="button" className="btn btn-primary" onClick={confirmApply} disabled={docUploading}> |
| #1576 | {docUploading ? "Uploading..." : "Submit Application"} |
| #1577 | </button> |
| #1578 | </div> |
| #1579 | </div> |
| #1580 | </div> |
| #1581 | </div> |
| #1582 | )} |
| #1583 | |
| #1584 | {/* Upgrade Modal */} |
| #1585 | {showUpgradeModal && ( |
| #1586 | <div className="dash-modal-overlay" onClick={() => setShowUpgradeModal(false)}> |
| #1587 | <div className="dash-modal" onClick={(e) => e.stopPropagation()}> |
| #1588 | <div className="dash-modal-header"> |
| #1589 | <h2>Upgrade to Premium</h2> |
| #1590 | <button type="button" className="dash-modal-close" onClick={() => setShowUpgradeModal(false)}>✕</button> |
| #1591 | </div> |
| #1592 | <div className="dash-modal-body" style={{textAlign: 'center', padding: '2rem'}}> |
| #1593 | <div style={{fontSize: '3rem', marginBottom: '1rem'}}>🔒</div> |
| #1594 | <h3 style={{marginBottom: '0.75rem'}}> |
| #1595 | You have used your 2 free applications this month |
| #1596 | </h3> |
| #1597 | <p style={{color: 'var(--gray-500)', marginBottom: '1.5rem'}}> |
| #1598 | Upgrade to Premium for 2,000 TZS/month to get: |
| #1599 | </p> |
| #1600 | <ul style={{textAlign: 'left', marginBottom: '1.5rem', color: 'var(--gray-600)', lineHeight: '2'}}> |
| #1601 | <li>✅ Unlimited applications per month</li> |
| #1602 | <li>✅ Profile boost — appear higher to organisations</li> |
| #1603 | <li>✅ Early access to new listings</li> |
| #1604 | <li>✅ Priority application tracking</li> |
| #1605 | </ul> |
| #1606 | <button |
| #1607 | type="button" |
| #1608 | className="btn btn-primary btn-lg" |
| #1609 | style={{width: '100%', marginBottom: '0.75rem'}} |
| #1610 | onClick={() => { |
| #1611 | alert('Premium payments coming soon! Contact us on WhatsApp to upgrade manually.'); |
| #1612 | setShowUpgradeModal(false); |
| #1613 | }}> |
| #1614 | 🚀 Upgrade to Premium — 2,000 TZS/month |
| #1615 | </button> |
| #1616 | <button |
| #1617 | type="button" |
| #1618 | className="btn btn-outline" |
| #1619 | style={{width: '100%'}} |
| #1620 | onClick={() => setShowUpgradeModal(false)}> |
| #1621 | Maybe Later |
| #1622 | </button> |
| #1623 | <p style={{fontSize: '0.75rem', color: 'var(--gray-400)', marginTop: '1rem'}}> |
| #1624 | Your free applications reset on the 1st of every month |
| #1625 | </p> |
| #1626 | </div> |
| #1627 | </div> |
| #1628 | </div> |
| #1629 | )} |
| #1630 | |
| #1631 | {/* Edit Profile Modal */} |
| #1632 | {showEditProfile && ( |
| #1633 | <YouthEditProfileModal |
| #1634 | profile={profile} |
| #1635 | onClose={() => setShowEditProfile(false)} |
| #1636 | onSave={async (updated) => { |
| #1637 | setProfile(updated); |
| #1638 | setShowEditProfile(false); |
| #1639 | if (userId) { |
| #1640 | const initials = updated.fullName.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "U"; |
| #1641 | setProfile((prev) => ({ ...prev, avatar: initials })); |
| #1642 | await supabase.from("youth_profiles").upsert({ |
| #1643 | user_id: userId, |
| #1644 | full_name: updated.fullName, |
| #1645 | email: updated.email, |
| #1646 | phone: updated.phone, |
| #1647 | city: updated.city, |
| #1648 | country_of_residence: updated.country, |
| #1649 | university: updated.university, |
| #1650 | programme: updated.programme, |
| #1651 | level_of_study: updated.levelOfStudy, |
| #1652 | year_of_study: updated.yearOfStudy, |
| #1653 | graduation_year: updated.graduationYear, |
| #1654 | skills: updated.skills, |
| #1655 | languages: updated.languages, |
| #1656 | digital_skills_level: updated.digitalSkillsLevel, |
| #1657 | opportunity_types: updated.opportunityTypes, |
| #1658 | career_fields: updated.careerFields, |
| #1659 | experience_description: updated.bio, |
| #1660 | availability_type: updated.availability ? [updated.availability] : ["Full-time"], |
| #1661 | }, { onConflict: "user_id" }); |
| #1662 | } |
| #1663 | }} |
| #1664 | /> |
| #1665 | )} |
| #1666 | |
| #1667 | {/* Loading Overlay */} |
| #1668 | {loading && ( |
| #1669 | <div className="dash-modal-overlay" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}> |
| #1670 | <div style={{ textAlign: "center", color: "white" }}> |
| #1671 | <div style={{ fontSize: "2rem", marginBottom: "1rem" }}>⏳</div> |
| #1672 | <p>Loading your dashboard...</p> |
| #1673 | </div> |
| #1674 | </div> |
| #1675 | )} |
| #1676 | </div> |
| #1677 | ); |
| #1678 | } |
| #1679 |