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' ;
2026-04-26 23:58:43 +02:00
import { Coins , Image , BriefcaseBusiness , UserCircle2 , CheckCircle2 } from 'lucide-solid' ;
import { CARD , BTN_GHOST , BTN_PRIMARY , INPUT , LABEL , NAVY } 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' ;
2026-04-26 23:58:43 +02:00
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' ,
} ;
2026-04-06 17:20:48 +02:00
// ── 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-26 23:58:43 +02:00
mediaUrl : string ;
2026-04-06 17:20:48 +02:00
}
2026-04-10 01:21:36 +02:00
interface JobSeekerPortfolioState {
headline : string ;
summary : string ;
education : string ;
workExperience : string ;
skills : string ;
}
2026-04-26 23:58:43 +02:00
const JOB_SEEKER_SAFE_TABS = [ 'About' , 'Education' , 'Work Experience' , 'Skills' ] ;
const PROFESSIONAL_SAFE_TABS : Record < string , string [ ] > = {
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 : '' } ;
2026-04-10 01:21:36 +02:00
const EMPTY_JOB_SEEKER_FORM : JobSeekerPortfolioState = {
headline : '' ,
summary : '' ,
education : '' ,
workExperience : '' ,
skills : '' ,
} ;
2026-04-26 23:58:43 +02:00
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 ;
2026-04-15 06:23:28 +02:00
} ;
type ProfessionalPortfolioState = {
about : string ;
2026-04-26 23:58:43 +02:00
services : ServiceEntry [ ] ;
experience : ExperienceMilestone [ ] ;
testimonials : TestimonialEntry [ ] ;
faqs : FaqEntry [ ] ;
tools : string [ ] ;
2026-04-15 06:23:28 +02:00
} ;
2026-04-26 23:58:43 +02:00
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 : '' } ;
2026-04-15 06:23:28 +02:00
const EMPTY_PROFESSIONAL_FORM : ProfessionalPortfolioState = {
about : '' ,
2026-04-26 23:58:43 +02:00
services : [ { . . . EMPTY_SERVICE } ] ,
experience : [ { . . . EMPTY_MILESTONE } ] ,
testimonials : [ { . . . EMPTY_TESTIMONIAL } ] ,
faqs : [ { . . . EMPTY_FAQ } ] ,
tools : [ ] ,
2026-04-15 06:23:28 +02:00
} ;
2026-04-06 17:20:48 +02:00
2026-04-26 23:58:43 +02:00
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 } ` : '' } ` ;
}
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-26 23:58:43 +02:00
const [ portfolioTopTab , setPortfolioTopTab ] = createSignal < 'edit' | 'preview' > ( 'edit' ) ;
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 ) ) ) ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
return Array . from ( new Set ( mapped ) ) ;
2026-04-10 01:21:36 +02:00
} ;
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 ) ;
}
return grouped ;
} ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
// ── 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 ;
} ;
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' ;
2026-04-26 23:58:43 +02:00
if ( t . includes ( 'testimonial' ) || t . includes ( 'review' ) ) return '' ;
2026-04-15 06:23:28 +02:00
if ( t . includes ( 'faq' ) || t . includes ( 'question' ) ) return 'FAQs' ;
2026-04-26 23:58:43 +02:00
return tab ;
2026-04-15 06:23:28 +02:00
} )
. filter ( Boolean ) ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
return Array . from ( new Set ( fromRuntime ) ) ;
2026-04-15 06:23:28 +02:00
} ;
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 || '' ) ,
2026-04-26 23:58:43 +02:00
services : parseServiceEntries ( parsed ? . services ) ,
experience : parseExperienceEntries ( parsed ? . experience ) ,
testimonials : parseTestimonials ( parsed ? . testimonials ) ,
faqs : parseFaqEntries ( parsed ? . faqs ) ,
tools : parseTools ( ( parsed as any ) ? . tools ) ,
2026-04-15 06:23:28 +02:00
} ) ;
} 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 ( ) ;
2026-04-26 23:58:43 +02:00
setJobSeekerTab ( tabs [ 0 ] || '' ) ;
2026-04-10 01:21:36 +02:00
setLoading ( false ) ;
return ;
}
2026-04-15 06:23:28 +02:00
if ( isProfessional ( ) ) {
const tabs = professionalTabs ( ) ;
2026-04-26 23:58:43 +02:00
setProfessionalTab ( tabs [ 0 ] || '' ) ;
2026-04-15 06:23:28 +02:00
loadProfessionalForm ( ) ;
}
2026-04-10 01:21:36 +02:00
void loadItems ( ) ;
} ) ;
2026-04-06 17:20:48 +02:00
const openCreate = ( ) = > {
2026-04-26 23:58:43 +02:00
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 ;
}
2026-04-06 17:20:48 +02:00
setEditId ( null ) ;
setForm ( { . . . EMPTY_FORM } ) ;
setError ( '' ) ;
setShowForm ( true ) ;
} ;
const openEdit = ( item : PortfolioItem ) = > {
2026-04-26 23:58:43 +02:00
const parsedMedia = parseMediaDescription ( item . description ) ;
2026-04-06 17:20:48 +02:00
setEditId ( item . id ) ;
setForm ( {
title : item.title ,
2026-04-26 23:58:43 +02:00
description : parsedMedia.description ,
2026-04-06 17:20:48 +02:00
tags : ( item . tags ? ? [ ] ) . join ( ', ' ) ,
2026-04-26 23:58:43 +02:00
mediaUrl : parsedMedia.mediaUrl ,
2026-04-06 17:20:48 +02:00
} ) ;
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 ; }
2026-04-26 23:58:43 +02:00
if ( rolePortfolioConfig ( ) . mediaMode === 'visual' && isMediaTab ( ) && ! form ( ) . mediaUrl . trim ( ) ) {
setError ( 'Image URL is required for showcase items.' ) ;
return ;
}
2026-04-06 17:20:48 +02:00
setSaving ( true ) ;
setError ( '' ) ;
2026-04-26 23:58:43 +02:00
const descriptionPayload = rolePortfolioConfig ( ) . mediaMode === 'visual' && isMediaTab ( )
? buildMediaDescription ( form ( ) . mediaUrl , form ( ) . description )
: form ( ) . description . trim ( ) ;
2026-04-06 17:20:48 +02:00
const payload = {
title : form ( ) . title . trim ( ) ,
2026-04-26 23:58:43 +02:00
description : descriptionPayload || undefined ,
2026-04-06 17:20:48 +02:00
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 ( ) ;
2026-04-26 23:58:43 +02:00
const activeTab = ( ) = > tabs . includes ( jobSeekerTab ( ) ) ? jobSeekerTab ( ) : tabs [ 0 ] ;
2026-04-10 01:21:36 +02:00
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 ( ) } >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
{ ( field ) = > {
const fieldKey = normalizeToken ( field ) ;
const isLong = isLongField ( field ) ;
const value = readField ( field ) ;
const isFilled = value . trim ( ) . length > 0 ;
return (
< div style = { { 'grid-column' : isLong ? '1 / -1' : 'auto' } } >
< label style = { LABEL } > { field } < / label >
< Show
when = { ! isLong }
fallback = {
< >
< textarea
rows = { 4 }
value = { value }
onInput = { ( e ) = > setField ( field , e . currentTarget . value ) }
placeholder = { ` Enter ${ field . toLowerCase ( ) } ` }
style = { { . . . INPUT , height : 'auto' , padding : '10px 12px' , resize : 'vertical' } }
/ >
< p
class = "validation-note"
style = { { color : isFilled ? '#fd6116' : '#6e7591' , 'margin-top' : '4px' } }
>
{ isFilled ? ` ✓ ${ field } entered ` : ` • ${ field } is required ` }
< / p >
< / >
}
>
< input
type = "text"
value = { value }
2026-04-10 01:21:36 +02:00
onInput = { ( e ) = > setField ( field , e . currentTarget . value ) }
placeholder = { ` Enter ${ field . toLowerCase ( ) } ` }
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
style = { INPUT }
2026-04-10 01:21:36 +02:00
/ >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
< p
class = "validation-note"
style = { { color : isFilled ? '#fd6116' : '#6e7591' , 'margin-top' : '4px' } }
>
{ isFilled ? ` ✓ ${ field } entered ` : ` • ${ field } is required ` }
< / p >
< / Show >
< / div >
) ;
} }
2026-04-10 01:21:36 +02:00
< / 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 >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
< button type = "button" onClick = { saveJobSeekerPortfolio } disabled = { jobSeekerSaving ( ) || ! jobSeekerTabComplete ( ) } style = { { . . . BTN_NAVY , opacity : ( jobSeekerSaving ( ) || ! jobSeekerTabComplete ( ) ) ? '0.7' : '1' } } >
2026-04-10 01:21:36 +02:00
{ 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-26 23:58:43 +02:00
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' ) ;
} ;
2026-04-15 06:23:28 +02:00
const isProjectsTab = ( ) = > {
const key = normalizeToken ( professionalTab ( ) ) ;
2026-04-26 23:58:43 +02:00
return isMediaTab ( ) || key . includes ( 'project' ) || key . includes ( 'portfolio' ) || key . includes ( 'gallery' ) || key . includes ( 'showreel' ) ;
2026-04-15 06:23:28 +02:00
} ;
const isServicesTab = ( ) = > {
const key = normalizeToken ( professionalTab ( ) ) ;
2026-04-26 23:58:43 +02:00
return key . includes ( 'service' ) || key . includes ( 'pricing' ) || key . includes ( 'package' ) || key . includes ( 'subject' ) || key . includes ( 'training' ) ;
2026-04-15 06:23:28 +02:00
} ;
const isTestimonialsTab = ( ) = > normalizeToken ( professionalTab ( ) ) . includes ( 'testimonial' ) ;
const isFaqTab = ( ) = > normalizeToken ( professionalTab ( ) ) . includes ( 'faq' ) ;
2026-04-26 23:58:43 +02:00
const isExperienceTab = ( ) = > {
2026-04-15 06:23:28 +02:00
const key = normalizeToken ( professionalTab ( ) ) ;
2026-04-26 23:58:43 +02:00
return key . includes ( 'experience' ) || key . includes ( 'stack' ) || key . includes ( 'tool' ) || key . includes ( 'qualification' ) || key . includes ( 'certification' ) ;
2026-04-15 06:23:28 +02:00
} ;
2026-04-26 23:58:43 +02:00
const isAboutTab = ( ) = > {
const key = normalizeToken ( professionalTab ( ) ) ;
return ! isProjectsTab ( ) && ! isServicesTab ( ) && ! isTestimonialsTab ( ) && ! isFaqTab ( ) && ! isExperienceTab ( ) ;
2026-04-15 06:23:28 +02:00
} ;
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"
2026-04-26 23:58:43 +02:00
onClick = { ( ) = > { setPortfolioTopTab ( 'edit' ) ; setProfessionalTab ( tab ) ; } }
2026-04-15 06:23:28 +02:00
style = { {
padding : '0 0 10px' ,
border : 'none' ,
background : 'none' ,
cursor : 'pointer' ,
'font-size' : '13px' ,
2026-04-26 23:58:43 +02:00
'font-weight' : portfolioTopTab ( ) === 'edit' && professionalTab ( ) === tab ? '700' : '500' ,
color : portfolioTopTab ( ) === 'edit' && professionalTab ( ) === tab ? '#FF5E13' : '#6B7280' ,
'border-bottom' : portfolioTopTab ( ) === 'edit' && professionalTab ( ) === tab ? '2px solid #FF5E13' : '2px solid transparent' ,
2026-04-15 06:23:28 +02:00
'margin-bottom' : '-1px' ,
} }
>
{ tab }
< / button >
) }
< / For >
2026-04-26 23:58:43 +02:00
< button
type = "button"
onClick = { ( ) = > setPortfolioTopTab ( 'preview' ) }
style = { {
padding : '0 0 10px' ,
border : 'none' ,
background : 'none' ,
cursor : 'pointer' ,
'font-size' : '13px' ,
'font-weight' : portfolioTopTab ( ) === 'preview' ? '700' : '500' ,
color : portfolioTopTab ( ) === 'preview' ? '#FF5E13' : '#6B7280' ,
'border-bottom' : portfolioTopTab ( ) === 'preview' ? '2px solid #FF5E13' : '2px solid transparent' ,
'margin-bottom' : '-1px' ,
} }
>
Preview
< / button >
2026-04-15 06:23:28 +02:00
< / 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' } } >
2026-04-26 23:58:43 +02:00
{ portfolioTopTab ( ) === 'preview' ? 'Preview how your portfolio appears to customers.' : 'Manage your portfolio content and showcase your work.' }
2026-04-15 06:23:28 +02:00
< / p >
< / div >
2026-04-26 23:58:43 +02:00
< Show when = { portfolioTopTab ( ) === 'edit' && isMediaTab ( ) } >
< button type = "button" onClick = { openCreate } style = { BTN_NAVY } >
+ Add Showcase
2026-04-15 06:23:28 +02:00
< / button >
< / Show >
2026-04-06 17:20:48 +02:00
< / div >
< / div >
2026-04-26 23:58:43 +02:00
< Show when = { portfolioTopTab ( ) === 'preview' } >
< div style = { { display : 'flex' , 'flex-direction' : 'column' , gap : '14px' } } >
< div style = { { 'border-radius' : '14px' , border : '1px solid #E5E7EB' , background : 'white' , 'box-shadow' : '0 1px 3px rgba(0,0,0,0.05)' , overflow : 'hidden' } } >
< div style = { { padding : '12px 16px' , 'border-bottom' : '1px solid #E5E7EB' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' , display : 'flex' , 'align-items' : 'center' , gap : '6px' } } > < UserCircle2 size = { 14 } style = { { color : '#FF5E13' } } / > About < / p >
2026-04-06 17:20:48 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< div style = { { padding : '14px 16px' } } >
< Show when = { professionalForm ( ) . about } fallback = {
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#9CA3AF' , 'font-style' : 'italic' } } > No about section added yet . Go to Edit tab to add your bio . < / p >
} >
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#374151' , 'line-height' : '1.6' , 'white-space' : 'pre-wrap' } } > { professionalForm ( ) . about } < / p >
< / Show >
2026-04-06 17:20:48 +02:00
< / div >
< / div >
2026-04-26 23:58:43 +02:00
< div style = { { 'border-radius' : '14px' , border : '1px solid #E5E7EB' , background : 'white' , 'box-shadow' : '0 1px 3px rgba(0,0,0,0.05)' , overflow : 'hidden' } } >
< div style = { { padding : '12px 16px' , 'border-bottom' : '1px solid #E5E7EB' , display : 'flex' , 'justify-content' : 'space-between' , 'align-items' : 'center' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' , display : 'flex' , 'align-items' : 'center' , gap : '6px' } } > < Coins size = { 14 } style = { { color : '#FF5E13' } } / > { rolePortfolioConfig ( ) . serviceTabLabel } < / p >
< Show when = { professionalForm ( ) . services . some ( s = > s . name || s . amount ) } >
< span style = { { height : '22px' , padding : '0 8px' , 'border-radius' : '999px' , background : '#FFF1EB' , border : '1px solid #FFD8C2' , color : '#C2410C' , 'font-size' : '10px' , 'font-weight' : '800' , display : 'inline-flex' , 'align-items' : 'center' } } > Transparent Pricing < / span >
< / Show >
< / div >
< div style = { { padding : '14px 16px' } } >
< Show when = { professionalForm ( ) . services . some ( s = > s . name || s . amount ) } fallback = {
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#9CA3AF' , 'font-style' : 'italic' } } > No services added yet . Go to Edit tab to add your services and pricing . < / p >
} >
< div style = { { display : 'grid' , 'grid-template-columns' : 'repeat(3,minmax(0,1fr))' , gap : '10px' } } >
{ professionalForm ( ) . services . filter ( s = > s . name || s . amount ) . map ( ( pkg , i ) = > (
< div style = { { border : ` 1px solid ${ i === 1 ? '#FFD8C2' : '#E5E7EB' } ` , background : i === 1 ? '#FFF8F4' : '#FFFFFF' , 'border-radius' : '10px' , padding : '12px' } } >
< Show when = { i === 1 } >
< span style = { { height : '20px' , padding : '0 8px' , 'border-radius' : '999px' , background : '#FF5E13' , color : 'white' , 'font-size' : '10px' , 'font-weight' : '800' , display : 'inline-flex' , 'align-items' : 'center' } } > Popular < / span >
< / Show >
< p style = { { margin : '4px 0 0' , 'font-size' : '11px' , 'font-weight' : '600' , 'text-transform' : 'uppercase' , 'letter-spacing' : '0.05em' , color : '#9CA3AF' } } > { pkg . name || 'Service' } < / p >
< p style = { { margin : '4px 0 0' , 'font-size' : '16px' , 'font-weight' : '700' , color : '#111827' } } > { pkg . amount || 'Contact for price' } < / p >
< div style = { { 'margin-top' : '8px' , display : 'grid' , gap : '4px' } } >
< Show when = { pkg . details } >
{ pkg . details . split ( ',' ) . map ( item = > (
< div style = { { display : 'flex' , 'align-items' : 'center' , gap : '6px' , 'font-size' : '12px' , color : '#374151' } } >
< CheckCircle2 size = { 11 } style = { { color : '#9CA3AF' , 'flex-shrink' : '0' } } / > { item . trim ( ) }
< / div >
) ) }
< / Show >
< / div >
< / div >
) ) }
2026-04-06 17:20:48 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< / Show >
< / div >
< / div >
< Show when = { Boolean ( rolePortfolioConfig ( ) . mediaTabLabel ) } >
< div style = { { 'border-radius' : '14px' , border : '1px solid #E5E7EB' , background : 'white' , 'box-shadow' : '0 1px 3px rgba(0,0,0,0.05)' , overflow : 'hidden' } } >
< div style = { { padding : '12px 16px' , 'border-bottom' : '1px solid #E5E7EB' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' , display : 'flex' , 'align-items' : 'center' , gap : '6px' } } > < Image size = { 14 } style = { { color : '#FF5E13' } } / > { rolePortfolioConfig ( ) . mediaTabLabel } ( { items ( ) . length } items ) < / p >
< / div >
< Show
when = { items ( ) . length > 0 }
fallback = { < p style = { { margin : '0' , padding : '14px 16px' , 'font-size' : '13px' , color : '#9CA3AF' , 'font-style' : 'italic' } } > No items added yet . Go to Edit tab and add showcase entries . < / p > }
>
< div style = { { padding : '14px 16px' , display : 'grid' , 'grid-template-columns' : 'repeat(3,1fr)' , gap : '8px' } } >
{ items ( ) . slice ( 0 , rolePortfolioConfig ( ) . mediaLimit ) . map ( ( item ) = > (
< div style = { { height : '110px' , 'border-radius' : '10px' , border : '1px solid #E5E7EB' , background : '#F9FAFB' , display : 'flex' , 'flex-direction' : 'column' , 'align-items' : 'center' , 'justify-content' : 'center' , gap : '4px' , padding : '8px' , overflow : 'hidden' , position : 'relative' } } >
< Show
when = { rolePortfolioConfig ( ) . mediaMode === 'visual' && parseMediaDescription ( item . description ) . mediaUrl }
fallback = {
< >
< Image size = { 20 } style = { { color : '#C5CCD5' } } / >
< span style = { { 'font-size' : '11px' , color : '#374151' , 'text-align' : 'center' , padding : '0 6px' , 'line-height' : '1.3' , 'font-weight' : '600' } } > { item . title } < / span >
< / >
}
>
< img
src = { parseMediaDescription ( item . description ) . mediaUrl }
alt = { item . title }
style = { { width : '100%' , height : '100%' , 'object-fit' : 'cover' , position : 'absolute' , inset : '0' } }
/ >
< div style = { { position : 'absolute' , left : '0' , right : '0' , bottom : '0' , padding : '6px' , background : 'linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,0.65) 100%)' } } >
< span style = { { 'font-size' : '10px' , color : 'white' , 'font-weight' : '700' , display : 'block' , 'text-align' : 'center' } } > { item . title } < / span >
< / div >
< / Show >
< / div >
) ) }
< / div >
< / Show >
< / div >
< / Show >
< div style = { { 'border-radius' : '14px' , border : '1px solid #E5E7EB' , background : 'white' , 'box-shadow' : '0 1px 3px rgba(0,0,0,0.05)' , overflow : 'hidden' } } >
< div style = { { padding : '12px 16px' , 'border-bottom' : '1px solid #E5E7EB' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' , display : 'flex' , 'align-items' : 'center' , gap : '6px' } } > < BriefcaseBusiness size = { 14 } style = { { color : '#FF5E13' } } / > { rolePortfolioConfig ( ) . experienceTabLabel } < / p >
< / div >
< div style = { { padding : '14px 16px' } } >
< Show when = { professionalForm ( ) . tools . length > 0 } >
< div style = { { 'margin-bottom' : '12px' } } >
< p style = { { margin : '0 0 8px' , 'font-size' : '11px' , 'font-weight' : '600' , 'text-transform' : 'uppercase' , 'letter-spacing' : '0.05em' , color : '#9CA3AF' } } > Tools & Equipment < / p >
< div style = { { display : 'flex' , 'flex-wrap' : 'wrap' , gap : '6px' } } >
{ professionalForm ( ) . tools . map ( tool = > (
< span style = { { height : '24px' , padding : '0 8px' , 'border-radius' : '6px' , border : '1px solid #E5E7EB' , background : '#F9FAFB' , 'font-size' : '11px' , 'font-weight' : '600' , color : '#374151' , display : 'inline-flex' , 'align-items' : 'center' } } > { tool } < / span >
) ) }
< / div >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< / Show >
< Show when = { professionalForm ( ) . experience . some ( e = > e . year || e . description ) } fallback = {
< Show when = { ! professionalForm ( ) . tools . length } >
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#9CA3AF' , 'font-style' : 'italic' } } > No experience added yet . Go to Edit tab to add your experience . < / p >
< / Show >
} >
< div style = { { 'border-top' : '1px solid #F3F4F6' , 'padding-top' : '12px' } } >
{ professionalForm ( ) . experience . filter ( e = > e . year || e . description ) . map ( ( m , i , arr ) = > (
< div style = { { display : 'flex' , gap : '12px' , 'align-items' : 'flex-start' , padding : '8px 0' , . . . ( i < arr . length - 1 ? { 'border-bottom' : '1px solid #F3F4F6' } : { } ) } } >
< span style = { { 'margin-top' : '2px' , width : '8px' , height : '8px' , 'border-radius' : '999px' , background : '#FF5E13' , 'flex-shrink' : '0' } } / >
< p style = { { margin : '0' , 'font-size' : '11px' , 'font-weight' : '700' , color : '#9CA3AF' , 'min-width' : '32px' } } > { m . year } < / p >
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#374151' , 'line-height' : '1.5' } } > { m . description } < / p >
< / div >
) ) }
2026-04-06 17:20:48 +02:00
< / div >
2026-04-15 06:23:28 +02:00
< / Show >
< / div >
2026-04-26 23:58:43 +02:00
< / div >
< div style = { { 'border-radius' : '14px' , border : '1px solid #E5E7EB' , background : 'white' , 'box-shadow' : '0 1px 3px rgba(0,0,0,0.05)' , overflow : 'hidden' } } >
< div style = { { padding : '12px 16px' , 'border-bottom' : '1px solid #E5E7EB' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' } } > FAQs < / p >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< div style = { { padding : '14px 16px' } } >
< Show when = { professionalForm ( ) . faqs . some ( f = > f . question || f . answer ) } fallback = {
< p style = { { margin : '0' , 'font-size' : '13px' , color : '#9CA3AF' , 'font-style' : 'italic' } } > No FAQs added yet . Go to Edit tab to add frequently asked questions . < / p >
} >
< div style = { { display : 'grid' , gap : '12px' } } >
{ professionalForm ( ) . faqs . filter ( f = > f . question || f . answer ) . map ( ( faq ) = > (
< div style = { { display : 'grid' , gap : '4px' } } >
< p style = { { margin : '0' , 'font-size' : '13px' , 'font-weight' : '700' , color : '#111827' } } > { faq . question } < / p >
< p style = { { margin : '0' , 'font-size' : '12px' , color : '#6B7280' , 'line-height' : '1.5' } } > { faq . answer } < / p >
< / div >
) ) }
< / div >
< / Show >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< / div >
< / div >
< / Show >
< Show when = { portfolioTopTab ( ) !== 'preview' } >
< Show
when = { isMediaTab ( ) }
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 >
< Show when = { isAboutTab ( ) } >
< div >
< label style = { LABEL } > About Bio < / label >
< textarea
rows = { 6 }
value = { professionalForm ( ) . about }
onInput = { ( e ) = > setProfessionalForm ( ( prev ) = > ( { . . . prev , about : e.currentTarget.value } ) ) }
placeholder = "Write about yourself, your background, and what makes you unique..."
style = { { . . . INPUT , height : 'auto' , padding : '10px 12px' , resize : 'vertical' } }
/ >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
< p
class = "validation-note"
style = { { color : professionalForm ( ) . about . trim ( ) ? '#fd6116' : '#6e7591' , 'margin-top' : '4px' } }
>
{ professionalForm ( ) . about . trim ( ) ? '✓ About bio entered' : '• About bio is required' }
< / p >
2026-04-26 23:58:43 +02:00
< / div >
< / Show >
< Show when = { isServicesTab ( ) } >
< div style = { { display : 'grid' , gap : '10px' } } >
< For each = { professionalForm ( ) . services } >
{ ( service , i ) = > (
< div style = { { border : '1px solid #E5E7EB' , 'border-radius' : '10px' , padding : '12px' , background : '#FAFAFA' } } >
< div style = { { display : 'grid' , 'grid-template-columns' : '1fr 100px 100px' , gap : '8px' , 'margin-bottom' : '8px' } } >
< div >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > Service Name < / label >
< input type = "text" value = { service . name } onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . services ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , name : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : updated } ) ) ;
} } placeholder = "e.g. Wedding Photography" style = { { . . . INPUT , height : '34px' } } / >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
< p class = "validation-note" style = { { color : service.name.trim ( ) ? '#fd6116' : '#6e7591' , 'margin-top' : '2px' } } >
{ service . name . trim ( ) ? '✓ Service name entered' : '• Service name is required' }
< / p >
2026-04-26 23:58:43 +02:00
< / div >
< div >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > Type < / label >
< select value = { service . pricingType } onChange = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . services ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , pricingType : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : updated } ) ) ;
} } style = { { . . . INPUT , height : '34px' } } >
< option value = "Fixed" > Fixed < / option >
< option value = "Per Hour" > Per Hour < / option >
< option value = "Per Day" > Per Day < / option >
< option value = "Package" > Package < / option >
< / select >
< / div >
< div >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > Price < / label >
< input type = "text" value = { service . amount } onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . services ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , amount : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : updated } ) ) ;
} } placeholder = "₹15,000" style = { { . . . INPUT , height : '34px' } } / >
< / div >
< / div >
< div >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > What ' s Included < / label >
< textarea
rows = { 2 }
value = { service . details }
onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . services ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , details : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : updated } ) ) ;
} }
placeholder = "List deliverables: 4-hour shoot, 100 edited photos, Online gallery..."
style = { { . . . INPUT , height : 'auto' , padding : '8px 10px' , resize : 'vertical' } }
/ >
< / div >
< Show when = { professionalForm ( ) . services . length > 1 } >
< button type = "button" onClick = { ( ) = > {
const updated = professionalForm ( ) . services . filter ( ( _ , idx ) = > idx !== i ( ) ) ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : updated } ) ) ;
} } style = { { 'margin-top' : '8px' , border : 'none' , background : 'none' , color : '#EF4444' , cursor : 'pointer' , 'font-size' : '12px' } } > Remove < / button >
< / Show >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
) }
< / For >
< button type = "button" onClick = { ( ) = > {
setProfessionalForm ( ( prev ) = > ( { . . . prev , services : [ . . . prev . services , { . . . EMPTY_SERVICE } ] } ) ) ;
} } style = { { . . . BTN_GHOST , height : '36px' } } > + Add Service < / button >
< / div >
< / Show >
< Show when = { isExperienceTab ( ) } >
< div style = { { display : 'grid' , gap : '16px' } } >
< div >
< label style = { { 'font-size' : '12px' , 'font-weight' : '700' , color : '#374151' , 'margin-bottom' : '8px' , display : 'block' } } > Tools & Equipment < / label >
< div style = { { display : 'flex' , 'flex-wrap' : 'wrap' , gap : '6px' , 'margin-bottom' : '8px' } } >
< For each = { professionalForm ( ) . tools } >
{ ( tool ) = > (
< span style = { { height : '28px' , padding : '0 10px' , 'border-radius' : '6px' , border : '1px solid #E5E7EB' , background : '#F9FAFB' , 'font-size' : '12px' , 'font-weight' : '600' , color : '#374151' , display : 'inline-flex' , 'align-items' : 'center' , gap : '6px' } } >
{ tool }
< button type = "button" onClick = { ( ) = > {
setProfessionalForm ( ( prev ) = > ( { . . . prev , tools : prev.tools.filter ( t = > t !== tool ) } ) ) ;
} } style = { { border : 'none' , background : 'none' , cursor : 'pointer' , color : '#9CA3AF' , 'font-size' : '10px' } } > x < / button >
< / span >
) }
< / For >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< div style = { { display : 'flex' , gap : '8px' } } >
< input
type = "text"
id = "tool-input"
placeholder = "Add tool (e.g. Canon EOS R6)"
style = { { . . . INPUT , height : '34px' , flex : '1' } }
onKeyDown = { ( e ) = > {
if ( e . key === 'Enter' && e . currentTarget . value . trim ( ) ) {
e . preventDefault ( ) ;
const val = e . currentTarget . value . trim ( ) ;
if ( ! professionalForm ( ) . tools . includes ( val ) ) {
setProfessionalForm ( ( prev ) = > ( { . . . prev , tools : [ . . . prev . tools , val ] } ) ) ;
}
e . currentTarget . value = '' ;
}
} }
/ >
< button type = "button" onClick = { ( ) = > {
const input = document . getElementById ( 'tool-input' ) as HTMLInputElement ;
if ( input ? . value . trim ( ) && ! professionalForm ( ) . tools . includes ( input . value . trim ( ) ) ) {
setProfessionalForm ( ( prev ) = > ( { . . . prev , tools : [ . . . prev . tools , input . value . trim ( ) ] } ) ) ;
input . value = '' ;
}
} } style = { BTN_GHOST } > Add < / button >
< / div >
< / div >
< div >
< label style = { { 'font-size' : '12px' , 'font-weight' : '700' , color : '#374151' , 'margin-bottom' : '8px' , display : 'block' } } > Experience Milestones < / label >
< For each = { professionalForm ( ) . experience } >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
{ ( milestone , i ) = > {
const hasContent = milestone . year . trim ( ) || milestone . description . trim ( ) ;
return (
< div style = { { display : 'grid' , 'grid-template-columns' : '80px 1fr auto' , gap : '8px' , 'margin-bottom' : '8px' , 'align-items' : 'center' } } >
< input type = "text" value = { milestone . year } onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . experience ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , year : e.currentTarget.value } ;
2026-04-26 23:58:43 +02:00
setProfessionalForm ( ( prev ) = > ( { . . . prev , experience : updated } ) ) ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
} } placeholder = "2024" style = { { . . . INPUT , height : '34px' , 'text-align' : 'center' } } / >
< input type = "text" value = { milestone . description } onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . experience ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , description : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , experience : updated } ) ) ;
} } placeholder = "Description..." style = { { . . . INPUT , height : '34px' } } / >
< Show when = { professionalForm ( ) . experience . length > 1 } >
< button type = "button" onClick = { ( ) = > {
const updated = professionalForm ( ) . experience . filter ( ( _ , idx ) = > idx !== i ( ) ) ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , experience : updated } ) ) ;
} } style = { { border : 'none' , background : 'none' , color : '#EF4444' , cursor : 'pointer' , 'font-size' : '16px' } } > x < / button >
< / Show >
< / div >
) ;
} }
2026-04-26 23:58:43 +02:00
< / For >
< button type = "button" onClick = { ( ) = > {
setProfessionalForm ( ( prev ) = > ( { . . . prev , experience : [ . . . prev . experience , { . . . EMPTY_MILESTONE } ] } ) ) ;
} } style = { { . . . BTN_GHOST , height : '36px' } } > + Add Milestone < / button >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +02:00
< / div >
< / Show >
< Show when = { isFaqTab ( ) } >
< div style = { { display : 'grid' , gap : '10px' } } >
< For each = { professionalForm ( ) . faqs } >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
{ ( faq , i ) = > {
const hasContent = faq . question . trim ( ) && faq . answer . trim ( ) ;
return (
< div style = { { border : '1px solid #E5E7EB' , 'border-radius' : '10px' , padding : '12px' , background : '#FAFAFA' } } >
< div style = { { 'margin-bottom' : '8px' } } >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > Question < / label >
< input type = "text" value = { faq . question } onInput = { ( e ) = > {
2026-04-26 23:58:43 +02:00
const updated = [ . . . professionalForm ( ) . faqs ] ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
updated [ i ( ) ] = { . . . updated [ i ( ) ] , question : e.currentTarget.value } ;
2026-04-26 23:58:43 +02:00
setProfessionalForm ( ( prev ) = > ( { . . . prev , faqs : updated } ) ) ;
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
} } placeholder = "e.g. Do you travel for events?" style = { { . . . INPUT , height : '34px' } } / >
< / div >
< div >
< label style = { { 'font-size' : '11px' , 'font-weight' : '600' , color : '#6B7280' } } > Answer < / label >
< textarea
rows = { 2 }
value = { faq . answer }
onInput = { ( e ) = > {
const updated = [ . . . professionalForm ( ) . faqs ] ;
updated [ i ( ) ] = { . . . updated [ i ( ) ] , answer : e.currentTarget.value } ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , faqs : updated } ) ) ;
} }
placeholder = "Answer the question..."
style = { { . . . INPUT , height : 'auto' , padding : '8px 10px' , resize : 'vertical' } }
/ >
< / div >
< p class = "validation-note" style = { { color : hasContent ? '#fd6116' : '#6e7591' , 'margin-top' : '4px' } } >
{ hasContent ? '✓ FAQ complete' : '• Both question and answer are required' }
< / p >
< Show when = { professionalForm ( ) . faqs . length > 1 } >
< button type = "button" onClick = { ( ) = > {
const updated = professionalForm ( ) . faqs . filter ( ( _ , idx ) = > idx !== i ( ) ) ;
setProfessionalForm ( ( prev ) = > ( { . . . prev , faqs : updated } ) ) ;
} } style = { { 'margin-top' : '8px' , border : 'none' , background : 'none' , color : '#EF4444' , cursor : 'pointer' , 'font-size' : '12px' } } > Remove < / button >
< / Show >
2026-04-26 23:58:43 +02:00
< / div >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
) ;
} }
2026-04-26 23:58:43 +02:00
< / For >
< button type = "button" onClick = { ( ) = > {
setProfessionalForm ( ( prev ) = > ( { . . . prev , faqs : [ . . . prev . faqs , { . . . EMPTY_FAQ } ] } ) ) ;
} } style = { { . . . BTN_GHOST , height : '36px' } } > + Add FAQ < / button >
< / div >
< / Show >
< div style = { { display : 'flex' , gap : '10px' , 'justify-content' : 'flex-end' } } >
< button type = "button" onClick = { ( ) = > setProfessionalForm ( { . . . EMPTY_PROFESSIONAL_FORM } ) } style = { BTN_GHOST } >
Clear All
< / button >
Update components (CaptchaCanvas, DashboardLayout, MyDashboardPage, PortfolioPage, ProfilePage), form-validation, routes (dashboard, login, signup), app.css, add e2e tests and helpers, add manual test files and config
2026-05-08 15:34:49 +02:00
< button type = "button" onClick = { saveProfessionalForm } disabled = { ! professionalFormComplete ( ) } style = { { . . . BTN_NAVY , opacity : professionalFormComplete ( ) ? '1' : '0.7' } } >
2026-04-26 23:58:43 +02:00
Save Section
< / button >
< / div >
2026-04-15 06:23:28 +02:00
< / div >
2026-04-26 23:58:43 +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 ${ rolePortfolioConfig ( ) . mediaTabLabel } Item ` : ` New ${ rolePortfolioConfig ( ) . mediaTabLabel } Item ` }
< / p >
< div style = { { display : 'grid' , 'grid-template-columns' : '1fr 1fr' , gap : '14px' } } >
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'Showcase Title' : 'Title' } < span style = { { color : '#EF4444' } } > * < / span > < / label >
< input type = "text" placeholder = { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'e.g. Bridal Makeup - Reception Look' : 'e.g. Developer dashboard rebuild' } value = { form ( ) . title } onInput = { ( e ) = > setField ( 'title' , e . currentTarget . value ) } style = { INPUT } / >
< / div >
< Show when = { rolePortfolioConfig ( ) . mediaMode === 'visual' } >
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > Image URL < span style = { { color : '#EF4444' } } > * < / span > < / label >
< input type = "url" placeholder = "https://..." value = { form ( ) . mediaUrl } onInput = { ( e ) = > setField ( 'mediaUrl' , e . currentTarget . value ) } style = { INPUT } / >
< / div >
< / Show >
< div style = { { 'grid-column' : 'span 2' } } >
< label style = { LABEL } > { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'Caption / Notes' : 'Description' } < / label >
< textarea rows = { 3 } placeholder = { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'Add short notes about this showcase item...' : '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 } > { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'Service Tags (comma separated)' : 'Tags (comma separated)' } < / label >
< input type = "text" placeholder = { rolePortfolioConfig ( ) . mediaMode === 'visual' ? 'e.g. wedding, candid, outdoor' : 'e.g. solidjs, rust, dashboard' } value = { form ( ) . tags } onInput = { ( e ) = > setField ( 'tags' , e . currentTarget . value ) } style = { INPUT } / >
< / div >
< / div >
< 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 { String ( rolePortfolioConfig ( ) . mediaTabLabel ) . toLowerCase ( ) } items yet < / p >
< p style = { { margin : '0 0 16px' , 'font-size' : '13px' , color : '#6B7280' } } >
{ rolePortfolioConfig ( ) . mediaMode === 'visual'
? ` Add up to ${ rolePortfolioConfig ( ) . mediaLimit } showcase images. `
: 'Add your first work sample to attract clients.' }
< / p >
< button type = "button" onClick = { openCreate } style = { BTN_NAVY } > + 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' , overflow : 'hidden' } } >
< Show
when = { rolePortfolioConfig ( ) . mediaMode === 'visual' && parseMediaDescription ( item . description ) . mediaUrl }
fallback = { < span > 🖼 ️ < / span > }
>
< img
src = { parseMediaDescription ( item . description ) . mediaUrl }
alt = { item . title }
style = { { width : '100%' , height : '100%' , 'object-fit' : 'cover' } }
/ >
< / Show >
< / div >
< p style = { { margin : '0' , 'font-size' : '14px' , 'font-weight' : '700' , color : '#111827' } } > { item . title } < / p >
< Show when = { parseMediaDescription ( item . description ) . description } >
< p style = { { margin : '0' , 'font-size' : '12px' , color : '#6B7280' , 'line-height' : '1.5' } } > { parseMediaDescription ( item . description ) . 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 >
< / div >
< / Show >
2026-04-06 17:20:48 +02:00
< / Show >
< / div >
) ;
}