/** * PortfolioPage — real My Portfolio CRUD, wired to backend. * Uses existing /api/:rolePrefix/portfolio/me endpoints for professionals. * Job seekers get a dedicated portfolio editor (education/work experience/skills). */ import { For, Show, createSignal, onMount } from 'solid-js'; import { Coins, Image, BriefcaseBusiness, UserCircle2, CheckCircle2 } from 'lucide-solid'; import { CARD, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL, NAVY } from '~/components/DashboardShell'; import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data'; const API = '/api/gateway'; const BTN_NAVY = { height: '36px', 'border-radius': '8px', border: 'none', background: NAVY, color: 'white', 'font-size': '13px', 'font-weight': '700', padding: '0 16px', cursor: 'pointer', display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', gap: '6px', }; // ── Role prefix map ─────────────────────────────────────────────────────────── const ROLE_PREFIX: Record = { PHOTOGRAPHER: 'photographers', MAKEUP_ARTIST: 'makeup-artists', TUTOR: 'tutors', DEVELOPER: 'developers', VIDEO_EDITOR: 'video-editors', GRAPHIC_DESIGNER: 'graphic-designers', SOCIAL_MEDIA_MANAGER:'social-media-managers', FITNESS_TRAINER: 'fitness-trainers', CATERING_SERVICES: 'catering-services', UGC_CONTENT_CREATOR: 'ugc-content-creators', }; // ── Types ───────────────────────────────────────────────────────────────────── interface PortfolioItem { id: string; title: string; description?: string; tags?: string[]; created_at?: string; } interface FormState { title: string; description: string; tags: string; mediaUrl: string; } interface JobSeekerPortfolioState { headline: string; summary: string; education: string; workExperience: string; skills: string; } const JOB_SEEKER_SAFE_TABS = ['About', 'Education', 'Work Experience', 'Skills']; const PROFESSIONAL_SAFE_TABS: Record = { PHOTOGRAPHER: ['About', 'Services & Pricing', 'Portfolio Gallery', 'Experience & Equipment', 'FAQs'], MAKEUP_ARTIST: ['About', 'Services & Pricing', 'Gallery', 'Experience & Certifications', 'FAQs'], TUTOR: ['About', 'Subjects & Pricing', 'Student Work', 'Qualifications', 'FAQs'], DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'FAQs'], VIDEO_EDITOR: ['About', 'Services & Pricing', 'Showreel', 'Experience & Tools', 'FAQs'], UGC_CONTENT_CREATOR: ['About', 'Services & Pricing', 'Content Portfolio', 'Experience & Tools', 'FAQs'], GRAPHIC_DESIGNER: ['About', 'Services & Pricing', 'Portfolio', 'Experience & Tools', 'FAQs'], SOCIAL_MEDIA_MANAGER: ['About', 'Services & Pricing', 'Case Studies', 'Experience & Tools', 'FAQs'], FITNESS_TRAINER: ['About', 'Training Plans', 'Client Results', 'Certifications', 'FAQs'], CATERING_SERVICES: ['About', 'Packages & Pricing', 'Gallery', 'Experience & Certifications', 'FAQs'], default: ['About', 'Services & Pricing', 'Portfolio', 'Experience', 'FAQs'], }; const EMPTY_FORM: FormState = { title: '', description: '', tags: '', mediaUrl: '' }; const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = { headline: '', summary: '', education: '', workExperience: '', skills: '', }; type PortfolioRoleConfig = { tabs: string[]; serviceTabLabel: string; experienceTabLabel: string; mediaTabLabel?: string; mediaMode: 'none' | 'visual' | 'text'; mediaLimit: number; }; type ServiceEntry = { name: string; pricingType: string; amount: string; details: string; }; type ExperienceMilestone = { year: string; description: string; }; type TestimonialEntry = { name: string; rating: number; text: string; }; type FaqEntry = { question: string; answer: string; }; type ProfessionalPortfolioState = { about: string; services: ServiceEntry[]; experience: ExperienceMilestone[]; testimonials: TestimonialEntry[]; faqs: FaqEntry[]; tools: string[]; }; const EMPTY_SERVICE: ServiceEntry = { name: '', pricingType: 'Fixed', amount: '', details: '' }; const EMPTY_MILESTONE: ExperienceMilestone = { year: '', description: '' }; const EMPTY_TESTIMONIAL: TestimonialEntry = { name: '', rating: 5, text: '' }; const EMPTY_FAQ: FaqEntry = { question: '', answer: '' }; const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = { about: '', services: [{ ...EMPTY_SERVICE }], experience: [{ ...EMPTY_MILESTONE }], testimonials: [{ ...EMPTY_TESTIMONIAL }], faqs: [{ ...EMPTY_FAQ }], tools: [], }; function isServiceLikeTab(tab: string): boolean { const key = String(tab || '').toLowerCase(); return key.includes('service') || key.includes('pricing') || key.includes('package') || key.includes('subject') || key.includes('training'); } function isExperienceLikeTab(tab: string): boolean { const key = String(tab || '').toLowerCase(); return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification'); } function isMediaLikeTab(tab: string): boolean { const key = String(tab || '').toLowerCase(); return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel') || key.includes('case studies') || key.includes('case_studies') || key.includes('student work') || key.includes('client results') || key.includes('content portfolio'); } function isVisualMediaTab(tab: string): boolean { const key = String(tab || '').toLowerCase(); return key.includes('gallery') || key.includes('showreel') || key.includes('portfolio'); } function parseListFromLegacyText(value: string): string[] { return String(value || '') .split(/\n|,/g) .map((item) => item.trim()) .filter(Boolean); } function parseServiceEntries(value: unknown): ServiceEntry[] { if (Array.isArray(value)) { const parsed = value .map((item) => ({ name: String((item as any)?.name || ''), pricingType: String((item as any)?.pricingType || 'Fixed') || 'Fixed', amount: String((item as any)?.amount || ''), details: String((item as any)?.details || ''), })) .filter((item) => item.name || item.amount || item.details); return parsed.length ? parsed : [{ ...EMPTY_SERVICE }]; } if (typeof value === 'string' && value.trim()) { return parseListFromLegacyText(value).map((line) => ({ name: line, pricingType: 'Fixed', amount: '', details: '', })); } return [{ ...EMPTY_SERVICE }]; } function parseExperienceEntries(value: unknown): ExperienceMilestone[] { if (Array.isArray(value)) { const parsed = value .map((item) => ({ year: String((item as any)?.year || ''), description: String((item as any)?.description || ''), })) .filter((item) => item.year || item.description); return parsed.length ? parsed : [{ ...EMPTY_MILESTONE }]; } if (typeof value === 'string' && value.trim()) { return parseListFromLegacyText(value).map((line) => ({ year: '', description: line })); } return [{ ...EMPTY_MILESTONE }]; } function parseTestimonials(value: unknown): TestimonialEntry[] { if (Array.isArray(value)) { const parsed = value .map((item) => ({ name: String((item as any)?.name || ''), rating: Number((item as any)?.rating || 5) || 5, text: String((item as any)?.text || ''), })) .filter((item) => item.name || item.text); return parsed.length ? parsed : [{ ...EMPTY_TESTIMONIAL }]; } if (typeof value === 'string' && value.trim()) { return parseListFromLegacyText(value).map((line) => ({ name: '', rating: 5, text: line })); } return [{ ...EMPTY_TESTIMONIAL }]; } function parseFaqEntries(value: unknown): FaqEntry[] { if (Array.isArray(value)) { const parsed = value .map((item) => ({ question: String((item as any)?.question || ''), answer: String((item as any)?.answer || ''), })) .filter((item) => item.question || item.answer); return parsed.length ? parsed : [{ ...EMPTY_FAQ }]; } if (typeof value === 'string' && value.trim()) { return parseListFromLegacyText(value).map((line) => ({ question: line, answer: '' })); } return [{ ...EMPTY_FAQ }]; } function parseTools(value: unknown): string[] { if (Array.isArray(value)) { return value.map((item) => String(item || '').trim()).filter(Boolean); } if (typeof value === 'string' && value.trim()) { return parseListFromLegacyText(value); } return []; } function parseMediaDescription(raw?: string): { mediaUrl: string; description: string } { const value = String(raw || ''); if (!value.startsWith('MEDIA_URL:')) return { mediaUrl: '', description: value }; const lines = value.split('\n'); const mediaUrl = String(lines[0] || '').replace('MEDIA_URL:', '').trim(); const description = lines.slice(1).join('\n').trim(); return { mediaUrl, description }; } function buildMediaDescription(mediaUrl: string, description: string): string { const cleanUrl = String(mediaUrl || '').trim(); const cleanDescription = String(description || '').trim(); if (!cleanUrl) return cleanDescription; return `MEDIA_URL:${cleanUrl}${cleanDescription ? `\n${cleanDescription}` : ''}`; } // ── Helpers ─────────────────────────────────────────────────────────────────── async function apiFetch(path: string, opts?: RequestInit) { const token = typeof window !== "undefined" ? window.sessionStorage.getItem("nxtgauge_access_token") || "" : ""; return fetch(`${API}${path}`, { ...opts, credentials: "include", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts?.headers ?? {}), }, }); } // ── Component ───────────────────────────────────────────────────────────────── interface Props { roleKey: string; runtimeTabs?: string[]; runtimeFields?: string[]; } export default function PortfolioPage(props: Props) { const prefix = () => ROLE_PREFIX[props.roleKey] ?? ''; const isProfessional = () => Boolean(prefix()); const isJobSeeker = () => String(props.roleKey || '').toUpperCase() === 'JOB_SEEKER'; const [items, setItems] = createSignal([]); const [loading, setLoading] = createSignal(true); const [showForm, setShowForm] = createSignal(false); const [editId, setEditId] = createSignal(null); const [form, setForm] = createSignal({ ...EMPTY_FORM }); const [saving, setSaving] = createSignal(false); const [deleting, setDeleting] = createSignal(null); const [error, setError] = createSignal(''); const [jobSeekerForm, setJobSeekerForm] = createSignal({ ...EMPTY_JOB_SEEKER_FORM }); const [jobSeekerSavedAt, setJobSeekerSavedAt] = createSignal(''); const [jobSeekerTab, setJobSeekerTab] = createSignal('About'); const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false); const [jobSeekerMsg, setJobSeekerMsg] = createSignal(''); const [jobSeekerErr, setJobSeekerErr] = createSignal(''); const [professionalTab, setProfessionalTab] = createSignal('About'); const [professionalForm, setProfessionalForm] = createSignal({ ...EMPTY_PROFESSIONAL_FORM }); const [professionalMsg, setProfessionalMsg] = createSignal(''); const [portfolioTopTab, setPortfolioTopTab] = createSignal<'edit' | 'preview'>('edit'); const loadItems = async () => { if (!isProfessional()) { setLoading(false); return; } setLoading(true); try { const res = await apiFetch(`/api/${prefix()}/portfolio/me`); if (res.ok) { const data = await res.json(); setItems(Array.isArray(data) ? data : (data.items ?? [])); } } finally { setLoading(false); } }; const loadJobSeekerPortfolio = () => { readJobSeekerProfile() .then((profile) => { const parsed = profile?.custom_data?.job_seeker_portfolio as Record | undefined; if (!parsed || typeof parsed !== 'object') return; setJobSeekerForm({ headline: String(parsed?.headline || ''), summary: String(parsed?.summary || ''), education: String(parsed?.education || ''), workExperience: String(parsed?.workExperience || ''), skills: String(parsed?.skills || ''), }); setJobSeekerSavedAt(String(parsed?.savedAt || '')); }) .catch(() => { // ignore non-blocking load errors }); }; const saveJobSeekerPortfolio = () => { setJobSeekerSaving(true); setJobSeekerMsg(''); setJobSeekerErr(''); const savedAt = new Date().toISOString(); const payload = { ...jobSeekerForm(), savedAt }; updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: payload })) .then(() => { setJobSeekerSavedAt(savedAt); setJobSeekerMsg('Portfolio saved successfully.'); }) .catch((e: any) => { setJobSeekerErr(e?.message || 'Failed to save portfolio.'); }) .finally(() => { setJobSeekerSaving(false); }); }; const normalizeToken = (value: string) => String(value || '').trim().toLowerCase().replace(/[_-]+/g, ' '); const toLabel = (value: string) => String(value || '') .trim() .replace(/[_-]+/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); const runtimePortfolioTabs = () => { const raw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : []; const allowed = new Set(['about', 'education', 'work experience', 'skills']); const mapped = raw .map((tab) => toLabel(tab)) .filter(Boolean) .map((tab) => { const t = normalizeToken(tab); if (t.includes('education')) return 'Education'; if (t.includes('work') || t.includes('experience')) return 'Work Experience'; if (t.includes('skill')) return 'Skills'; if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About'; return ''; }) .filter((tab) => Boolean(tab) && allowed.has(normalizeToken(tab))); return Array.from(new Set(mapped)); }; const runtimeFieldsByTab = () => { const fromRuntime = Array.isArray(props.runtimeFields) ? props.runtimeFields.map((f) => toLabel(String(f || ''))).filter(Boolean) : []; const grouped: Record = { About: [], Education: [], 'Work Experience': [], Skills: [], }; for (const field of fromRuntime) { const key = normalizeToken(field); if (key.includes('education') || key.includes('college') || key.includes('degree')) grouped.Education.push(field); else if (key.includes('work') || key.includes('experience') || key.includes('employment')) grouped['Work Experience'].push(field); else if (key.includes('skill') || key.includes('tool') || key.includes('technology')) grouped.Skills.push(field); else grouped.About.push(field); } return grouped; }; // ── Required field validation for Save button ──────────────────────────── // Map field label -> jobSeekerForm key const jobSeekerFieldKey = (label: string): string => { const key = normalizeToken(label); if (key.includes('headline')) return 'headline'; if (key.includes('summary')) return 'summary'; if (key.includes('education')) return 'education'; if (key.includes('work') || key.includes('experience')) return 'workExperience'; if (key.includes('skill')) return 'skills'; return ''; }; // True when all required fields for the active tab have non-empty values const jobSeekerTabComplete = () => { if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false; const fields = runtimeFieldsByTab()[jobSeekerTab()] ?? []; return fields.every((field) => { const key = jobSeekerFieldKey(field); const val = key ? (jobSeekerForm() as any)[key] : ''; return String(val || '').trim().length > 0; }); }; // True when all required professional sections (from runtimeFields) have non-empty values const professionalFormComplete = () => { if (!Array.isArray(props.runtimeFields) || props.runtimeFields.length === 0) return false; const requiredSections = props.runtimeFields.map((f) => normalizeToken(f)).filter(Boolean); const form = professionalForm(); let complete = true; for (const section of requiredSections) { if (section.includes('about')) { if (!String(form.about || '').trim()) { complete = false; break; } } else if (section.includes('service')) { const hasService = form.services.some( (s) => String(s.name || '').trim() || String(s.amount || '').trim() ); if (!hasService) { complete = false; break; } } else if (section.includes('experience') || section.includes('tool')) { const hasExp = form.experience.some( (e) => String(e.year || '').trim() || String(e.description || '').trim() ); const hasTools = form.tools.some((t) => String(t || '').trim()); if (!hasExp && !hasTools) { complete = false; break; } } else if (section.includes('faq')) { const hasFaq = form.faqs.some( (f) => String(f.question || '').trim() && String(f.answer || '').trim() ); if (!hasFaq) { complete = false; break; } } else if (section.includes('testimonial')) { // testimonials optional for save } } return complete; }; const professionalTabs = () => { const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : []; const fromRuntime = runtimeRaw .map((tab) => toLabel(tab)) .filter(Boolean) .map((tab) => { const t = normalizeToken(tab); if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About'; if (t.includes('testimonial') || t.includes('review')) return ''; if (t.includes('faq') || t.includes('question')) return 'FAQs'; return tab; }) .filter(Boolean); return Array.from(new Set(fromRuntime)); }; const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`; const loadProfessionalForm = () => { if (typeof window === 'undefined') return; try { const raw = window.localStorage.getItem(professionalFormStorageKey()); if (!raw) return; const parsed = JSON.parse(raw) as Partial; setProfessionalForm({ about: String(parsed?.about || ''), services: parseServiceEntries(parsed?.services), experience: parseExperienceEntries(parsed?.experience), testimonials: parseTestimonials(parsed?.testimonials), faqs: parseFaqEntries(parsed?.faqs), tools: parseTools((parsed as any)?.tools), }); } catch { // Ignore malformed local storage payloads. } }; const saveProfessionalForm = () => { if (typeof window !== 'undefined') { window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(professionalForm())); } setProfessionalMsg('Portfolio section saved.'); window.setTimeout(() => setProfessionalMsg(''), 1800); }; onMount(() => { if (isJobSeeker()) { loadJobSeekerPortfolio(); const tabs = runtimePortfolioTabs(); setJobSeekerTab(tabs[0] || ''); setLoading(false); return; } if (isProfessional()) { const tabs = professionalTabs(); setProfessionalTab(tabs[0] || ''); loadProfessionalForm(); } void loadItems(); }); const openCreate = () => { const config = rolePortfolioConfig(); if (config.mediaMode === 'visual' && isMediaTab() && items().length >= config.mediaLimit) { setProfessionalMsg(`You can add up to ${config.mediaLimit} showcase images for ${props.roleKey.toLowerCase().replace(/_/g, ' ')}.`); window.setTimeout(() => setProfessionalMsg(''), 2200); return; } setEditId(null); setForm({ ...EMPTY_FORM }); setError(''); setShowForm(true); }; const openEdit = (item: PortfolioItem) => { const parsedMedia = parseMediaDescription(item.description); setEditId(item.id); setForm({ title: item.title, description: parsedMedia.description, tags: (item.tags ?? []).join(', '), mediaUrl: parsedMedia.mediaUrl, }); setError(''); setShowForm(true); }; const cancelForm = () => { setShowForm(false); setEditId(null); setForm({ ...EMPTY_FORM }); setError(''); }; const setField = (key: keyof FormState, val: string) => setForm((prev) => ({ ...prev, [key]: val })); const handleSave = async () => { if (!form().title.trim()) { setError('Title is required.'); return; } if (rolePortfolioConfig().mediaMode === 'visual' && isMediaTab() && !form().mediaUrl.trim()) { setError('Image URL is required for showcase items.'); return; } setSaving(true); setError(''); const descriptionPayload = rolePortfolioConfig().mediaMode === 'visual' && isMediaTab() ? buildMediaDescription(form().mediaUrl, form().description) : form().description.trim(); const payload = { title: form().title.trim(), description: descriptionPayload || undefined, tags: form().tags .split(',') .map((t) => t.trim()) .filter(Boolean), }; try { const id = editId(); const res = id ? await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }) : await apiFetch(`/api/${prefix()}/portfolio/me`, { method: 'POST', body: JSON.stringify(payload) }); if (res.ok) { await loadItems(); cancelForm(); } else { const d = await res.json().catch(() => ({})); setError(d.error ?? d.message ?? 'Failed to save. Please try again.'); } } catch { setError('Network error. Please try again.'); } finally { setSaving(false); } }; const handleDelete = async (id: string) => { if (!confirm('Delete this portfolio item?')) return; setDeleting(id); try { await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'DELETE' }); await loadItems(); } finally { setDeleting(null); } }; // ── Job seeker portfolio editor ───────────────────────────────────────── if (isJobSeeker()) { const setField = (fieldKey: string, value: string) => { const key = normalizeToken(fieldKey); setJobSeekerForm((prev) => { if (key.includes('headline')) return { ...prev, headline: value }; if (key.includes('summary') || key.includes('about')) return { ...prev, summary: value }; if (key.includes('education') || key.includes('degree') || key.includes('college')) return { ...prev, education: value }; if (key.includes('work') || key.includes('experience') || key.includes('employment')) return { ...prev, workExperience: value }; if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return { ...prev, skills: value }; return { ...prev, summary: value }; }); }; const readField = (fieldKey: string) => { const key = normalizeToken(fieldKey); if (key.includes('headline')) return jobSeekerForm().headline; if (key.includes('summary') || key.includes('about')) return jobSeekerForm().summary; if (key.includes('education') || key.includes('degree') || key.includes('college')) return jobSeekerForm().education; if (key.includes('work') || key.includes('experience') || key.includes('employment')) return jobSeekerForm().workExperience; if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return jobSeekerForm().skills; return ''; }; const tabs = runtimePortfolioTabs(); const fieldsByTab = runtimeFieldsByTab(); const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : tabs[0]; const activeFields = () => fieldsByTab[activeTab()] || []; const isLongField = (field: string) => { const key = normalizeToken(field); return key.includes('summary') || key.includes('about') || key.includes('experience') || key.includes('education') || key.includes('skills'); }; return (
{(tab) => ( )}

My Portfolio

Runtime-config driven form using configured tabs and fields.

Saved to profile at {new Date(jobSeekerSavedAt()).toLocaleString()}.

{jobSeekerMsg()}
{jobSeekerErr()}

{activeTab()}

{(field) => { const fieldKey = normalizeToken(field); const isLong = isLongField(field); const value = readField(field); const isFilled = value.trim().length > 0; return (