Download frontend/src/components/settings/LandingPagesSettings.jsx from Seth0330/EMAILOUT: direct link, hf CLI and curl.
- Browser
- Download file 36.8 kB
-
https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/settings/LandingPagesSettings.jsx
- Command line
-
hf download hf://spaces/Seth0330/EMAILOUT/frontend/src/components/settings/LandingPagesSettings.jsx
-
curl -L -o LandingPagesSettings.jsx https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/settings/LandingPagesSettings.jsx
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> | |
| ); | |
| } | |