| 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> |
| ); |
| } |
|
|