From 515bf890c6a40e9dd6c5c795ec22be546bc69aba Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:53:26 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 31 ++ src/routes/p.$slug.tsx | 219 ++++++++++++ src/routes/settings.public-pages.$pageId.tsx | 351 +++++++++++++++++++ 3 files changed, 601 insertions(+) create mode 100644 src/routes/p.$slug.tsx create mode 100644 src/routes/settings.public-pages.$pageId.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5d98e10..ed3ef6f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -52,6 +52,7 @@ import { Route as CasesNewRouteImport } from './routes/cases.new' import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId' import { Route as ApiStripeWebhookRouteImport } from './routes/api.stripe-webhook' import { Route as AdminUsersRouteImport } from './routes/admin.users' +import { Route as SettingsPublicPagesIndexRouteImport } from './routes/settings.public-pages.index' import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index' import { Route as LovableEmailSuppressionRouteImport } from './routes/lovable/email/suppression' import { Route as InvoicesNewClientIdRouteImport } from './routes/invoices.new.$clientId' @@ -279,6 +280,12 @@ const AdminUsersRoute = AdminUsersRouteImport.update({ path: '/admin/users', getParentRoute: () => rootRouteImport, } as any) +const SettingsPublicPagesIndexRoute = + SettingsPublicPagesIndexRouteImport.update({ + id: '/public-pages/', + path: '/public-pages/', + getParentRoute: () => SettingsRoute, + } as any) const DocumentsTemplatesIndexRoute = DocumentsTemplatesIndexRouteImport.update({ id: '/documents/templates/', path: '/documents/templates/', @@ -392,6 +399,7 @@ export interface FileRoutesByFullPath { '/invoices/new/$clientId': typeof InvoicesNewClientIdRoute '/lovable/email/suppression': typeof LovableEmailSuppressionRoute '/documents/templates/': typeof DocumentsTemplatesIndexRoute + '/settings/public-pages/': typeof SettingsPublicPagesIndexRoute '/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute '/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute '/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute @@ -447,6 +455,7 @@ export interface FileRoutesByTo { '/invoices/new/$clientId': typeof InvoicesNewClientIdRoute '/lovable/email/suppression': typeof LovableEmailSuppressionRoute '/documents/templates': typeof DocumentsTemplatesIndexRoute + '/settings/public-pages': typeof SettingsPublicPagesIndexRoute '/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute '/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute '/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute @@ -504,6 +513,7 @@ export interface FileRoutesById { '/invoices/new/$clientId': typeof InvoicesNewClientIdRoute '/lovable/email/suppression': typeof LovableEmailSuppressionRoute '/documents/templates/': typeof DocumentsTemplatesIndexRoute + '/settings/public-pages/': typeof SettingsPublicPagesIndexRoute '/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute '/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute '/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute @@ -562,6 +572,7 @@ export interface FileRouteTypes { | '/invoices/new/$clientId' | '/lovable/email/suppression' | '/documents/templates/' + | '/settings/public-pages/' | '/lovable/email/queue/process' | '/lovable/email/transactional/preview' | '/lovable/email/transactional/send' @@ -617,6 +628,7 @@ export interface FileRouteTypes { | '/invoices/new/$clientId' | '/lovable/email/suppression' | '/documents/templates' + | '/settings/public-pages' | '/lovable/email/queue/process' | '/lovable/email/transactional/preview' | '/lovable/email/transactional/send' @@ -673,6 +685,7 @@ export interface FileRouteTypes { | '/invoices/new/$clientId' | '/lovable/email/suppression' | '/documents/templates/' + | '/settings/public-pages/' | '/lovable/email/queue/process' | '/lovable/email/transactional/preview' | '/lovable/email/transactional/send' @@ -1025,6 +1038,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminUsersRouteImport parentRoute: typeof rootRouteImport } + '/settings/public-pages/': { + id: '/settings/public-pages/' + path: '/public-pages' + fullPath: '/settings/public-pages/' + preLoaderRoute: typeof SettingsPublicPagesIndexRouteImport + parentRoute: typeof SettingsRoute + } '/documents/templates/': { id: '/documents/templates/' path: '/documents/templates' @@ -1117,6 +1137,7 @@ interface SettingsRouteChildren { SettingsWorkflowRoute: typeof SettingsWorkflowRoute SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute SettingsIndexRoute: typeof SettingsIndexRoute + SettingsPublicPagesIndexRoute: typeof SettingsPublicPagesIndexRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -1131,6 +1152,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsWorkflowRoute: SettingsWorkflowRoute, SettingsWorkflowsRoute: SettingsWorkflowsRoute, SettingsIndexRoute: SettingsIndexRoute, + SettingsPublicPagesIndexRoute: SettingsPublicPagesIndexRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( @@ -1207,3 +1229,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/p.$slug.tsx b/src/routes/p.$slug.tsx new file mode 100644 index 0000000..6752ada --- /dev/null +++ b/src/routes/p.$slug.tsx @@ -0,0 +1,219 @@ +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { Loader2, Phone, Mail, MapPin } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import justiceIcon from "@/components/justice-icon"; + +export const Route = createFileRoute("/p/$slug")({ + component: PublicPagePage, +}); + +type PageRow = { + id: string; slug: string; title: string; meta_description: string | null; status: string; +}; +type SectionRow = { id: string; section_type: string; sort_order: number; content: any }; +type FirmRow = { + company_name: string | null; contact_email: string | null; contact_phone: string | null; + address_line1: string | null; address_line2: string | null; city: string | null; + state: string | null; postal_code: string | null; logo_storage_path: string | null; +}; +type NavPage = { slug: string; title: string }; + +function PublicPagePage() { + const { slug } = Route.useParams(); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(null); + const [sections, setSections] = useState([]); + const [firm, setFirm] = useState(null); + const [navPages, setNavPages] = useState([]); + const [logoUrl, setLogoUrl] = useState(null); + + useEffect(() => { + (async () => { + setLoading(true); + const [pRes, fRes, nRes] = await Promise.all([ + supabase.from("public_pages").select("*").eq("slug", slug).eq("status", "published").maybeSingle(), + supabase.from("firm_settings").select("*").order("updated_at", { ascending: false }).limit(1).maybeSingle(), + supabase.from("public_pages").select("slug,title").eq("status", "published").eq("show_in_nav", true).order("sort_order", { ascending: true }), + ]); + if (pRes.data) { + setPage(pRes.data as PageRow); + const { data: sData } = await supabase.from("public_page_sections") + .select("*").eq("page_id", (pRes.data as PageRow).id) + .order("sort_order", { ascending: true }); + setSections((sData ?? []) as SectionRow[]); + } + if (fRes.data) { + setFirm(fRes.data as FirmRow); + if ((fRes.data as FirmRow).logo_storage_path) { + const { data } = supabase.storage.from("firm-logos").getPublicUrl((fRes.data as FirmRow).logo_storage_path!); + setLogoUrl(data.publicUrl); + } + } + setNavPages((nRes.data ?? []) as NavPage[]); + setLoading(false); + if (pRes.data) { + document.title = `${(pRes.data as PageRow).title} | ${(fRes.data as FirmRow | null)?.company_name ?? "Stage Law Firm"}`; + } + })(); + }, [slug]); + + if (loading) { + return
; + } + if (!page) { + return ( +
+
+

Page not found

+

This page doesn't exist or hasn't been published yet.

+
+
+ ); + } + + const firmName = firm?.company_name ?? "Stage Law Firm, PLLC"; + const cityLine = [firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", "); + + return ( +
+
+
+ + {logoUrl ? ( + {firmName} + ) : ( +
Logo
+ )} + {firmName} + + +
+
+ +
+ {sections.length === 0 ? ( +
+

{page.title}

+

No content yet.

+
+ ) : sections.map((s) => )} +
+ +
+
+
+ {logoUrl && {firmName}} +
+

{firmName}

+
+
+
+ {firm?.contact_phone && ( + + )} + {firm?.contact_email && ( + + )} + {(firm?.address_line1 || cityLine) && ( +
+
{firm?.address_line1}{firm?.address_line2 ? <>, {firm.address_line2} : null}{cityLine ? <>
{cityLine} : null}
+
+ )} +
+
+
+ Copyright {new Date().getFullYear()}. All Rights Reserved. +
+
+
+ ); +} + +function SectionRenderer({ section }: { section: SectionRow }) { + const c = section.content || {}; + switch (section.section_type) { + case "hero": + return ( +
+
+ {c.eyebrow &&

{c.eyebrow}

} +

{c.heading}

+ {c.subheading &&

{c.subheading}

} +
+
+ ); + case "rich_text": + return ( +
+
+
+ ); + case "feature_grid": { + const items = Array.isArray(c.items) ? c.items : []; + return ( +
+ {c.heading &&

{c.heading}

} +
+ {items.map((it: any, i: number) => ( +
+

{it.title}

+

{it.body}

+
+ ))} +
+
+ ); + } + case "cta": + return ( +
+
+

{c.heading}

+ {c.subheading &&

{c.subheading}

} + {c.button_label && c.button_url && ( + + )} +
+
+ ); + case "contact": { + return ( +
+ {c.heading &&

{c.heading}

} +
+ {c.phone && } + {c.email && } + {c.address &&
{c.address}
} +
+
+ ); + } + case "faq": { + const items = Array.isArray(c.items) ? c.items : []; + return ( +
+ {c.heading &&

{c.heading}

} +
+ {items.map((it: any, i: number) => ( +
+

{it.q}

+

{it.a}

+
+ ))} +
+
+ ); + } + default: return null; + } +} diff --git a/src/routes/settings.public-pages.$pageId.tsx b/src/routes/settings.public-pages.$pageId.tsx new file mode 100644 index 0000000..3cf7426 --- /dev/null +++ b/src/routes/settings.public-pages.$pageId.tsx @@ -0,0 +1,351 @@ +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { RichTextEditor } from "@/components/documents/rich-text-editor"; +import { + Loader2, ArrowLeft, Plus, Trash2, ChevronUp, ChevronDown, Save, ExternalLink, Image as ImageIcon, X, +} from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/settings/public-pages/$pageId")({ + component: PublicPageEditor, +}); + +type PageRow = { + id: string; slug: string; title: string; meta_description: string | null; + status: string; show_in_nav: boolean; sort_order: number; +}; + +type SectionRow = { + id: string; page_id: string; section_type: string; sort_order: number; content: any; +}; + +const SECTION_TYPES = [ + { value: "hero", label: "Hero (heading + subtext)" }, + { value: "rich_text", label: "Rich text block" }, + { value: "feature_grid", label: "Feature grid" }, + { value: "cta", label: "Call-to-action" }, + { value: "contact", label: "Contact info" }, + { value: "faq", label: "FAQ list" }, +]; + +function defaultContent(type: string): any { + switch (type) { + case "hero": return { heading: "Heading", subheading: "Subheading", eyebrow: "" }; + case "rich_text": return { html: "

Write something...

" }; + case "feature_grid": return { heading: "Features", items: [{ title: "Feature 1", body: "Description" }] }; + case "cta": return { heading: "Ready to start?", subheading: "", button_label: "Contact us", button_url: "/p/contact" }; + case "contact": return { heading: "Get in touch", phone: "", email: "", address: "" }; + case "faq": return { heading: "FAQ", items: [{ q: "Question?", a: "Answer." }] }; + default: return {}; + } +} + +function PublicPageEditor() { + const { pageId } = Route.useParams(); + const { isAdmin } = useAuth(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [page, setPage] = useState(null); + const [sections, setSections] = useState([]); + + const load = async () => { + setLoading(true); + const [pRes, sRes] = await Promise.all([ + supabase.from("public_pages").select("*").eq("id", pageId).maybeSingle(), + supabase.from("public_page_sections").select("*").eq("page_id", pageId).order("sort_order", { ascending: true }), + ]); + if (pRes.data) setPage(pRes.data as PageRow); + setSections((sRes.data ?? []) as SectionRow[]); + setLoading(false); + }; + + useEffect(() => { load(); }, [pageId]); + + const savePage = async () => { + if (!page) return; + setSaving(true); + const updates: any = { + title: page.title, slug: page.slug, meta_description: page.meta_description, + status: page.status, show_in_nav: page.show_in_nav, sort_order: page.sort_order, + }; + if (page.status === "published") updates.published_at = new Date().toISOString(); + const { error } = await supabase.from("public_pages").update(updates).eq("id", pageId); + setSaving(false); + if (error) { toast.error("Could not save", { description: error.message }); return; } + toast.success("Page saved"); + }; + + const addSection = async (type: string) => { + const sort_order = sections.length > 0 ? Math.max(...sections.map(s => s.sort_order)) + 1 : 0; + const { data, error } = await supabase + .from("public_page_sections") + .insert({ page_id: pageId, section_type: type, sort_order, content: defaultContent(type) }) + .select("*").single(); + if (error) { toast.error("Could not add section", { description: error.message }); return; } + setSections([...sections, data as SectionRow]); + }; + + const updateSection = (id: string, content: any) => { + setSections(sections.map(s => s.id === id ? { ...s, content } : s)); + }; + + const persistSection = async (id: string) => { + const s = sections.find(x => x.id === id); + if (!s) return; + const { error } = await supabase.from("public_page_sections").update({ content: s.content }).eq("id", id); + if (error) toast.error("Could not save section", { description: error.message }); + else toast.success("Section saved"); + }; + + const removeSection = async (id: string) => { + if (!confirm("Delete this section?")) return; + const { error } = await supabase.from("public_page_sections").delete().eq("id", id); + if (error) { toast.error("Could not delete", { description: error.message }); return; } + setSections(sections.filter(s => s.id !== id)); + }; + + const move = async (id: string, dir: -1 | 1) => { + const idx = sections.findIndex(s => s.id === id); + const swapIdx = idx + dir; + if (swapIdx < 0 || swapIdx >= sections.length) return; + const a = sections[idx]; const b = sections[swapIdx]; + const newSections = [...sections]; + newSections[idx] = { ...b, sort_order: a.sort_order }; + newSections[swapIdx] = { ...a, sort_order: b.sort_order }; + setSections(newSections); + await Promise.all([ + supabase.from("public_page_sections").update({ sort_order: b.sort_order }).eq("id", a.id), + supabase.from("public_page_sections").update({ sort_order: a.sort_order }).eq("id", b.id), + ]); + }; + + if (!isAdmin) return

Admins only.

; + if (loading) return
; + if (!page) return

Page not found.

; + + return ( +
+
+ +
+ {page.status === "published" && ( + + )} + +
+
+ + + Page settings + +
+
+ + setPage({ ...page, title: e.target.value })} /> +
+
+ + setPage({ ...page, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") })} /> +
+
+
+ +