EMAILOUT / frontend /src /components /settings /LandingPagesSettings.jsx
Seth
update
257d77d
Raw
History Blame Contribute Delete
36.8 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft,
ClipboardCopy,
ExternalLink,
ImagePlus,
Loader2,
Plus,
Sparkles,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import AppShell from '@/components/layout/AppShell';
import LandingPageTemplate from '@/components/landing/LandingPageTemplate';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
const AUTOSAVE_MS = 700;
function emptyDraft() {
return {
name: 'New landing page',
prompt: '',
content: {
headline: '',
subheadline: '',
benefits: [
{ title: 'Benefit 1', body: '' },
{ title: 'Benefit 2', body: '' },
{ title: 'Benefit 3', body: '' },
],
form_heading: 'Book a demo',
form_subtext: '',
footer_text: '',
},
cta_form_id: null,
banner_url: null,
logo_url: null,
public_url: '',
cta_form_public_id: null,
};
}
function draftFromPage(p) {
const c = p.content || {};
const benefits = (c.benefits || []).map((b) => ({ ...b }));
while (benefits.length < 3) {
benefits.push({ title: `Benefit ${benefits.length + 1}`, body: '' });
}
return {
name: p.name,
prompt: p.prompt || '',
content: {
headline: c.headline || '',
subheadline: c.subheadline || '',
benefits: benefits.slice(0, 3),
form_heading: c.form_heading || 'Book a demo',
form_subtext: c.form_subtext || '',
footer_text: c.footer_text || '',
},
cta_form_id: p.cta_form_id ?? null,
banner_url: p.banner_url || null,
logo_url: p.logo_url || null,
public_url: p.public_url || '',
cta_form_public_id: p.cta_form_public_id || null,
};
}
function draftSnapshot(draft) {
return JSON.stringify({
name: (draft.name || '').trim(),
prompt: draft.prompt || '',
content: draft.content,
cta_form_id: draft.cta_form_id,
});
}
function SaveIndicator({ state }) {
if (state === 'saving') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Saving…
</span>
);
}
if (state === 'saved') return <span className="text-xs text-green-600">Saved</span>;
if (state === 'error') return <span className="text-xs text-red-600">Could not save</span>;
return null;
}
export default function LandingPagesSettings({ onBack }) {
const [pages, setPages] = useState([]);
const [ctaForms, setCtaForms] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState(null);
const [draft, setDraft] = useState(emptyDraft());
const [saveState, setSaveState] = useState('idle');
const [creating, setCreating] = useState(false);
const [generating, setGenerating] = useState(false);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [error, setError] = useState('');
const skipAutosaveRef = useRef(true);
const saveTimerRef = useRef(null);
const lastSavedSnapshotRef = useRef('');
const savedFadeTimerRef = useRef(null);
const selected = useMemo(
() => pages.find((p) => p.id === selectedId) || null,
[pages, selectedId]
);
const loadPages = useCallback(async () => {
setLoading(true);
setError('');
try {
const [pr, fr] = await Promise.all([
apiFetch('/api/landing-pages'),
apiFetch('/api/landing-pages/cta-form-options'),
]);
const pdata = pr.ok ? await pr.json() : { pages: [] };
const fdata = fr.ok ? await fr.json() : { forms: [] };
if (!pr.ok) throw new Error(pdata.detail || 'Could not load pages');
const list = pdata.pages || [];
setPages(list);
setCtaForms(fdata.forms || []);
setSelectedId((prev) => {
if (prev && list.some((p) => p.id === prev)) return prev;
return list.length ? list[0].id : null;
});
} catch (e) {
setError(e.message || 'Load failed');
setPages([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadPages();
}, [loadPages]);
useEffect(() => {
if (!selected) return;
const next = draftFromPage(selected);
skipAutosaveRef.current = true;
lastSavedSnapshotRef.current = draftSnapshot(next);
setDraft(next);
setSaveState('idle');
}, [selected?.id]);
useEffect(() => {
if (!selectedId) return;
if (skipAutosaveRef.current) {
skipAutosaveRef.current = false;
return;
}
const snapshot = draftSnapshot(draft);
if (snapshot === lastSavedSnapshotRef.current) return;
const name = draft.name?.trim();
if (!name) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
if (snapshot !== draftSnapshot(draft)) return;
setSaveState('saving');
try {
const payload = JSON.parse(snapshot);
const res = await apiFetch(`/api/landing-pages/${selectedId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: payload.name,
prompt: payload.prompt,
content: payload.content,
cta_form_id: payload.cta_form_id,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Save failed');
lastSavedSnapshotRef.current = snapshot;
setPages((prev) => prev.map((p) => (p.id === data.id ? data : p)));
setDraft((d) => ({
...d,
public_url: data.public_url || d.public_url,
banner_url: data.banner_url ?? d.banner_url,
logo_url: data.logo_url ?? d.logo_url,
cta_form_public_id: data.cta_form_public_id ?? d.cta_form_public_id,
}));
if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current);
setSaveState('saved');
savedFadeTimerRef.current = window.setTimeout(() => setSaveState('idle'), 2000);
} catch (e) {
setError(e.message || 'Save failed');
setSaveState('error');
}
}, AUTOSAVE_MS);
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [draft, selectedId]);
const selectPage = (p) => {
const next = draftFromPage(p);
setSelectedId(p.id);
skipAutosaveRef.current = true;
lastSavedSnapshotRef.current = draftSnapshot(next);
setDraft(next);
setSaveState('idle');
};
const createPage = async () => {
setCreating(true);
setError('');
try {
const res = await apiFetch('/api/landing-pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New landing page', prompt: '' }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Create failed');
await loadPages();
setSelectedId(data.id);
const next = draftFromPage(data);
skipAutosaveRef.current = true;
lastSavedSnapshotRef.current = draftSnapshot(next);
setDraft(next);
} catch (e) {
setError(e.message || 'Create failed');
} finally {
setCreating(false);
}
};
const deletePage = async () => {
if (!selectedId) return;
if (!window.confirm('Delete this landing page? The public link will stop working.')) return;
try {
const res = await apiFetch(`/api/landing-pages/${selectedId}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Delete failed');
}
setSelectedId(null);
setDraft(emptyDraft());
await loadPages();
} catch (e) {
setError(e.message || 'Delete failed');
}
};
const generateFromPrompt = async () => {
if (!selectedId) return;
const prompt = draft.prompt?.trim();
if (!prompt || prompt.length < 8) {
setError('Enter a prompt with at least 8 characters to generate.');
return;
}
setGenerating(true);
setError('');
try {
const res = await apiFetch(`/api/landing-pages/${selectedId}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Generation failed');
setPages((prev) => prev.map((p) => (p.id === data.id ? data : p)));
const next = draftFromPage(data);
skipAutosaveRef.current = true;
lastSavedSnapshotRef.current = draftSnapshot(next);
setDraft(next);
setSaveState('saved');
} catch (e) {
setError(e.message || 'Generation failed');
} finally {
setGenerating(false);
}
};
const uploadAsset = async (kind, file) => {
if (!selectedId || !file) return;
const setter = kind === 'banner' ? setUploadingBanner : setUploadingLogo;
setter(true);
setError('');
try {
const fd = new FormData();
fd.append('file', file);
const res = await apiFetch(`/api/landing-pages/${selectedId}/${kind}`, {
method: 'POST',
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Upload failed');
setPages((prev) => prev.map((p) => (p.id === data.id ? data : p)));
setDraft((d) => ({
...d,
banner_url: data.banner_url ?? d.banner_url,
logo_url: data.logo_url ?? d.logo_url,
public_url: data.public_url || d.public_url,
}));
} catch (e) {
setError(e.message || 'Upload failed');
} finally {
setter(false);
}
};
const updateContent = (key, value) => {
setDraft((d) => ({
...d,
content: { ...d.content, [key]: value },
}));
};
const updateBenefit = (idx, patch) => {
setDraft((d) => ({
...d,
content: {
...d.content,
benefits: d.content.benefits.map((b, i) => (i === idx ? { ...b, ...patch } : b)),
},
}));
};
const previewCtaPublicId = useMemo(() => {
if (draft.cta_form_public_id) return draft.cta_form_public_id;
const form = ctaForms.find((f) => f.id === draft.cta_form_id);
return form?.public_id || null;
}, [draft.cta_form_id, draft.cta_form_public_id, ctaForms]);
const publicUrl = draft.public_url || (selected?.public_url ?? '');
return (
<AppShell
title="Landing Pages"
subtitle="Generate appointment-style pages from a prompt, add your branding, and embed a CTA form."
>
<div className="w-full max-w-[1600px]">
<div className="mb-6 flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" className="gap-2" onClick={onBack}>
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to settings
</Button>
<Button type="button" onClick={createPage} disabled={creating} className="gap-2">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
New page
</Button>
</div>
{error ? (
<p className="mb-4 text-sm text-red-600 rounded-lg border border-red-200 bg-red-50 px-3 py-2">
{error}
</p>
) : null}
{loading ? (
<div className="flex justify-center py-20 text-slate-400">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)] xl:grid-cols-[200px_minmax(280px,1fr)_minmax(520px,1.15fr)] 2xl:grid-cols-[200px_minmax(320px,0.95fr)_minmax(600px,1.25fr)]">
<aside className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm h-fit">
<p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Your pages
</p>
{pages.length === 0 ? (
<p className="px-2 py-4 text-sm text-slate-500">No pages yet.</p>
) : (
<ul className="space-y-1">
{pages.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => selectPage(p)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
selectedId === p.id
? 'bg-violet-100 text-violet-800 font-medium'
: 'text-slate-700 hover:bg-slate-50'
)}
>
{p.name || 'Untitled'}
</button>
</li>
))}
</ul>
)}
</aside>
<div className="space-y-6 min-w-0">
{!selected ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white p-10 text-center text-slate-500">
Create a landing page to generate copy and share a public link.
</div>
) : (
<>
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-3 justify-between">
<div className="flex-1 min-w-[12rem]">
<label className="text-xs font-medium text-slate-600">
Page name
</label>
<Input
value={draft.name}
onChange={(e) =>
setDraft((d) => ({ ...d, name: e.target.value }))
}
className="mt-1"
/>
</div>
<div className="flex items-center gap-3 pb-1">
<SaveIndicator state={saveState} />
<Button
type="button"
variant="outline"
size="sm"
className="text-red-600"
onClick={deletePage}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-600">
AI prompt
</label>
<p className="text-[10px] text-slate-400 mt-0.5">
Describe your offer, audience, and goal — AI fills the
page template
</p>
<Textarea
value={draft.prompt}
onChange={(e) =>
setDraft((d) => ({ ...d, prompt: e.target.value }))
}
rows={3}
className="mt-1 text-sm"
placeholder="e.g. B2B demo booking page for our AI document platform targeting operations leaders..."
/>
<Button
type="button"
className="mt-2 gap-2"
onClick={generateFromPrompt}
disabled={generating}
>
{generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
Generate page content
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-xs font-medium text-slate-600">
Banner image
</label>
<label className="mt-1 flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2 text-xs text-slate-600 hover:bg-slate-50">
<ImagePlus className="h-4 w-4" />
{uploadingBanner ? 'Uploading…' : 'Upload banner'}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={uploadingBanner}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadAsset('banner', f);
e.target.value = '';
}}
/>
</label>
</div>
<div>
<label className="text-xs font-medium text-slate-600">
Logo
</label>
<label className="mt-1 flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2 text-xs text-slate-600 hover:bg-slate-50">
<ImagePlus className="h-4 w-4" />
{uploadingLogo ? 'Uploading…' : 'Upload logo'}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={uploadingLogo}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadAsset('logo', f);
e.target.value = '';
}}
/>
</label>
</div>
</div>
<div>
<label className="text-xs font-medium text-slate-600">
CTA form
</label>
<Select
value={
draft.cta_form_id != null
? String(draft.cta_form_id)
: 'none'
}
onValueChange={(v) => {
const id = v === 'none' ? null : parseInt(v, 10);
const form = ctaForms.find((f) => f.id === id);
setDraft((d) => ({
...d,
cta_form_id: id,
cta_form_public_id: form?.public_id || null,
}));
}}
>
<SelectTrigger className="mt-1 h-9 text-sm">
<SelectValue placeholder="Select a form" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No form</SelectItem>
{ctaForms.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
{ctaForms.length === 0 ? (
<p className="text-[10px] text-amber-600 mt-1">
Create a CTA form first under Manage CTA forms.
</p>
) : null}
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Page copy
</p>
<div>
<label className="text-[10px] text-slate-500">Headline</label>
<Input
value={draft.content.headline}
onChange={(e) => updateContent('headline', e.target.value)}
className="mt-0.5 h-9 text-sm"
/>
</div>
<div>
<label className="text-[10px] text-slate-500">Subheadline</label>
<Textarea
value={draft.content.subheadline}
onChange={(e) =>
updateContent('subheadline', e.target.value)
}
rows={2}
className="text-sm"
/>
</div>
{draft.content.benefits.map((b, idx) => (
<div
key={idx}
className="rounded-xl border border-slate-100 bg-slate-50/80 p-3 space-y-2"
>
<Input
value={b.title}
onChange={(e) =>
updateBenefit(idx, { title: e.target.value })
}
placeholder={`Benefit ${idx + 1} title`}
className="h-8 text-sm font-medium"
/>
<Textarea
value={b.body}
onChange={(e) =>
updateBenefit(idx, { body: e.target.value })
}
rows={2}
className="text-sm"
/>
</div>
))}
<div>
<label className="text-[10px] text-slate-500">
Form heading
</label>
<Input
value={draft.content.form_heading}
onChange={(e) =>
updateContent('form_heading', e.target.value)
}
className="mt-0.5 h-9 text-sm"
/>
</div>
<div>
<label className="text-[10px] text-slate-500">
Form subtext
</label>
<Textarea
value={draft.content.form_subtext}
onChange={(e) =>
updateContent('form_subtext', e.target.value)
}
rows={2}
className="text-sm"
/>
</div>
<div>
<label className="text-[10px] text-slate-500">Footer</label>
<Input
value={draft.content.footer_text}
onChange={(e) =>
updateContent('footer_text', e.target.value)
}
className="mt-0.5 h-9 text-sm"
/>
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-3 xl:hidden">
<PreviewBlock
draft={draft}
previewCtaPublicId={previewCtaPublicId}
publicUrl={publicUrl}
/>
</section>
{publicUrl ? (
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-2">
<p className="text-sm font-semibold text-slate-800">
Public link
</p>
<p className="text-xs text-slate-500">
Share on LinkedIn, email, or ads. Submissions go to your
embedded CTA form → Leads.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-xs bg-slate-100 px-2 py-1.5 rounded break-all flex-1 min-w-[12rem]">
{publicUrl}
</code>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1"
onClick={() =>
publicUrl &&
navigator.clipboard.writeText(publicUrl)
}
>
<ClipboardCopy className="h-3.5 w-3.5" />
Copy
</Button>
<a
href={publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-9 items-center justify-center rounded-md border border-slate-200 bg-white px-3 text-sm font-medium hover:bg-slate-50 gap-1"
>
Open
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</section>
) : null}
</>
)}
</div>
{selected ? (
<aside className="hidden xl:block min-w-0">
<div className="sticky top-4 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm w-full">
<PreviewBlock
draft={draft}
previewCtaPublicId={previewCtaPublicId}
publicUrl={publicUrl}
/>
</div>
</aside>
) : null}
</div>
)}
</div>
</AppShell>
);
}
function PreviewBlock({ draft, previewCtaPublicId, publicUrl }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2 px-1">
<p className="text-sm font-semibold text-slate-800">Live preview</p>
{publicUrl ? (
<a
href={publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-600 hover:underline"
>
Open tab
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="max-h-[78vh] overflow-y-auto overflow-x-auto rounded-xl w-full">
<LandingPageTemplate
preview
className="w-full"
content={draft.content}
bannerUrl={draft.banner_url}
logoUrl={draft.logo_url}
ctaFormPublicId={previewCtaPublicId}
/>
</div>
</div>
);
}