/* global React, Icon, StatusPill */
const { useState, useEffect, useRef } = React;
/* ============================================================
CASE STUDIES — Phase 1: List + Brief editor
Phase 2 (image library + drag-drop) and Phase 3 (renderer + PDF)
land on the Images / Preview / Render tabs.
============================================================ */
function CaseStudies({ goto }) {
const [items, setItems] = useState(null);
const [creating, setCreating] = useState(false);
const reload = () => fetch('/api/case-studies', { credentials: 'include' })
.then((r) => r.json())
.then((d) => setItems(d.items || []))
.catch(() => setItems([]));
useEffect(() => { reload(); }, []);
return (
Outreach assets
Case Studies
{items ? `${items.length} case ${items.length === 1 ? 'study' : 'studies'}` : '—'}
setCreating(true)}>
New case study
{items === null &&
Loading…
}
{items && items.length === 0 && (
No case studies yet. Click + New case study to start one.
)}
{items && items.length > 0 && (
{items.map((cs) => (
goto('case-study-editor', cs.id)} />
))}
)}
{creating && (
setCreating(false)}
onCreated={(cs) => { setCreating(false); goto('case-study-editor', cs.id); }}
/>
)}
);
}
function CaseStudyCard({ cs, onOpen }) {
const headlineComplete = !!(cs.headline_before && cs.headline_after && cs.headline_time);
const kpiCount = (cs.kpi_cards || []).length;
const stackCount = (cs.stack_pillars || []).length;
return (
{cs.client_name}
{cs.client_slug}
{headlineComplete && (
From {cs.headline_before} to {cs.headline_after} in {cs.headline_time} .
)}
{kpiCount} KPIs
{stackCount} pillars
{cs.last_rendered_at && rendered }
);
}
function NewCaseStudyModal({ onClose, onCreated }) {
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const submit = async () => {
if (!name.trim()) { setError('Client name is required'); return; }
setBusy(true);
setError(null);
try {
const r = await fetch('/api/case-studies', {
method: 'POST', credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ client_name: name.trim() }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || 'create failed'); }
const data = await r.json();
onCreated(data);
} catch (e) { setError(e.message); } finally { setBusy(false); }
};
return (
e.stopPropagation()} style={{ background: 'var(--surface)', border: '1px solid var(--divider)', width: 480, padding: 28 }}>
Outreach assets
New case study
Client name
setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder="e.g. Stride · Hintime · EnduraXcel"
style={{ width: '100%', marginBottom: 16 }}
/>
{error &&
{error}
}
Cancel
{busy ? 'Creating…' : 'Create'}
);
}
/* ============================================================
EDITOR
============================================================ */
function CaseStudyEditor({ caseStudyId, goto }) {
const [cs, setCs] = useState(null);
const [tab, setTab] = useState('brief');
const [savedAt, setSavedAt] = useState(null);
const reload = async () => {
const d = await fetch(`/api/case-studies/${caseStudyId}`, { credentials: 'include' }).then((r) => r.json());
setCs(d);
};
useEffect(() => { reload(); }, [caseStudyId]);
// Auto-save with a small debounce on field blur (handled per-field in BriefEditor)
const patch = async (data) => {
const r = await fetch(`/api/case-studies/${caseStudyId}`, {
method: 'PATCH', credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
if (r.ok) {
const updated = await r.json();
setCs(updated);
setSavedAt(new Date());
}
};
if (!cs) return Loading…
;
return (
{[['brief','Brief'],['images','Images'],['preview','Preview'],['render','Render']].map(([k,l]) => (
setTab(k)}>{l}
))}
{tab === 'brief' &&
}
{tab === 'images' &&
}
{tab === 'preview' &&
}
{tab === 'render' &&
}
);
}
function ComingSoon({ msg }) {
return (
{msg}
);
}
/* ============================================================
BRIEF EDITOR
============================================================ */
function BriefEditor({ cs, setCs, patch }) {
// Helpers — local set updates state, blur sends a PATCH
const set = (field, value) => setCs({ ...cs, [field]: value });
const save = (field, value) => patch({ [field]: value });
return (
{/* Identity + dates */}
{/* Headline */}
{/* KPI cards */}
{/* Before block */}
{ set('before_had', items); save('before_had', items); }} placeholder="e.g. Strong B2B network with 80+ hotels" />
{ set('before_didnt', items); save('before_didnt', items); }} placeholder="e.g. No D2C channel" />
{
const t = e.target.value;
const v = t === 'none' ? null : { type: t, content: cs.before_callout?.content || '' };
set('before_callout', v); save('before_callout', v);
}} style={{ width: '100%', marginBottom: 8 }}>
None
Problem callout (red border)
B2B logo strip placeholder (10 cells)
{cs.before_callout?.type === 'problem' && (
{/* Stack pillars */}
{/* Chart */}
{ set('chart_type', e.target.value); save('chart_type', e.target.value); }} style={{ width: '100%' }}>
No chart (skip Numbers page chart)
Bar chart — order growth (12-18 months)
Bar chart — retention % (18 months)
LTV / CPA visual (subscription stories)
{/* Stats cards */}
{/* About Linea */}
{/* CTA */}
{/* Tags + summary for matching */}
{ set('tags', next); save('tags', next); }}
/>
);
}
function TagChipsRow({ tags, onChange }) {
const TAXONOMY = [
{ key: 'industry', label: 'Industry', options: ['F&B','Beauty','Fashion','SaaS','Agency','Retail','Real estate','Travel','Education','Health','Luxury','D2C consumer','Other'] },
{ key: 'biz_model', label: 'Business model', options: ['D2C','B2B','B2C','Marketplace','Subscription','Retainer','One-off'] },
{ key: 'stage', label: 'Stage', options: ['Pre-launch','Launching','Scaling','Mature','Pivoting'] },
{ key: 'region', label: 'Region', options: ['UAE','GCC','Italy','Europe','MENA','Global'] },
{ key: 'audience', label: 'Audience', options: ['Mass-market','Premium','Luxury','Niche','Local','Tourist'] },
];
const set = (k, v) => onChange({ ...tags, [k]: v });
const toggle = (k, opt) => {
const arr = tags[k] || [];
set(k, arr.includes(opt) ? arr.filter(x => x !== opt) : [...arr, opt]);
};
const setKeywords = (str) => set('keywords', str.split(',').map(s => s.trim()).filter(Boolean));
return (
{TAXONOMY.map((t) => (
{t.label}
{t.options.map((opt) => {
const active = (tags[t.key] || []).includes(opt);
return (
toggle(t.key, opt)} style={{
padding: '4px 10px', fontSize: 11, cursor: 'pointer',
background: active ? 'var(--accent-bg)' : 'transparent',
border: '1px solid', borderColor: active ? 'var(--accent)' : 'var(--divider)',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
borderRadius: 4,
}}>{opt}
);
})}
))}
);
}
/* ============================================================
SHARED: Section, Row, Field, ListEditor, BulletList
============================================================ */
function Section({ title, eyebrow, hint, children }) {
return (
{eyebrow}
{title}
{hint && {hint}
}
{children}
);
}
function Row({ children }) {
return {children}
;
}
function CSField({ label, hint, small, children }) {
return (
{label}
{children}
{hint &&
{hint}
}
);
}
function BulletList({ items, onChange, placeholder }) {
const list = Array.isArray(items) ? items : [];
const update = (i, v) => onChange(list.map((x, idx) => idx === i ? v : x));
const remove = (i) => onChange(list.filter((_, idx) => idx !== i));
const add = () => onChange([...list, '']);
return (
{list.map((item, i) => (
update(i, e.target.value)} placeholder={placeholder} style={{ flex: 1 }} />
remove(i)}>
))}
Add
);
}
function ListEditor({ items, onChange, newItem, maxItems, renderItem }) {
const list = Array.isArray(items) ? items : [];
const update = (i, patch) => onChange(list.map((x, idx) => idx === i ? { ...x, ...patch } : x));
const remove = (i) => onChange(list.filter((_, idx) => idx !== i));
const add = () => onChange([...list, newItem(list.length)]);
return (
{list.map((item, i) => (
{renderItem(item, (patch) => update(i, patch), i)}
remove(i)} style={{ position: 'absolute', top: 6, right: 6, background: 'none', border: 'none', color: 'var(--text-dim)', cursor: 'pointer', padding: 2 }}>
))}
{(!maxItems || list.length < maxItems) && (
Add {list.length === 0 ? 'first item' : 'item'}
)}
);
}
/* ============================================================
IMAGES TAB — Phase 2: library on left, slot map on right
============================================================ */
const SLOT_DEFS = [
{ name: 'client_logo', label: 'Client logo', page: 'Topbar (every page)', aspect: '5:1', help: 'Wide horizontal lockup' },
{ name: 'cover_hero', label: 'Cover hero', page: 'Page 1 — Cover', aspect: '5:3', help: 'Main feature image' },
{ name: 'hotel_logos', label: 'B2B partner logos', page: 'Page 2 — Before (callout)', aspect: '5:2', help: 'Up to 10', multi: true, max: 10 },
{ name: 'ecom_hero', label: 'Ecom landing page', page: 'Page 4 — Live and Direct', aspect: '5:3', help: 'Full-width screenshot' },
{ name: 'ecom_secondary', label: 'Ecom secondary', page: 'Page 4 — Live and Direct', aspect: '4:3', help: 'Secondary landing screenshot' },
{ name: 'ecom_mobile', label: 'Mobile screenshot', page: 'Page 4 — Live and Direct', aspect: '9:16', help: 'Phone aspect' },
{ name: 'ad_creatives', label: 'Ad creatives', page: 'Page 5 — Numbers (optional)', aspect: '1:1', help: 'Up to 4', multi: true, max: 4 },
{ name: 'founders_photo', label: 'Founders photo', page: 'Page 7 — CTA', aspect: '5:3', help: '78mm wide on the page' },
];
function _aspectToFloat(s) {
const [a, b] = (s || '5:3').split(':').map(Number);
return (a && b) ? `${a} / ${b}` : '5 / 3';
}
function ImagesTab({ cs, reload }) {
const [images, setImages] = useState(cs.images || []);
const [busyUpload, setBusyUpload] = useState(0);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
// Keep local list in sync if parent reloads
useEffect(() => { setImages(cs.images || []); }, [cs.images]);
const slots = cs.image_slots || {};
const refreshImages = async () => {
const r = await fetch(`/api/case-studies/${cs.id}`, { credentials: 'include' });
if (r.ok) {
const d = await r.json();
setImages(d.images || []);
}
};
const uploadFiles = async (fileList) => {
const files = Array.from(fileList || []).filter((f) => f.type.startsWith('image/'));
if (files.length === 0) return;
setBusyUpload((n) => n + files.length);
setError(null);
for (const f of files) {
try {
const fd = new FormData();
fd.append('file', f);
const r = await fetch(`/api/case-studies/${cs.id}/images`, {
method: 'POST', credentials: 'include', body: fd,
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || `${r.status} ${r.statusText}`);
}
const data = await r.json();
setImages((prev) => [data, ...prev]);
} catch (e) {
setError(`Couldn't upload ${f.name}: ${e.message}`);
} finally {
setBusyUpload((n) => Math.max(0, n - 1));
}
}
};
const deleteImage = async (image_id) => {
if (!confirm('Delete this image? Any slot using it will be cleared.')) return;
const r = await fetch(`/api/case-studies/${cs.id}/images/${image_id}`, { method: 'DELETE', credentials: 'include' });
if (r.ok) {
setImages((prev) => prev.filter((x) => x.id !== image_id));
reload();
}
};
const assignSlot = async (slotName, value) => {
// value is { image_id } or { image_ids: [...] } or null to clear
const body = value || {};
const r = await fetch(`/api/case-studies/${cs.id}/slots/${slotName}`, {
method: 'PATCH', credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) reload();
};
return (
{/* LEFT: library */}
{images.length === 0 && busyUpload === 0 && (
No images yet. Drop files above to upload.
)}
{images.map((img) => (
deleteImage(img.id)} />
))}
{/* RIGHT: slot map */}
Slot map · drag images from the library into a slot
{SLOT_DEFS.map((def) => {
const value = slots[def.name];
return def.multi
? assignSlot(def.name, { image_ids: ids })} />
: assignSlot(def.name, id ? { image_id: id } : null)} />;
})}
);
}
function UploadZone({ busy, onFiles, onClickPick }) {
const [drag, setDrag] = useState(false);
return (
{ e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); onFiles(e.dataTransfer.files); }}
onClick={onClickPick}
style={{
padding: '14px 12px',
border: `1.5px dashed ${drag ? 'var(--accent)' : 'var(--divider)'}`,
background: drag ? 'var(--accent-bg)' : 'transparent',
cursor: 'pointer', textAlign: 'center', borderRadius: 8,
transition: 'all 0.1s', userSelect: 'none',
}}
>
{busy ? 'Uploading…' : 'Drop images or click to upload'}
JPG · PNG · WEBP · max 12 MB
);
}
function LibraryThumb({ img, onDelete }) {
const onDragStart = (e) => {
e.dataTransfer.setData('text/x-cs-image-id', img.id);
e.dataTransfer.effectAllowed = 'copy';
};
const aspectLabel = img.aspect_ratio ? `${(img.aspect_ratio).toFixed(2)}:1` : '?';
return (
{aspectLabel}
{ e.stopPropagation(); onDelete(); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-dim)', padding: 2, lineHeight: 1 }}
title="Delete from library"
>
);
}
function SlotRow({ def, imageId, images, onChange }) {
const assigned = images.find((i) => i.id === imageId);
const [hover, setHover] = useState(false);
const onDragOver = (e) => {
if (e.dataTransfer.types.includes('text/x-cs-image-id')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setHover(true);
}
};
const onDrop = (e) => {
e.preventDefault();
setHover(false);
const id = e.dataTransfer.getData('text/x-cs-image-id');
if (id) onChange(id);
};
return (
setHover(false)}
onDrop={onDrop}
style={{
aspectRatio: _aspectToFloat(def.aspect),
border: `1.5px dashed ${hover ? 'var(--accent)' : (assigned ? 'transparent' : 'var(--divider)')}`,
background: assigned ? '#000' : (hover ? 'var(--accent-bg)' : 'var(--raised)'),
borderRadius: 6,
overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'relative',
transition: 'all 0.1s',
}}
>
{assigned ? (
<>
onChange(null)}
style={{ position: 'absolute', top: 4, right: 4, padding: '2px 6px', fontSize: 10, background: 'rgba(0,0,0,0.7)', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
title="Clear assignment"
>
×
>
) : (
)}
{def.label}
{def.page}
Aspect ratio {def.aspect} · {def.help}
{assigned && (
{assigned.filename}
)}
);
}
function MultiSlotRow({ def, ids, images, onChange }) {
const cells = ids.map((id) => images.find((i) => i.id === id) || null);
while (cells.length < def.max) cells.push(null);
const setIdAt = (idx, newId) => {
const next = [...ids];
if (newId === null) {
next.splice(idx, 1);
} else {
next[idx] = newId;
}
onChange(next.filter(Boolean));
};
return (
{ids.length}/{def.max} · aspect {def.aspect}
{cells.map((img, idx) => (
setIdAt(idx, newId)}
onClear={() => setIdAt(idx, null)}
/>
))}
{def.help}
);
}
function MultiSlotCell({ img, aspect, onAssign, onClear }) {
const [hover, setHover] = useState(false);
const onDragOver = (e) => {
if (e.dataTransfer.types.includes('text/x-cs-image-id')) {
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
setHover(true);
}
};
const onDrop = (e) => {
e.preventDefault(); setHover(false);
const id = e.dataTransfer.getData('text/x-cs-image-id');
if (id) onAssign(id);
};
return (
setHover(false)}
onDrop={onDrop}
style={{
aspectRatio: _aspectToFloat(aspect),
border: `1.5px dashed ${hover ? 'var(--accent)' : (img ? 'transparent' : 'var(--divider)')}`,
background: img ? '#000' : (hover ? 'var(--accent-bg)' : 'var(--raised)'),
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{img ? (
<>
×
>
) : (
{aspect}
)}
);
}
/* ============================================================
PREVIEW + RENDER TABS — Phase 3
============================================================ */
function PreviewTab({ cs }) {
// Cache-bust on every brief change so the iframe re-renders
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { setReloadKey((k) => k + 1); }, [cs.updated_at]);
const url = `/api/case-studies/${cs.id}/preview.html?_=${reloadKey}`;
return (
Live preview · re-renders when the brief or image slots change.
Open in new tab
setReloadKey((k) => k + 1)}>
Refresh
);
}
function RenderTab({ cs, reload }) {
const [busy, setBusy] = useState(false);
const [last, setLast] = useState(cs.last_rendered_at || null);
const [err, setErr] = useState(null);
const previewUrl = `/api/case-studies/${cs.id}/preview.html`;
const snapshot = async () => {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/case-studies/${cs.id}/render`, { method: 'POST', credentials: 'include' });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || `${r.status} ${r.statusText}`);
}
const data = await r.json();
setLast(data.last_rendered_at);
reload();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
Render
The HTML version is fully functional. To produce a PDF, open the preview in a new tab and use your browser's Print → Save as PDF (Cmd+P) at A4 page size with no headers/footers. Server-side Playwright render is queued for the next phase.
Open preview in new tab
{busy ? 'Saving snapshot…' : 'Save HTML snapshot'}
{last &&
Last snapshot: {new Date(last).toLocaleString()} }
{err &&
{err}
}
Snapshot saves the rendered HTML at /srv/linea-crm/case-study-pdfs/{cs.id}/{cs.client_slug}-<timestamp>.html on the server.
Tip · Manual PDF (matches the existing skill)
On your Mac, run:
{`# 1. Save the preview HTML locally:
curl -L -b "$LINEA_CRM_COOKIE" \\
"https://crm.lineagroup.ae/api/case-studies/${cs.id}/preview.html" \\
-o ~/Desktop/${cs.client_slug}.html
# 2. Render to PDF with Chrome headless (per .claude/skills/case-study-builder/SKILL.md):
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
"$CHROME" --headless --disable-gpu --no-pdf-header-footer --print-to-pdf-no-header \\
--print-to-pdf="$HOME/Desktop/${cs.client_name.replace(/[^a-zA-Z0-9]+/g, '')}-CaseStudy-LineaDark.pdf" \\
"file://$HOME/Desktop/${cs.client_slug}.html"
open "$HOME/Desktop/${cs.client_name.replace(/[^a-zA-Z0-9]+/g, '')}-CaseStudy-LineaDark.pdf"`}
);
}
Object.assign(window, { CaseStudies, CaseStudyEditor });