/** * 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']; // ── 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 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; }; onMount(() => { if (isJobSeeker()) { loadJobSeekerPortfolio(); const tabs = runtimePortfolioTabs(); setJobSeekerTab(tabs[0] || 'About'); setLoading(false); return; } 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.

); } return (
{/* ── Header ────────────────────────────────────────────────────── */}

My Portfolio

Showcase your work to attract clients.

{/* ── Create / Edit form ─────────────────────────────────────────── */}

{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}

setField('title', e.currentTarget.value)} style={INPUT} />