/** * 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 { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell'; import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data'; const API = '/api/gateway'; // ── 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; } interface JobSeekerPortfolioState { headline: string; summary: string; education: string; workExperience: string; skills: string; } const EMPTY_FORM: FormState = { title: '', description: '', tags: '' }; const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = { headline: '', summary: '', education: '', workExperience: '', skills: '', }; const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills']; const PROFESSIONAL_PORTFOLIO_TABS: Record = { DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'Testimonials', 'FAQs'], default: ['About', 'Services & Pricing', 'Projects', 'Experience', 'Testimonials', 'FAQs'], }; type ProfessionalPortfolioState = { about: string; services: string; experience: string; testimonials: string; faqs: string; }; const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = { about: '', services: '', experience: '', testimonials: '', faqs: '', }; // ── Helpers ─────────────────────────────────────────────────────────────────── async function apiFetch(path: string, opts?: RequestInit) { return fetch(`${API}${path}`, { ...opts, credentials: 'include', headers: { 'Content-Type': 'application/json', ...(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 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))); const unique = Array.from(new Set(mapped)); return unique.length ? unique : JOB_SEEKER_FALLBACK_TABS; }; 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); } if (!grouped.About.length) grouped.About = ['Professional Headline', 'Career Summary']; if (!grouped.Education.length) grouped.Education = ['Education']; if (!grouped['Work Experience'].length) grouped['Work Experience'] = ['Work Experience']; if (!grouped.Skills.length) grouped.Skills = ['Skills']; return grouped; }; 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('service') || t.includes('pricing') || t.includes('package')) return 'Services & Pricing'; if (t.includes('project') || t.includes('portfolio') || t.includes('gallery') || t.includes('showreel')) return 'Projects'; if (t.includes('stack') || t.includes('experience') || t.includes('qualification') || t.includes('tool')) return props.roleKey === 'DEVELOPER' ? 'Tech Stack & Experience' : 'Experience'; if (t.includes('testimonial') || t.includes('review')) return 'Testimonials'; if (t.includes('faq') || t.includes('question')) return 'FAQs'; return ''; }) .filter(Boolean); const uniqueRuntime = Array.from(new Set(fromRuntime)); if (uniqueRuntime.length >= 3) return uniqueRuntime; return PROFESSIONAL_PORTFOLIO_TABS[props.roleKey] || PROFESSIONAL_PORTFOLIO_TABS.default; }; 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: String(parsed?.services || ''), experience: String(parsed?.experience || ''), testimonials: String(parsed?.testimonials || ''), faqs: String(parsed?.faqs || ''), }); } 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] || 'About'); setLoading(false); return; } if (isProfessional()) { const tabs = professionalTabs(); setProfessionalTab(tabs[0] || 'About'); loadProfessionalForm(); } void loadItems(); }); const openCreate = () => { setEditId(null); setForm({ ...EMPTY_FORM }); setError(''); setShowForm(true); }; const openEdit = (item: PortfolioItem) => { setEditId(item.id); setForm({ title: item.title, description: item.description ?? '', tags: (item.tags ?? []).join(', '), }); 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; } setSaving(true); setError(''); const payload = { title: form().title.trim(), description: form().description.trim() || 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] || 'About'); 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) => (
setField(field, e.currentTarget.value)} placeholder={`Enter ${field.toLowerCase()}`} style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }} /> } > setField(field, e.currentTarget.value)} placeholder={`Enter ${field.toLowerCase()}`} style={INPUT} />
)}
); } // ── Not a professional role ───────────────────────────────────────────── if (!isProfessional()) { return (

Portfolio is available for professional roles only.

); } const isProjectsTab = () => { const key = normalizeToken(professionalTab()); return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel'); }; const isServicesTab = () => { const key = normalizeToken(professionalTab()); return key.includes('service') || key.includes('pricing') || key.includes('package'); }; const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial'); const isFaqTab = () => normalizeToken(professionalTab()).includes('faq'); const sectionFieldKey = () => { if (isServicesTab()) return 'services' as const; if (isTestimonialsTab()) return 'testimonials' as const; if (isFaqTab()) return 'faqs' as const; const key = normalizeToken(professionalTab()); if (key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification')) return 'experience' as const; return 'about' as const; }; const sectionPlaceholder = () => { if (isServicesTab()) return 'List your plans, pricing slabs, and deliverables...'; if (isTestimonialsTab()) return 'Add client quotes and project outcomes...'; if (isFaqTab()) return 'Add common questions and answers...'; if (sectionFieldKey() === 'experience') return 'Share stack, years of experience, and toolchain...'; return 'Write a short summary about your profile and strengths...'; }; return (
{(tab) => ( )}

My Portfolio

Runtime-config driven tab layout aligned with external dashboard preview.

{professionalMsg()}

{professionalTab()}