1359 lines
68 KiB
TypeScript
1359 lines
68 KiB
TypeScript
/**
|
|
* PortfolioPage — real My Portfolio CRUD, wired to backend.
|
|
* Uses existing /api/:rolePrefix/portfolio/me endpoints for professionals.
|
|
* Job seekers get a dedicated portfolio editor (education/work experience/skills).
|
|
*/
|
|
import { For, Show, createSignal, onMount } from 'solid-js';
|
|
import { Coins, Image, BriefcaseBusiness, UserCircle2, CheckCircle2 } from 'lucide-solid';
|
|
import { CARD, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL, NAVY } from '~/components/DashboardShell';
|
|
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
|
|
|
|
const API = '/api/gateway';
|
|
|
|
const BTN_NAVY = {
|
|
height: '36px',
|
|
'border-radius': '8px',
|
|
border: 'none',
|
|
background: NAVY,
|
|
color: 'white',
|
|
'font-size': '13px',
|
|
'font-weight': '700',
|
|
padding: '0 16px',
|
|
cursor: 'pointer',
|
|
display: 'inline-flex',
|
|
'align-items': 'center',
|
|
'justify-content': 'center',
|
|
gap: '6px',
|
|
};
|
|
|
|
// ── Role prefix map ───────────────────────────────────────────────────────────
|
|
|
|
const ROLE_PREFIX: Record<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;
|
|
mediaUrl: string;
|
|
}
|
|
|
|
interface JobSeekerPortfolioState {
|
|
headline: string;
|
|
summary: string;
|
|
education: string;
|
|
workExperience: string;
|
|
skills: string;
|
|
}
|
|
|
|
const JOB_SEEKER_SAFE_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
|
|
|
|
const PROFESSIONAL_SAFE_TABS: Record<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: '' };
|
|
const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
|
|
headline: '',
|
|
summary: '',
|
|
education: '',
|
|
workExperience: '',
|
|
skills: '',
|
|
};
|
|
type PortfolioRoleConfig = {
|
|
tabs: string[];
|
|
serviceTabLabel: string;
|
|
experienceTabLabel: string;
|
|
mediaTabLabel?: string;
|
|
mediaMode: 'none' | 'visual' | 'text';
|
|
mediaLimit: number;
|
|
};
|
|
|
|
type ServiceEntry = {
|
|
name: string;
|
|
pricingType: string;
|
|
amount: string;
|
|
details: string;
|
|
};
|
|
|
|
type ExperienceMilestone = {
|
|
year: string;
|
|
description: string;
|
|
};
|
|
|
|
type TestimonialEntry = {
|
|
name: string;
|
|
rating: number;
|
|
text: string;
|
|
};
|
|
|
|
type FaqEntry = {
|
|
question: string;
|
|
answer: string;
|
|
};
|
|
|
|
type ProfessionalPortfolioState = {
|
|
about: string;
|
|
services: ServiceEntry[];
|
|
experience: ExperienceMilestone[];
|
|
testimonials: TestimonialEntry[];
|
|
faqs: FaqEntry[];
|
|
tools: string[];
|
|
};
|
|
|
|
const EMPTY_SERVICE: ServiceEntry = { name: '', pricingType: 'Fixed', amount: '', details: '' };
|
|
const EMPTY_MILESTONE: ExperienceMilestone = { year: '', description: '' };
|
|
const EMPTY_TESTIMONIAL: TestimonialEntry = { name: '', rating: 5, text: '' };
|
|
const EMPTY_FAQ: FaqEntry = { question: '', answer: '' };
|
|
|
|
const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = {
|
|
about: '',
|
|
services: [{ ...EMPTY_SERVICE }],
|
|
experience: [{ ...EMPTY_MILESTONE }],
|
|
testimonials: [{ ...EMPTY_TESTIMONIAL }],
|
|
faqs: [{ ...EMPTY_FAQ }],
|
|
tools: [],
|
|
};
|
|
|
|
function isServiceLikeTab(tab: string): boolean {
|
|
const key = String(tab || '').toLowerCase();
|
|
return key.includes('service') || key.includes('pricing') || key.includes('package') || key.includes('subject') || key.includes('training');
|
|
}
|
|
|
|
function isExperienceLikeTab(tab: string): boolean {
|
|
const key = String(tab || '').toLowerCase();
|
|
return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification');
|
|
}
|
|
|
|
function isMediaLikeTab(tab: string): boolean {
|
|
const key = String(tab || '').toLowerCase();
|
|
return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel') || key.includes('case studies') || key.includes('case_studies') || key.includes('student work') || key.includes('client results') || key.includes('content portfolio');
|
|
}
|
|
|
|
function isVisualMediaTab(tab: string): boolean {
|
|
const key = String(tab || '').toLowerCase();
|
|
return key.includes('gallery') || key.includes('showreel') || key.includes('portfolio');
|
|
}
|
|
|
|
function parseListFromLegacyText(value: string): string[] {
|
|
return String(value || '')
|
|
.split(/\n|,/g)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseServiceEntries(value: unknown): ServiceEntry[] {
|
|
if (Array.isArray(value)) {
|
|
const parsed = value
|
|
.map((item) => ({
|
|
name: String((item as any)?.name || ''),
|
|
pricingType: String((item as any)?.pricingType || 'Fixed') || 'Fixed',
|
|
amount: String((item as any)?.amount || ''),
|
|
details: String((item as any)?.details || ''),
|
|
}))
|
|
.filter((item) => item.name || item.amount || item.details);
|
|
return parsed.length ? parsed : [{ ...EMPTY_SERVICE }];
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return parseListFromLegacyText(value).map((line) => ({
|
|
name: line,
|
|
pricingType: 'Fixed',
|
|
amount: '',
|
|
details: '',
|
|
}));
|
|
}
|
|
return [{ ...EMPTY_SERVICE }];
|
|
}
|
|
|
|
function parseExperienceEntries(value: unknown): ExperienceMilestone[] {
|
|
if (Array.isArray(value)) {
|
|
const parsed = value
|
|
.map((item) => ({
|
|
year: String((item as any)?.year || ''),
|
|
description: String((item as any)?.description || ''),
|
|
}))
|
|
.filter((item) => item.year || item.description);
|
|
return parsed.length ? parsed : [{ ...EMPTY_MILESTONE }];
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return parseListFromLegacyText(value).map((line) => ({ year: '', description: line }));
|
|
}
|
|
return [{ ...EMPTY_MILESTONE }];
|
|
}
|
|
|
|
function parseTestimonials(value: unknown): TestimonialEntry[] {
|
|
if (Array.isArray(value)) {
|
|
const parsed = value
|
|
.map((item) => ({
|
|
name: String((item as any)?.name || ''),
|
|
rating: Number((item as any)?.rating || 5) || 5,
|
|
text: String((item as any)?.text || ''),
|
|
}))
|
|
.filter((item) => item.name || item.text);
|
|
return parsed.length ? parsed : [{ ...EMPTY_TESTIMONIAL }];
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return parseListFromLegacyText(value).map((line) => ({ name: '', rating: 5, text: line }));
|
|
}
|
|
return [{ ...EMPTY_TESTIMONIAL }];
|
|
}
|
|
|
|
function parseFaqEntries(value: unknown): FaqEntry[] {
|
|
if (Array.isArray(value)) {
|
|
const parsed = value
|
|
.map((item) => ({
|
|
question: String((item as any)?.question || ''),
|
|
answer: String((item as any)?.answer || ''),
|
|
}))
|
|
.filter((item) => item.question || item.answer);
|
|
return parsed.length ? parsed : [{ ...EMPTY_FAQ }];
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return parseListFromLegacyText(value).map((line) => ({ question: line, answer: '' }));
|
|
}
|
|
return [{ ...EMPTY_FAQ }];
|
|
}
|
|
|
|
function parseTools(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return parseListFromLegacyText(value);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function parseMediaDescription(raw?: string): { mediaUrl: string; description: string } {
|
|
const value = String(raw || '');
|
|
if (!value.startsWith('MEDIA_URL:')) return { mediaUrl: '', description: value };
|
|
const lines = value.split('\n');
|
|
const mediaUrl = String(lines[0] || '').replace('MEDIA_URL:', '').trim();
|
|
const description = lines.slice(1).join('\n').trim();
|
|
return { mediaUrl, description };
|
|
}
|
|
|
|
function buildMediaDescription(mediaUrl: string, description: string): string {
|
|
const cleanUrl = String(mediaUrl || '').trim();
|
|
const cleanDescription = String(description || '').trim();
|
|
if (!cleanUrl) return cleanDescription;
|
|
return `MEDIA_URL:${cleanUrl}${cleanDescription ? `\n${cleanDescription}` : ''}`;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
async function apiFetch(path: string, opts?: RequestInit) {
|
|
const token =
|
|
typeof window !== "undefined"
|
|
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
|
|
: "";
|
|
return fetch(`${API}${path}`, {
|
|
...opts,
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...(opts?.headers ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
interface Props {
|
|
roleKey: string;
|
|
runtimeTabs?: string[];
|
|
runtimeFields?: string[];
|
|
}
|
|
|
|
export default function PortfolioPage(props: Props) {
|
|
const prefix = () => ROLE_PREFIX[props.roleKey] ?? '';
|
|
const isProfessional = () => Boolean(prefix());
|
|
const isJobSeeker = () => String(props.roleKey || '').toUpperCase() === 'JOB_SEEKER';
|
|
|
|
const [items, setItems] = createSignal<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('');
|
|
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('');
|
|
const [professionalTab, setProfessionalTab] = createSignal('About');
|
|
const [professionalForm, setProfessionalForm] = createSignal<ProfessionalPortfolioState>({ ...EMPTY_PROFESSIONAL_FORM });
|
|
const [professionalMsg, setProfessionalMsg] = createSignal('');
|
|
const [portfolioTopTab, setPortfolioTopTab] = createSignal<'edit' | 'preview'>('edit');
|
|
|
|
const loadItems = async () => {
|
|
if (!isProfessional()) { setLoading(false); return; }
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch(`/api/${prefix()}/portfolio/me`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setItems(Array.isArray(data) ? data : (data.items ?? []));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadJobSeekerPortfolio = () => {
|
|
readJobSeekerProfile()
|
|
.then((profile) => {
|
|
const parsed = profile?.custom_data?.job_seeker_portfolio as Record<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)));
|
|
return Array.from(new Set(mapped));
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
// ── 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;
|
|
};
|
|
|
|
const professionalTabs = () => {
|
|
const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
|
|
const fromRuntime = runtimeRaw
|
|
.map((tab) => toLabel(tab))
|
|
.filter(Boolean)
|
|
.map((tab) => {
|
|
const t = normalizeToken(tab);
|
|
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
|
|
if (t.includes('testimonial') || t.includes('review')) return '';
|
|
if (t.includes('faq') || t.includes('question')) return 'FAQs';
|
|
return tab;
|
|
})
|
|
.filter(Boolean);
|
|
return Array.from(new Set(fromRuntime));
|
|
};
|
|
|
|
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: parseServiceEntries(parsed?.services),
|
|
experience: parseExperienceEntries(parsed?.experience),
|
|
testimonials: parseTestimonials(parsed?.testimonials),
|
|
faqs: parseFaqEntries(parsed?.faqs),
|
|
tools: parseTools((parsed as any)?.tools),
|
|
});
|
|
} catch {
|
|
// Ignore malformed local storage payloads.
|
|
}
|
|
};
|
|
|
|
const saveProfessionalForm = () => {
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(professionalForm()));
|
|
}
|
|
setProfessionalMsg('Portfolio section saved.');
|
|
window.setTimeout(() => setProfessionalMsg(''), 1800);
|
|
};
|
|
|
|
onMount(() => {
|
|
if (isJobSeeker()) {
|
|
loadJobSeekerPortfolio();
|
|
const tabs = runtimePortfolioTabs();
|
|
setJobSeekerTab(tabs[0] || '');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (isProfessional()) {
|
|
const tabs = professionalTabs();
|
|
setProfessionalTab(tabs[0] || '');
|
|
loadProfessionalForm();
|
|
}
|
|
void loadItems();
|
|
});
|
|
|
|
const openCreate = () => {
|
|
const config = rolePortfolioConfig();
|
|
if (config.mediaMode === 'visual' && isMediaTab() && items().length >= config.mediaLimit) {
|
|
setProfessionalMsg(`You can add up to ${config.mediaLimit} showcase images for ${props.roleKey.toLowerCase().replace(/_/g, ' ')}.`);
|
|
window.setTimeout(() => setProfessionalMsg(''), 2200);
|
|
return;
|
|
}
|
|
setEditId(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setError('');
|
|
setShowForm(true);
|
|
};
|
|
|
|
const openEdit = (item: PortfolioItem) => {
|
|
const parsedMedia = parseMediaDescription(item.description);
|
|
setEditId(item.id);
|
|
setForm({
|
|
title: item.title,
|
|
description: parsedMedia.description,
|
|
tags: (item.tags ?? []).join(', '),
|
|
mediaUrl: parsedMedia.mediaUrl,
|
|
});
|
|
setError('');
|
|
setShowForm(true);
|
|
};
|
|
|
|
const cancelForm = () => {
|
|
setShowForm(false);
|
|
setEditId(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setError('');
|
|
};
|
|
|
|
const setField = (key: keyof FormState, val: string) =>
|
|
setForm((prev) => ({ ...prev, [key]: val }));
|
|
|
|
const handleSave = async () => {
|
|
if (!form().title.trim()) { setError('Title is required.'); return; }
|
|
if (rolePortfolioConfig().mediaMode === 'visual' && isMediaTab() && !form().mediaUrl.trim()) {
|
|
setError('Image URL is required for showcase items.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError('');
|
|
const descriptionPayload = rolePortfolioConfig().mediaMode === 'visual' && isMediaTab()
|
|
? buildMediaDescription(form().mediaUrl, form().description)
|
|
: form().description.trim();
|
|
const payload = {
|
|
title: form().title.trim(),
|
|
description: descriptionPayload || undefined,
|
|
tags: form().tags
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter(Boolean),
|
|
};
|
|
try {
|
|
const id = editId();
|
|
const res = id
|
|
? await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
|
: await apiFetch(`/api/${prefix()}/portfolio/me`, { method: 'POST', body: JSON.stringify(payload) });
|
|
|
|
if (res.ok) {
|
|
await loadItems();
|
|
cancelForm();
|
|
} else {
|
|
const d = await res.json().catch(() => ({}));
|
|
setError(d.error ?? d.message ?? 'Failed to save. Please try again.');
|
|
}
|
|
} catch {
|
|
setError('Network error. Please try again.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Delete this portfolio item?')) return;
|
|
setDeleting(id);
|
|
try {
|
|
await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'DELETE' });
|
|
await loadItems();
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
// ── Job seeker portfolio editor ─────────────────────────────────────────
|
|
if (isJobSeeker()) {
|
|
const setField = (fieldKey: string, value: string) => {
|
|
const key = normalizeToken(fieldKey);
|
|
setJobSeekerForm((prev) => {
|
|
if (key.includes('headline')) return { ...prev, headline: value };
|
|
if (key.includes('summary') || key.includes('about')) return { ...prev, summary: value };
|
|
if (key.includes('education') || key.includes('degree') || key.includes('college')) return { ...prev, education: value };
|
|
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return { ...prev, workExperience: value };
|
|
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return { ...prev, skills: value };
|
|
return { ...prev, summary: value };
|
|
});
|
|
};
|
|
const readField = (fieldKey: string) => {
|
|
const key = normalizeToken(fieldKey);
|
|
if (key.includes('headline')) return jobSeekerForm().headline;
|
|
if (key.includes('summary') || key.includes('about')) return jobSeekerForm().summary;
|
|
if (key.includes('education') || key.includes('degree') || key.includes('college')) return jobSeekerForm().education;
|
|
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return jobSeekerForm().workExperience;
|
|
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return jobSeekerForm().skills;
|
|
return '';
|
|
};
|
|
const tabs = runtimePortfolioTabs();
|
|
const fieldsByTab = runtimeFieldsByTab();
|
|
const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : tabs[0];
|
|
const activeFields = () => fieldsByTab[activeTab()] || [];
|
|
const isLongField = (field: string) => {
|
|
const key = normalizeToken(field);
|
|
return key.includes('summary')
|
|
|| key.includes('about')
|
|
|| key.includes('experience')
|
|
|| key.includes('education')
|
|
|| key.includes('skills');
|
|
};
|
|
|
|
return (
|
|
<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' }}>
|
|
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>My Portfolio</p>
|
|
<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) => {
|
|
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}
|
|
onInput={(e) => setField(field, e.currentTarget.value)}
|
|
placeholder={`Enter ${field.toLowerCase()}`}
|
|
style={INPUT}
|
|
/>
|
|
<p
|
|
class="validation-note"
|
|
style={{ color: isFilled ? '#fd6116' : '#6e7591', 'margin-top': '4px' }}
|
|
>
|
|
{isFilled ? `✓ ${field} entered` : `• ${field} is required`}
|
|
</p>
|
|
</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() || !jobSeekerTabComplete()} style={{ ...BTN_NAVY, opacity: (jobSeekerSaving() || !jobSeekerTabComplete()) ? '0.7' : '1' }}>
|
|
{jobSeekerSaving() ? 'Saving...' : 'Save Portfolio'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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>
|
|
);
|
|
}
|
|
|
|
const rolePortfolioConfig = () => {
|
|
const tabs = professionalTabs();
|
|
const serviceTabLabel = tabs.find((tab) => isServiceLikeTab(tab)) || '';
|
|
const experienceTabLabel = tabs.find((tab) => isExperienceLikeTab(tab)) || '';
|
|
const mediaTabLabel = tabs.find((tab) => isMediaLikeTab(tab)) || '';
|
|
const mediaMode = mediaTabLabel ? (isVisualMediaTab(mediaTabLabel) ? 'visual' : 'text') : 'none';
|
|
const mediaLimit = mediaMode === 'visual' ? 6 : mediaMode === 'text' ? 8 : 0;
|
|
return {
|
|
tabs,
|
|
serviceTabLabel,
|
|
experienceTabLabel,
|
|
mediaTabLabel,
|
|
mediaMode,
|
|
mediaLimit,
|
|
} as PortfolioRoleConfig;
|
|
};
|
|
|
|
const isMediaTab = () => {
|
|
const tab = normalizeToken(professionalTab());
|
|
const mediaLabel = normalizeToken(rolePortfolioConfig().mediaTabLabel || '');
|
|
return tab === mediaLabel || tab.includes('project') || tab.includes('portfolio') || tab.includes('gallery') || tab.includes('showreel') || tab.includes('case_stud') || tab.includes('student_work') || tab.includes('client_result');
|
|
};
|
|
|
|
const isProjectsTab = () => {
|
|
const key = normalizeToken(professionalTab());
|
|
return isMediaTab() || key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel');
|
|
};
|
|
|
|
const isServicesTab = () => {
|
|
const key = normalizeToken(professionalTab());
|
|
return key.includes('service') || key.includes('pricing') || key.includes('package') || key.includes('subject') || key.includes('training');
|
|
};
|
|
|
|
const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial');
|
|
const isFaqTab = () => normalizeToken(professionalTab()).includes('faq');
|
|
const isExperienceTab = () => {
|
|
const key = normalizeToken(professionalTab());
|
|
return key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification') || key.includes('certification');
|
|
};
|
|
const isAboutTab = () => {
|
|
const key = normalizeToken(professionalTab());
|
|
return !isProjectsTab() && !isServicesTab() && !isTestimonialsTab() && !isFaqTab() && !isExperienceTab();
|
|
};
|
|
return (
|
|
<div style={{ 'max-width': '800px' }}>
|
|
<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={() => { setPortfolioTopTab('edit'); setProfessionalTab(tab); }}
|
|
style={{
|
|
padding: '0 0 10px',
|
|
border: 'none',
|
|
background: 'none',
|
|
cursor: 'pointer',
|
|
'font-size': '13px',
|
|
'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',
|
|
'margin-bottom': '-1px',
|
|
}}
|
|
>
|
|
{tab}
|
|
</button>
|
|
)}
|
|
</For>
|
|
<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>
|
|
</div>
|
|
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'padding': '14px 0' }}>
|
|
<div>
|
|
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>My Portfolio</p>
|
|
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
|
{portfolioTopTab() === 'preview' ? 'Preview how your portfolio appears to customers.' : 'Manage your portfolio content and showcase your work.'}
|
|
</p>
|
|
</div>
|
|
<Show when={portfolioTopTab() === 'edit' && isMediaTab()}>
|
|
<button type="button" onClick={openCreate} style={BTN_NAVY}>
|
|
+ Add Showcase
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</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', 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>
|
|
))}
|
|
</div>
|
|
</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>
|
|
</div>
|
|
</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>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</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' }}
|
|
/>
|
|
<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>
|
|
</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' }} />
|
|
<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>
|
|
</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>
|
|
</div>
|
|
)}
|
|
</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>
|
|
</div>
|
|
<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}>
|
|
{(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 };
|
|
setProfessionalForm((prev) => ({ ...prev, experience: updated }));
|
|
}} 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>
|
|
);
|
|
}}
|
|
</For>
|
|
<button type="button" onClick={() => {
|
|
setProfessionalForm((prev) => ({ ...prev, experience: [...prev.experience, { ...EMPTY_MILESTONE }] }));
|
|
}} style={{ ...BTN_GHOST, height: '36px' }}>+ Add Milestone</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={isFaqTab()}>
|
|
<div style={{ display: 'grid', gap: '10px' }}>
|
|
<For each={professionalForm().faqs}>
|
|
{(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) => {
|
|
const updated = [...professionalForm().faqs];
|
|
updated[i()] = { ...updated[i()], question: e.currentTarget.value };
|
|
setProfessionalForm((prev) => ({ ...prev, faqs: updated }));
|
|
}} 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>
|
|
</div>
|
|
);
|
|
}}
|
|
</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>
|
|
<button type="button" onClick={saveProfessionalForm} disabled={!professionalFormComplete()} style={{ ...BTN_NAVY, opacity: professionalFormComplete() ? '1' : '0.7' }}>
|
|
Save Section
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<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>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|