365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
|
|
/**
|
|||
|
|
* PortfolioPage — real My Portfolio CRUD, wired to backend.
|
|||
|
|
* Uses existing /api/:rolePrefix/portfolio/me endpoints.
|
|||
|
|
* Professionals only.
|
|||
|
|
*/
|
|||
|
|
import { For, Show, createSignal, onMount } from 'solid-js';
|
|||
|
|
import { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell';
|
|||
|
|
|
|||
|
|
const API = '/api/gateway';
|
|||
|
|
|
|||
|
|
// ── Role prefix map ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const ROLE_PREFIX: Record<string, string> = {
|
|||
|
|
PHOTOGRAPHER: 'photographers',
|
|||
|
|
MAKEUP_ARTIST: 'makeup-artists',
|
|||
|
|
TUTOR: 'tutors',
|
|||
|
|
DEVELOPER: 'developers',
|
|||
|
|
VIDEO_EDITOR: 'video-editors',
|
|||
|
|
GRAPHIC_DESIGNER: 'graphic-designers',
|
|||
|
|
SOCIAL_MEDIA_MANAGER:'social-media-managers',
|
|||
|
|
FITNESS_TRAINER: 'fitness-trainers',
|
|||
|
|
CATERING_SERVICES: 'catering-services',
|
|||
|
|
UGC_CONTENT_CREATOR: 'ugc-content-creators',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface PortfolioItem {
|
|||
|
|
id: string;
|
|||
|
|
title: string;
|
|||
|
|
description?: string;
|
|||
|
|
tags?: string[];
|
|||
|
|
created_at?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface FormState {
|
|||
|
|
title: string;
|
|||
|
|
description: string;
|
|||
|
|
tags: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const EMPTY_FORM: FormState = { title: '', description: '', tags: '' };
|
|||
|
|
|
|||
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function apiFetch(path: string, opts?: RequestInit) {
|
|||
|
|
return fetch(`${API}${path}`, {
|
|||
|
|
...opts,
|
|||
|
|
credentials: 'include',
|
|||
|
|
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
roleKey: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function PortfolioPage(props: Props) {
|
|||
|
|
const prefix = () => ROLE_PREFIX[props.roleKey] ?? '';
|
|||
|
|
const isProfessional = () => Boolean(prefix());
|
|||
|
|
|
|||
|
|
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 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);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMount(loadItems);
|
|||
|
|
|
|||
|
|
const openCreate = () => {
|
|||
|
|
setEditId(null);
|
|||
|
|
setForm({ ...EMPTY_FORM });
|
|||
|
|
setError('');
|
|||
|
|
setShowForm(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const openEdit = (item: PortfolioItem) => {
|
|||
|
|
setEditId(item.id);
|
|||
|
|
setForm({
|
|||
|
|
title: item.title,
|
|||
|
|
description: item.description ?? '',
|
|||
|
|
tags: (item.tags ?? []).join(', '),
|
|||
|
|
});
|
|||
|
|
setError('');
|
|||
|
|
setShowForm(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancelForm = () => {
|
|||
|
|
setShowForm(false);
|
|||
|
|
setEditId(null);
|
|||
|
|
setForm({ ...EMPTY_FORM });
|
|||
|
|
setError('');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const setField = (key: keyof FormState, val: string) =>
|
|||
|
|
setForm((prev) => ({ ...prev, [key]: val }));
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
if (!form().title.trim()) { setError('Title is required.'); return; }
|
|||
|
|
setSaving(true);
|
|||
|
|
setError('');
|
|||
|
|
const payload = {
|
|||
|
|
title: form().title.trim(),
|
|||
|
|
description: form().description.trim() || undefined,
|
|||
|
|
tags: form().tags
|
|||
|
|
.split(',')
|
|||
|
|
.map((t) => t.trim())
|
|||
|
|
.filter(Boolean),
|
|||
|
|
};
|
|||
|
|
try {
|
|||
|
|
const id = editId();
|
|||
|
|
const res = id
|
|||
|
|
? await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
|||
|
|
: await apiFetch(`/api/${prefix()}/portfolio/me`, { method: 'POST', body: JSON.stringify(payload) });
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
await loadItems();
|
|||
|
|
cancelForm();
|
|||
|
|
} else {
|
|||
|
|
const d = await res.json().catch(() => ({}));
|
|||
|
|
setError(d.error ?? d.message ?? 'Failed to save. Please try again.');
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
setError('Network error. Please try again.');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (id: string) => {
|
|||
|
|
if (!confirm('Delete this portfolio item?')) return;
|
|||
|
|
setDeleting(id);
|
|||
|
|
try {
|
|||
|
|
await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'DELETE' });
|
|||
|
|
await loadItems();
|
|||
|
|
} finally {
|
|||
|
|
setDeleting(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── 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>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ 'max-width': '800px' }}>
|
|||
|
|
|
|||
|
|
{/* ── Header ────────────────────────────────────────────────────── */}
|
|||
|
|
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
|
|||
|
|
<div>
|
|||
|
|
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
|||
|
|
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
|||
|
|
Showcase your work to attract clients.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
|||
|
|
+ Add Item
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* ── Create / Edit form ─────────────────────────────────────────── */}
|
|||
|
|
<Show when={showForm()}>
|
|||
|
|
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
|
|||
|
|
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
|
|||
|
|
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
|
|||
|
|
<div style={{ 'grid-column': 'span 2' }}>
|
|||
|
|
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
placeholder="e.g. Wedding shoot at Udaipur"
|
|||
|
|
value={form().title}
|
|||
|
|
onInput={(e) => setField('title', e.currentTarget.value)}
|
|||
|
|
style={INPUT}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ 'grid-column': 'span 2' }}>
|
|||
|
|
<label style={LABEL}>Description</label>
|
|||
|
|
<textarea
|
|||
|
|
rows={3}
|
|||
|
|
placeholder="Brief description of the work…"
|
|||
|
|
value={form().description}
|
|||
|
|
onInput={(e) => setField('description', e.currentTarget.value)}
|
|||
|
|
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ 'grid-column': 'span 2' }}>
|
|||
|
|
<label style={LABEL}>Tags (comma separated)</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
placeholder="e.g. wedding, outdoor, portrait"
|
|||
|
|
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>
|
|||
|
|
|
|||
|
|
{/* ── Loading ─────────────────────────────────────────────────────── */}
|
|||
|
|
<Show when={loading()}>
|
|||
|
|
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
|
|||
|
|
Loading portfolio…
|
|||
|
|
</div>
|
|||
|
|
</Show>
|
|||
|
|
|
|||
|
|
{/* ── Empty state ─────────────────────────────────────────────────── */}
|
|||
|
|
<Show when={!loading() && items().length === 0 && !showForm()}>
|
|||
|
|
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
|
|||
|
|
<p style={{ margin: '0', 'font-size': '40px' }}>🗂️</p>
|
|||
|
|
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
|
|||
|
|
No portfolio items yet
|
|||
|
|
</p>
|
|||
|
|
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>
|
|||
|
|
Add your first work sample to attract clients.
|
|||
|
|
</p>
|
|||
|
|
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
|||
|
|
+ Add First Item
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</Show>
|
|||
|
|
|
|||
|
|
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
|
|||
|
|
<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',
|
|||
|
|
}}>
|
|||
|
|
{/* Placeholder image area */}
|
|||
|
|
<div style={{
|
|||
|
|
height: '120px',
|
|||
|
|
'border-radius': '8px',
|
|||
|
|
background: '#F3F4F6',
|
|||
|
|
display: 'flex',
|
|||
|
|
'align-items': 'center',
|
|||
|
|
'justify-content': 'center',
|
|||
|
|
color: '#D1D5DB',
|
|||
|
|
'font-size': '28px',
|
|||
|
|
}}>
|
|||
|
|
🖼️
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
|
|||
|
|
{item.title}
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<Show when={item.description}>
|
|||
|
|
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>
|
|||
|
|
{item.description}
|
|||
|
|
</p>
|
|||
|
|
</Show>
|
|||
|
|
|
|||
|
|
<Show when={item.tags && item.tags.length > 0}>
|
|||
|
|
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
|
|||
|
|
<For each={item.tags}>
|
|||
|
|
{(tag) => (
|
|||
|
|
<span style={{
|
|||
|
|
'font-size': '10px',
|
|||
|
|
'font-weight': '700',
|
|||
|
|
color: '#6B7280',
|
|||
|
|
background: '#F3F4F6',
|
|||
|
|
border: '1px solid #E5E7EB',
|
|||
|
|
'border-radius': '6px',
|
|||
|
|
padding: '2px 8px',
|
|||
|
|
}}>
|
|||
|
|
{tag}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</For>
|
|||
|
|
</div>
|
|||
|
|
</Show>
|
|||
|
|
|
|||
|
|
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => openEdit(item)}
|
|||
|
|
style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}
|
|||
|
|
>
|
|||
|
|
Edit
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleDelete(item.id)}
|
|||
|
|
disabled={deleting() === item.id}
|
|||
|
|
style={{
|
|||
|
|
height: '30px',
|
|||
|
|
'border-radius': '8px',
|
|||
|
|
border: '1px solid #FECACA',
|
|||
|
|
background: '#fff',
|
|||
|
|
color: '#EF4444',
|
|||
|
|
'font-size': '11px',
|
|||
|
|
'font-weight': '700',
|
|||
|
|
padding: '0 12px',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
flex: '1',
|
|||
|
|
opacity: deleting() === item.id ? '0.6' : '1',
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{deleting() === item.id ? '…' : 'Delete'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</For>
|
|||
|
|
</div>
|
|||
|
|
</Show>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|