2026-04-06 17:20:48 +02:00
/ * *
* PortfolioPage — real My Portfolio CRUD , wired to backend .
2026-04-10 01:21:36 +02:00
* Uses existing / api / : rolePrefix / portfolio / me endpoints for professionals .
* Job seekers get a dedicated portfolio editor ( education / work experience / skills ) .
2026-04-06 17:20:48 +02:00
* /
import { For , Show , createSignal , onMount } from 'solid-js' ;
import { CARD , BTN_ORANGE , BTN_GHOST , BTN_PRIMARY , INPUT , LABEL } from '~/components/DashboardShell' ;
2026-04-10 01:21:36 +02:00
import { readJobSeekerProfile , updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data' ;
2026-04-06 17:20:48 +02:00
const API = '/api/gateway' ;
// ── Role prefix map ───────────────────────────────────────────────────────────
const ROLE_PREFIX : Record < string , string > = {
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 ;
}
2026-04-10 01:21:36 +02:00
interface JobSeekerPortfolioState {
headline : string ;
summary : string ;
education : string ;
workExperience : string ;
skills : string ;
}
2026-04-06 17:20:48 +02:00
const EMPTY_FORM : FormState = { title : '' , description : '' , tags : '' } ;
2026-04-10 01:21:36 +02:00
const EMPTY_JOB_SEEKER_FORM : JobSeekerPortfolioState = {
headline : '' ,
summary : '' ,
education : '' ,
workExperience : '' ,
skills : '' ,
} ;
const JOB_SEEKER_FALLBACK_TABS = [ 'About' , 'Education' , 'Work Experience' , 'Skills' ] ;
2026-04-15 06:23:28 +02:00
const PROFESSIONAL_PORTFOLIO_TABS : Record < string , string [ ] > = {
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 : '' ,
} ;
2026-04-06 17:20:48 +02:00
// ── Helpers ───────────────────────────────────────────────────────────────────
async function apiFetch ( path : string , opts? : RequestInit ) {
2026-04-22 01:32:43 +02:00
const token =
typeof window !== "undefined"
? window . sessionStorage . getItem ( "nxtgauge_access_token" ) || ""
: "" ;
2026-04-06 17:20:48 +02:00
return fetch ( ` ${ API } ${ path } ` , {
. . . opts ,
2026-04-22 01:32:43 +02:00
credentials : "include" ,
headers : {
"Content-Type" : "application/json" ,
. . . ( token ? { Authorization : ` Bearer ${ token } ` } : { } ) ,
. . . ( opts ? . headers ? ? { } ) ,
} ,
2026-04-06 17:20:48 +02:00
} ) ;
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
roleKey : string ;
2026-04-10 01:21:36 +02:00
runtimeTabs? : string [ ] ;
runtimeFields? : string [ ] ;
2026-04-06 17:20:48 +02:00
}
export default function PortfolioPage ( props : Props ) {
const prefix = ( ) = > ROLE_PREFIX [ props . roleKey ] ? ? '' ;
const isProfessional = ( ) = > Boolean ( prefix ( ) ) ;
2026-04-10 01:21:36 +02:00
const isJobSeeker = ( ) = > String ( props . roleKey || '' ) . toUpperCase ( ) === 'JOB_SEEKER' ;
2026-04-06 17:20:48 +02:00
const [ items , setItems ] = createSignal < PortfolioItem [ ] > ( [ ] ) ;
const [ loading , setLoading ] = createSignal ( true ) ;
const [ showForm , setShowForm ] = createSignal ( false ) ;
const [ editId , setEditId ] = createSignal < string | null > ( null ) ;
const [ form , setForm ] = createSignal < FormState > ( { . . . EMPTY_FORM } ) ;
const [ saving , setSaving ] = createSignal ( false ) ;
const [ deleting , setDeleting ] = createSignal < string | null > ( null ) ;
const [ error , setError ] = createSignal ( '' ) ;
2026-04-10 01:21:36 +02:00
const [ jobSeekerForm , setJobSeekerForm ] = createSignal < JobSeekerPortfolioState > ( { . . . 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 ( '' ) ;
2026-04-15 06:23:28 +02:00
const [ professionalTab , setProfessionalTab ] = createSignal ( 'About' ) ;
const [ professionalForm , setProfessionalForm ] = createSignal < ProfessionalPortfolioState > ( { . . . EMPTY_PROFESSIONAL_FORM } ) ;
const [ professionalMsg , setProfessionalMsg ] = createSignal ( '' ) ;
2026-04-06 17:20:48 +02:00
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 ) ;
}
} ;
2026-04-10 01:21:36 +02:00
const loadJobSeekerPortfolio = ( ) = > {
readJobSeekerProfile ( )
. then ( ( profile ) = > {
const parsed = profile ? . custom_data ? . job_seeker_portfolio as Record < string , unknown > | 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 < string , string [ ] > = {
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 ;
} ;
2026-04-15 06:23:28 +02:00
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 < ProfessionalPortfolioState > ;
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 ) ;
} ;
2026-04-10 01:21:36 +02:00
onMount ( ( ) = > {
if ( isJobSeeker ( ) ) {
loadJobSeekerPortfolio ( ) ;
const tabs = runtimePortfolioTabs ( ) ;
setJobSeekerTab ( tabs [ 0 ] || 'About' ) ;
setLoading ( false ) ;
return ;
}
2026-04-15 06:23:28 +02:00
if ( isProfessional ( ) ) {
const tabs = professionalTabs ( ) ;
setProfessionalTab ( tabs [ 0 ] || 'About' ) ;
loadProfessionalForm ( ) ;
}
2026-04-10 01:21:36 +02:00
void loadItems ( ) ;
} ) ;
2026-04-06 17:20:48 +02:00
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 ) ;
}
} ;
2026-04-10 01:21:36 +02:00
// ── 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 (
< div style = { { 'max-width' : '900px' } } >
< div style = { { . . . CARD , 'margin-bottom' : '14px' , padding : '0 16px' } } >
< div style = { { display : 'flex' , gap : '20px' , 'border-bottom' : '1px solid #E5E7EB' , padding : '12px 0 0' } } >
< For each = { tabs } >
{ ( tab ) = > (
< button
type = "button"
onClick = { ( ) = > setJobSeekerTab ( tab ) }
style = { {
padding : '0 0 10px' ,
border : 'none' ,
background : 'none' ,
cursor : 'pointer' ,
'font-size' : '13px' ,
'font-weight' : jobSeekerTab ( ) === tab ? '700' : '500' ,
color : jobSeekerTab ( ) === tab ? '#FF5E13' : '#6B7280' ,
'border-bottom' : jobSeekerTab ( ) === tab ? '2px solid #FF5E13' : '2px solid transparent' ,
'margin-bottom' : '-1px' ,
} }
>
{ tab }
< / button >
) }
< / For >
< / div >
< div style = { { padding : '14px 0' } } >
2026-04-22 01:26:36 +02:00
< p style = { { margin : '0' , 'font-size' : '18px' , 'font-weight' : '800' , color : '#111827' } } > My Portfolio < / p >
2026-04-10 01:21:36 +02:00
< p style = { { margin : '6px 0 0' , 'font-size' : '13px' , color : '#6B7280' } } >
Runtime - config driven form using configured tabs and fields .
< / p >
< Show when = { jobSeekerSavedAt ( ) } >
< p style = { { margin : '8px 0 0' , 'font-size' : '12px' , color : '#059669' , 'font-weight' : '600' } } >
Saved to profile at { new Date ( jobSeekerSavedAt ( ) ) . toLocaleString ( ) } .
< / p >
< / Show >
< / div >
< / div >
< div style = { { . . . CARD , display : 'grid' , gap : '12px' } } >
< Show when = { jobSeekerMsg ( ) } >
< div style = { { border : '1px solid #BBF7D0' , background : '#ECFDF5' , color : '#065F46' , 'border-radius' : '10px' , padding : '10px 12px' , 'font-size' : '12px' , 'font-weight' : '600' } } >
{ jobSeekerMsg ( ) }
< / div >
< / Show >
< Show when = { jobSeekerErr ( ) } >
< div style = { { border : '1px solid #FECACA' , background : '#FEF2F2' , color : '#B91C1C' , 'border-radius' : '10px' , padding : '10px 12px' , 'font-size' : '12px' , 'font-weight' : '600' } } >
{ jobSeekerErr ( ) }
< / div >
< / Show >
< p style = { { margin : '0' , 'font-size' : '14px' , 'font-weight' : '700' , color : '#111827' } } > { activeTab ( ) } < / p >
< div style = { { display : 'grid' , 'grid-template-columns' : '1fr 1fr' , gap : '12px' } } >
< For each = { activeFields ( ) } >
{ ( field ) = > (
< div style = { { 'grid-column' : isLongField ( field ) ? '1 / -1' : 'auto' } } >
< label style = { LABEL } > { field } < / label >
< Show
when = { ! isLongField ( field ) }
fallback = {
< textarea
rows = { 4 }
value = { readField ( field ) }
onInput = { ( e ) = > setField ( field , e . currentTarget . value ) }
placeholder = { ` Enter ${ field . toLowerCase ( ) } ` }
style = { { . . . INPUT , height : 'auto' , padding : '10px 12px' , resize : 'vertical' } }
/ >
}
>
< input
type = "text"
value = { readField ( field ) }
onInput = { ( e ) = > setField ( field , e . currentTarget . value ) }
placeholder = { ` Enter ${ field . toLowerCase ( ) } ` }
style = { INPUT }
/ >
< / Show >
< / div >
) }
< / For >
< / div >
< div style = { { display : 'flex' , gap : '10px' , 'justify-content' : 'flex-end' } } >
< button type = "button" onClick = { ( ) = > setJobSeekerForm ( { . . . EMPTY_JOB_SEEKER_FORM } ) } style = { BTN_GHOST } >
Clear
< / button >
< button type = "button" onClick = { saveJobSeekerPortfolio } disabled = { jobSeekerSaving ( ) } style = { { . . . BTN_ORANGE , opacity : jobSeekerSaving ( ) ? '0.7' : '1' } } >
{ jobSeekerSaving ( ) ? 'Saving...' : 'Save Portfolio' }
< / button >
< / div >
< / div >
< / div >
) ;
}
2026-04-06 17:20:48 +02:00
// ── Not a professional role ─────────────────────────────────────────────
if ( ! isProfessional ( ) ) {
return (
< div style = { { . . . CARD , 'text-align' : 'center' , padding : '40px' } } >
< p style = { { margin : '0' , 'font-size' : '15px' , color : '#9CA3AF' } } >
Portfolio is available for professional roles only .
< / p >
< / div >
) ;
}
2026-04-15 06:23:28 +02:00
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...' ;
} ;
2026-04-06 17:20:48 +02:00
return (
< div style = { { 'max-width' : '800px' } } >
2026-04-15 06:23:28 +02:00
< div style = { { . . . CARD , 'margin-bottom' : '14px' , padding : '0 16px' } } >
< div style = { { display : 'flex' , gap : '20px' , 'border-bottom' : '1px solid #E5E7EB' , padding : '12px 0 0' , 'flex-wrap' : 'wrap' } } >
< For each = { professionalTabs ( ) } >
{ ( tab ) = > (
< button
type = "button"
onClick = { ( ) = > setProfessionalTab ( tab ) }
style = { {
padding : '0 0 10px' ,
border : 'none' ,
background : 'none' ,
cursor : 'pointer' ,
'font-size' : '13px' ,
'font-weight' : professionalTab ( ) === tab ? '700' : '500' ,
color : professionalTab ( ) === tab ? '#FF5E13' : '#6B7280' ,
'border-bottom' : professionalTab ( ) === tab ? '2px solid #FF5E13' : '2px solid transparent' ,
'margin-bottom' : '-1px' ,
} }
>
{ tab }
< / button >
) }
< / For >
< / div >
< div style = { { display : 'flex' , 'align-items' : 'center' , 'justify-content' : 'space-between' , 'padding' : '14px 0' } } >
< div >
2026-04-22 01:26:36 +02:00
< p style = { { margin : '0' , 'font-size' : '18px' , 'font-weight' : '800' , color : '#111827' } } > My Portfolio < / p >
2026-04-15 06:23:28 +02:00
< p style = { { margin : '6px 0 0' , 'font-size' : '13px' , color : '#6B7280' } } >
Runtime - config driven tab layout aligned with external dashboard preview .
< / p >
< / div >
< Show when = { isProjectsTab ( ) } >
< button type = "button" onClick = { openCreate } style = { BTN_ORANGE } >
+ Add Item
< / button >
< / Show >
2026-04-06 17:20:48 +02:00
< / div >
< / div >
2026-04-15 06:23:28 +02:00
< Show
when = { isProjectsTab ( ) }
fallback = {
< div style = { { . . . CARD , display : 'grid' , gap : '12px' } } >
< Show when = { professionalMsg ( ) } >
< div style = { { border : '1px solid #BBF7D0' , background : '#ECFDF5' , color : '#065F46' , 'border-radius' : '10px' , padding : '10px 12px' , 'font-size' : '12px' , 'font-weight' : '600' } } >
{ professionalMsg ( ) }
< / div >
< / Show >
< p style = { { margin : '0' , 'font-size' : '14px' , 'font-weight' : '700' , color : '#111827' } } > { professionalTab ( ) } < / p >
< div >
< label style = { LABEL } > Section Content < / label >
2026-04-06 17:20:48 +02:00
< textarea
2026-04-15 06:23:28 +02:00
rows = { 8 }
value = { professionalForm ( ) [ sectionFieldKey ( ) ] }
onInput = { ( e ) = > setProfessionalForm ( ( prev ) = > ( { . . . prev , [ sectionFieldKey ( ) ] : e . currentTarget . value } ) ) }
placeholder = { sectionPlaceholder ( ) }
2026-04-06 17:20:48 +02:00
style = { { . . . INPUT , height : 'auto' , padding : '10px 12px' , resize : 'vertical' } }
/ >
< / div >
2026-04-15 06:23:28 +02:00
< div style = { { display : 'flex' , gap : '10px' , 'justify-content' : 'flex-end' } } >
< button type = "button" onClick = { ( ) = > setProfessionalForm ( { . . . EMPTY_PROFESSIONAL_FORM } ) } style = { BTN_GHOST } >
Clear
< / button >
< button type = "button" onClick = { saveProfessionalForm } style = { BTN_ORANGE } >
Save Section
< / button >
2026-04-06 17:20:48 +02:00
< / div >
< / div >
2026-04-15 06:23:28 +02:00
}
>
< div >
< Show when = { showForm ( ) } >
< div style = { { . . . CARD , 'margin-bottom' : '16px' , border : '1px solid #FF5E13' } } >
< p style = { { margin : '0 0 16px' , 'font-size' : '16px' , 'font-weight' : '800' , color : '#0D0D2A' } } >
{ editId ( ) ? 'Edit Portfolio Item' : 'New Portfolio Item' }
< / p >
< div style = { { display : 'grid' , 'grid-template-columns' : '1fr 1fr' , gap : '14px' } } >
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > Title < span style = { { color : '#EF4444' } } > * < / span > < / label >
< input type = "text" placeholder = "e.g. Developer dashboard rebuild" value = { form ( ) . title } onInput = { ( e ) = > setField ( 'title' , e . currentTarget . value ) } style = { INPUT } / >
2026-04-06 17:20:48 +02:00
< / div >
2026-04-15 06:23:28 +02:00
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > Description < / label >
< textarea rows = { 3 } placeholder = "Brief description of the project..." value = { form ( ) . description } onInput = { ( e ) = > setField ( 'description' , e . currentTarget . value ) } style = { { . . . INPUT , height : 'auto' , padding : '10px 12px' , resize : 'vertical' } } / >
< / div >
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > Tags ( comma separated ) < / label >
< input type = "text" placeholder = "e.g. solidjs, rust, dashboard" value = { form ( ) . tags } onInput = { ( e ) = > setField ( 'tags' , e . currentTarget . value ) } style = { INPUT } / >
2026-04-06 17:20:48 +02:00
< / div >
< / div >
2026-04-15 06:23:28 +02:00
< Show when = { error ( ) } >
< p style = { { margin : '12px 0 0' , 'font-size' : '13px' , color : '#EF4444' , 'font-weight' : '600' } } > { error ( ) } < / p >
< / Show >
< div style = { { display : 'flex' , gap : '10px' , 'margin-top' : '16px' } } >
< button type = "button" onClick = { handleSave } disabled = { saving ( ) } style = { { . . . BTN_PRIMARY , opacity : saving ( ) ? '0.6' : '1' } } >
{ saving ( ) ? 'Saving…' : editId ( ) ? 'Update Item' : 'Add Item' }
< / button >
< button type = "button" onClick = { cancelForm } style = { BTN_GHOST } > Cancel < / button >
< / div >
< / div >
< / Show >
< Show when = { loading ( ) } >
< div style = { { . . . CARD , 'text-align' : 'center' , padding : '32px' , color : '#9CA3AF' , 'font-size' : '14px' } } >
Loading portfolio …
< / div >
< / Show >
< Show when = { ! loading ( ) && items ( ) . length === 0 && ! showForm ( ) } >
< div style = { { . . . CARD , 'text-align' : 'center' , padding : '48px 24px' } } >
< p style = { { margin : '0' , 'font-size' : '40px' } } > 🗂 ️ < / p >
< p style = { { margin : '12px 0 4px' , 'font-size' : '16px' , 'font-weight' : '700' , color : '#111827' } } > No portfolio items yet < / p >
< p style = { { margin : '0 0 16px' , 'font-size' : '13px' , color : '#6B7280' } } > Add your first work sample to attract clients . < / p >
< button type = "button" onClick = { openCreate } style = { BTN_ORANGE } > + Add First Item < / button >
< / div >
< / Show >
< Show when = { ! loading ( ) && items ( ) . length > 0 } >
< div style = { { display : 'grid' , 'grid-template-columns' : '1fr 1fr' , gap : '12px' } } >
< For each = { items ( ) } >
{ ( item ) = > (
< div style = { { . . . CARD , padding : '16px' , display : 'flex' , 'flex-direction' : 'column' , gap : '8px' } } >
< div style = { { height : '120px' , 'border-radius' : '8px' , background : '#F3F4F6' , display : 'flex' , 'align-items' : 'center' , 'justify-content' : 'center' , color : '#D1D5DB' , 'font-size' : '28px' } } > 🖼 ️ < / div >
< p style = { { margin : '0' , 'font-size' : '14px' , 'font-weight' : '700' , color : '#111827' } } > { item . title } < / p >
< Show when = { item . description } >
< p style = { { margin : '0' , 'font-size' : '12px' , color : '#6B7280' , 'line-height' : '1.5' } } > { item . description } < / p >
< / Show >
< Show when = { item . tags && item . tags . length > 0 } >
< div style = { { display : 'flex' , 'flex-wrap' : 'wrap' , gap : '4px' } } >
< For each = { item . tags } >
{ ( tag ) = > < span style = { { 'font-size' : '10px' , 'font-weight' : '700' , color : '#6B7280' , background : '#F3F4F6' , border : '1px solid #E5E7EB' , 'border-radius' : '6px' , padding : '2px 8px' } } > { tag } < / span > }
< / For >
< / div >
< / Show >
< div style = { { display : 'flex' , gap : '8px' , 'margin-top' : '4px' } } >
< button type = "button" onClick = { ( ) = > openEdit ( item ) } style = { { . . . BTN_GHOST , height : '30px' , 'font-size' : '11px' , padding : '0 12px' , flex : '1' } } > Edit < / button >
< button
type = "button"
onClick = { ( ) = > handleDelete ( item . id ) }
disabled = { deleting ( ) === item . id }
style = { { height : '30px' , 'border-radius' : '8px' , border : '1px solid #FECACA' , background : '#fff' , color : '#EF4444' , 'font-size' : '11px' , 'font-weight' : '700' , padding : '0 12px' , cursor : 'pointer' , flex : '1' , opacity : deleting ( ) === item . id ? '0.6' : '1' } }
>
{ deleting ( ) === item . id ? '…' : 'Delete' }
< / button >
< / div >
< / div >
) }
< / For >
< / div >
< / Show >
2026-04-06 17:20:48 +02:00
< / div >
< / Show >
< / div >
) ;
}