/** * 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))); const unique = Array.from(new Set(mapped)); return unique.length ? unique : JOB_SEEKER_SAFE_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('testimonial') || t.includes('review')) return ''; if (t.includes('faq') || t.includes('question')) return 'FAQs'; return tab; }) .filter(Boolean); const uniqueRuntime = Array.from(new Set(fromRuntime)); if (uniqueRuntime.length) return uniqueRuntime; return PROFESSIONAL_SAFE_TABS[props.roleKey] || PROFESSIONAL_SAFE_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: 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) => (
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 rolePortfolioConfig = () => { const tabs = professionalTabs(); const serviceTabLabel = tabs.find((tab) => isServiceLikeTab(tab)) || ''; const experienceTabLabel = tabs.find((tab) => isExperienceLikeTab(tab)) || ''; const mediaTabLabel = tabs.find((tab) => isMediaLikeTab(tab)) || ''; const mediaMode = mediaTabLabel ? (isVisualMediaTab(mediaTabLabel) ? 'visual' : 'text') : 'none'; const mediaLimit = mediaMode === 'visual' ? 6 : mediaMode === 'text' ? 8 : 0; return { tabs, serviceTabLabel, experienceTabLabel, mediaTabLabel, mediaMode, mediaLimit, } as PortfolioRoleConfig; }; const isMediaTab = () => { const tab = normalizeToken(professionalTab()); const mediaLabel = normalizeToken(rolePortfolioConfig().mediaTabLabel || ''); return tab === mediaLabel || tab.includes('project') || tab.includes('portfolio') || tab.includes('gallery') || tab.includes('showreel') || tab.includes('case_stud') || tab.includes('student_work') || tab.includes('client_result'); }; const isProjectsTab = () => { const key = normalizeToken(professionalTab()); return isMediaTab() || 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') || key.includes('subject') || key.includes('training'); }; const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial'); const isFaqTab = () => normalizeToken(professionalTab()).includes('faq'); const isExperienceTab = () => { const key = normalizeToken(professionalTab()); return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification'); }; const isAboutTab = () => { const key = normalizeToken(professionalTab()); return !isProjectsTab() && !isServicesTab() && !isTestimonialsTab() && !isFaqTab() && !isExperienceTab(); }; return (
{(tab) => ( )}

My Portfolio

{portfolioTopTab() === 'preview' ? 'Preview how your portfolio appears to customers.' : 'Manage your portfolio content and showcase your work.'}

About

No about section added yet. Go to Edit tab to add your bio.

}>

{professionalForm().about}

{rolePortfolioConfig().serviceTabLabel}

s.name || s.amount)}> Transparent Pricing
s.name || s.amount)} fallback={

No services added yet. Go to Edit tab to add your services and pricing.

}>
{professionalForm().services.filter(s => s.name || s.amount).map((pkg, i) => (
Popular

{pkg.name || 'Service'}

{pkg.amount || 'Contact for price'}

{pkg.details.split(',').map(item => (
{item.trim()}
))}
))}

{rolePortfolioConfig().mediaTabLabel} ({items().length} items)

0} fallback={

No items added yet. Go to Edit tab and add showcase entries.

} >
{items().slice(0, rolePortfolioConfig().mediaLimit).map((item) => (
{item.title} } > {item.title}
{item.title}
))}

{rolePortfolioConfig().experienceTabLabel}

0}>

Tools & Equipment

{professionalForm().tools.map(tool => ( {tool} ))}
e.year || e.description)} fallback={

No experience added yet. Go to Edit tab to add your experience.

}>
{professionalForm().experience.filter(e => e.year || e.description).map((m, i, arr) => (

{m.year}

{m.description}

))}

FAQs

f.question || f.answer)} fallback={

No FAQs added yet. Go to Edit tab to add frequently asked questions.

}>
{professionalForm().faqs.filter(f => f.question || f.answer).map((faq) => (

{faq.question}

{faq.answer}

))}
{professionalMsg()}

{professionalTab()}