Added public page editor
X-Lovable-Edit-ID: edt-86109e4f-97e6-40f7-83b9-c987e841ae79 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -2775,6 +2775,86 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
public_page_sections: {
|
||||
Row: {
|
||||
content: Json
|
||||
created_at: string
|
||||
id: string
|
||||
page_id: string
|
||||
section_type: string
|
||||
sort_order: number
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
content?: Json
|
||||
created_at?: string
|
||||
id?: string
|
||||
page_id: string
|
||||
section_type: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
content?: Json
|
||||
created_at?: string
|
||||
id?: string
|
||||
page_id?: string
|
||||
section_type?: string
|
||||
sort_order?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "public_page_sections_page_id_fkey"
|
||||
columns: ["page_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "public_pages"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
public_pages: {
|
||||
Row: {
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
meta_description: string | null
|
||||
published_at: string | null
|
||||
show_in_nav: boolean
|
||||
slug: string
|
||||
sort_order: number
|
||||
status: string
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
meta_description?: string | null
|
||||
published_at?: string | null
|
||||
show_in_nav?: boolean
|
||||
slug: string
|
||||
sort_order?: number
|
||||
status?: string
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
meta_description?: string | null
|
||||
published_at?: string | null
|
||||
show_in_nav?: boolean
|
||||
slug?: string
|
||||
sort_order?: number
|
||||
status?: string
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
smtp_settings: {
|
||||
Row: {
|
||||
created_at: string
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
|
||||
import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields'
|
||||
import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields'
|
||||
import { Route as PayIdRouteImport } from './routes/pay.$id'
|
||||
import { Route as PSlugRouteImport } from './routes/p.$slug'
|
||||
import { Route as InvoicesNewRouteImport } from './routes/invoices.new'
|
||||
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
|
||||
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
|
||||
@@ -52,7 +53,9 @@ 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 SettingsPublicPagesPageIdRouteImport } from './routes/settings.public-pages.$pageId'
|
||||
import { Route as LovableEmailSuppressionRouteImport } from './routes/lovable/email/suppression'
|
||||
import { Route as InvoicesNewClientIdRouteImport } from './routes/invoices.new.$clientId'
|
||||
import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new'
|
||||
@@ -224,6 +227,11 @@ const PayIdRoute = PayIdRouteImport.update({
|
||||
path: '/pay/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PSlugRoute = PSlugRouteImport.update({
|
||||
id: '/p/$slug',
|
||||
path: '/p/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InvoicesNewRoute = InvoicesNewRouteImport.update({
|
||||
id: '/invoices/new',
|
||||
path: '/invoices/new',
|
||||
@@ -279,11 +287,23 @@ 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/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsPublicPagesPageIdRoute =
|
||||
SettingsPublicPagesPageIdRouteImport.update({
|
||||
id: '/public-pages/$pageId',
|
||||
path: '/public-pages/$pageId',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const LovableEmailSuppressionRoute = LovableEmailSuppressionRouteImport.update({
|
||||
id: '/lovable/email/suppression',
|
||||
path: '/lovable/email/suppression',
|
||||
@@ -356,6 +376,7 @@ export interface FileRoutesByFullPath {
|
||||
'/hooks/poll-imap': typeof HooksPollImapRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/invoices/new': typeof InvoicesNewRouteWithChildren
|
||||
'/p/$slug': typeof PSlugRoute
|
||||
'/pay/$id': typeof PayIdRoute
|
||||
'/settings/client-fields': typeof SettingsClientFieldsRoute
|
||||
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
|
||||
@@ -391,7 +412,9 @@ export interface FileRoutesByFullPath {
|
||||
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
|
||||
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
|
||||
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
|
||||
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
|
||||
'/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
|
||||
@@ -411,6 +434,7 @@ export interface FileRoutesByTo {
|
||||
'/hooks/poll-imap': typeof HooksPollImapRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/invoices/new': typeof InvoicesNewRouteWithChildren
|
||||
'/p/$slug': typeof PSlugRoute
|
||||
'/pay/$id': typeof PayIdRoute
|
||||
'/settings/client-fields': typeof SettingsClientFieldsRoute
|
||||
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
|
||||
@@ -446,7 +470,9 @@ export interface FileRoutesByTo {
|
||||
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
|
||||
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
|
||||
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
|
||||
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
|
||||
'/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
|
||||
@@ -468,6 +494,7 @@ export interface FileRoutesById {
|
||||
'/hooks/poll-imap': typeof HooksPollImapRoute
|
||||
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
|
||||
'/invoices/new': typeof InvoicesNewRouteWithChildren
|
||||
'/p/$slug': typeof PSlugRoute
|
||||
'/pay/$id': typeof PayIdRoute
|
||||
'/settings/client-fields': typeof SettingsClientFieldsRoute
|
||||
'/settings/custom-fields': typeof SettingsCustomFieldsRoute
|
||||
@@ -503,7 +530,9 @@ export interface FileRoutesById {
|
||||
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
|
||||
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
|
||||
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
|
||||
'/settings/public-pages/$pageId': typeof SettingsPublicPagesPageIdRoute
|
||||
'/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
|
||||
@@ -526,6 +555,7 @@ export interface FileRouteTypes {
|
||||
| '/hooks/poll-imap'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/invoices/new'
|
||||
| '/p/$slug'
|
||||
| '/pay/$id'
|
||||
| '/settings/client-fields'
|
||||
| '/settings/custom-fields'
|
||||
@@ -561,7 +591,9 @@ export interface FileRouteTypes {
|
||||
| '/documents/templates/new'
|
||||
| '/invoices/new/$clientId'
|
||||
| '/lovable/email/suppression'
|
||||
| '/settings/public-pages/$pageId'
|
||||
| '/documents/templates/'
|
||||
| '/settings/public-pages/'
|
||||
| '/lovable/email/queue/process'
|
||||
| '/lovable/email/transactional/preview'
|
||||
| '/lovable/email/transactional/send'
|
||||
@@ -581,6 +613,7 @@ export interface FileRouteTypes {
|
||||
| '/hooks/poll-imap'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/invoices/new'
|
||||
| '/p/$slug'
|
||||
| '/pay/$id'
|
||||
| '/settings/client-fields'
|
||||
| '/settings/custom-fields'
|
||||
@@ -616,7 +649,9 @@ export interface FileRouteTypes {
|
||||
| '/documents/templates/new'
|
||||
| '/invoices/new/$clientId'
|
||||
| '/lovable/email/suppression'
|
||||
| '/settings/public-pages/$pageId'
|
||||
| '/documents/templates'
|
||||
| '/settings/public-pages'
|
||||
| '/lovable/email/queue/process'
|
||||
| '/lovable/email/transactional/preview'
|
||||
| '/lovable/email/transactional/send'
|
||||
@@ -637,6 +672,7 @@ export interface FileRouteTypes {
|
||||
| '/hooks/poll-imap'
|
||||
| '/invoices/$invoiceId'
|
||||
| '/invoices/new'
|
||||
| '/p/$slug'
|
||||
| '/pay/$id'
|
||||
| '/settings/client-fields'
|
||||
| '/settings/custom-fields'
|
||||
@@ -672,7 +708,9 @@ export interface FileRouteTypes {
|
||||
| '/documents/templates/new'
|
||||
| '/invoices/new/$clientId'
|
||||
| '/lovable/email/suppression'
|
||||
| '/settings/public-pages/$pageId'
|
||||
| '/documents/templates/'
|
||||
| '/settings/public-pages/'
|
||||
| '/lovable/email/queue/process'
|
||||
| '/lovable/email/transactional/preview'
|
||||
| '/lovable/email/transactional/send'
|
||||
@@ -694,6 +732,7 @@ export interface RootRouteChildren {
|
||||
HooksPollImapRoute: typeof HooksPollImapRoute
|
||||
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
|
||||
InvoicesNewRoute: typeof InvoicesNewRouteWithChildren
|
||||
PSlugRoute: typeof PSlugRoute
|
||||
PayIdRoute: typeof PayIdRoute
|
||||
ArchiveIndexRoute: typeof ArchiveIndexRoute
|
||||
CalendarIndexRoute: typeof CalendarIndexRoute
|
||||
@@ -948,6 +987,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PayIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/p/$slug': {
|
||||
id: '/p/$slug'
|
||||
path: '/p/$slug'
|
||||
fullPath: '/p/$slug'
|
||||
preLoaderRoute: typeof PSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/invoices/new': {
|
||||
id: '/invoices/new'
|
||||
path: '/invoices/new'
|
||||
@@ -1025,6 +1071,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'
|
||||
@@ -1032,6 +1085,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocumentsTemplatesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/public-pages/$pageId': {
|
||||
id: '/settings/public-pages/$pageId'
|
||||
path: '/public-pages/$pageId'
|
||||
fullPath: '/settings/public-pages/$pageId'
|
||||
preLoaderRoute: typeof SettingsPublicPagesPageIdRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/lovable/email/suppression': {
|
||||
id: '/lovable/email/suppression'
|
||||
path: '/lovable/email/suppression'
|
||||
@@ -1117,6 +1177,8 @@ interface SettingsRouteChildren {
|
||||
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
|
||||
SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute
|
||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||
SettingsPublicPagesPageIdRoute: typeof SettingsPublicPagesPageIdRoute
|
||||
SettingsPublicPagesIndexRoute: typeof SettingsPublicPagesIndexRoute
|
||||
}
|
||||
|
||||
const SettingsRouteChildren: SettingsRouteChildren = {
|
||||
@@ -1131,6 +1193,8 @@ const SettingsRouteChildren: SettingsRouteChildren = {
|
||||
SettingsWorkflowRoute: SettingsWorkflowRoute,
|
||||
SettingsWorkflowsRoute: SettingsWorkflowsRoute,
|
||||
SettingsIndexRoute: SettingsIndexRoute,
|
||||
SettingsPublicPagesPageIdRoute: SettingsPublicPagesPageIdRoute,
|
||||
SettingsPublicPagesIndexRoute: SettingsPublicPagesIndexRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
|
||||
@@ -1177,6 +1241,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
HooksPollImapRoute: HooksPollImapRoute,
|
||||
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
|
||||
InvoicesNewRoute: InvoicesNewRouteWithChildren,
|
||||
PSlugRoute: PSlugRoute,
|
||||
PayIdRoute: PayIdRoute,
|
||||
ArchiveIndexRoute: ArchiveIndexRoute,
|
||||
CalendarIndexRoute: CalendarIndexRoute,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createFileRoute, Link } 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";
|
||||
|
||||
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<PageRow | null>(null);
|
||||
const [sections, setSections] = useState<SectionRow[]>([]);
|
||||
const [firm, setFirm] = useState<FirmRow | null>(null);
|
||||
const [navPages, setNavPages] = useState<NavPage[]>([]);
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(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 <div className="min-h-screen flex items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||
}
|
||||
if (!page) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">This page doesn't exist or hasn't been published yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const firmName = firm?.company_name ?? "Stage Law Firm, PLLC";
|
||||
const cityLine = [firm?.city, firm?.state, firm?.postal_code].filter(Boolean).join(", ");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 flex flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between">
|
||||
<Link to="/p/$slug" params={{ slug: navPages[0]?.slug ?? slug }} className="flex items-center gap-3">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={firmName} className="h-12 w-auto" />
|
||||
) : (
|
||||
<div className="h-12 w-12 bg-slate-100 rounded flex items-center justify-center text-xs">Logo</div>
|
||||
)}
|
||||
<span className="font-serif text-xl tracking-wide uppercase">{firmName}</span>
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-6 text-sm font-medium uppercase tracking-wide">
|
||||
{navPages.map((np) => (
|
||||
<Link key={np.slug} to="/p/$slug" params={{ slug: np.slug }} className="hover:text-slate-600">
|
||||
{np.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
{sections.length === 0 ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-serif">{page.title}</h1>
|
||||
<p className="text-muted-foreground mt-2">No content yet.</p>
|
||||
</div>
|
||||
) : sections.map((s) => <SectionRenderer key={s.id} section={s} />)}
|
||||
</main>
|
||||
|
||||
<footer className="bg-[hsl(220_60%_15%)] text-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-12 grid md:grid-cols-2 gap-8">
|
||||
<div className="flex items-start gap-4">
|
||||
{logoUrl && <img src={logoUrl} alt={firmName} className="h-16 w-auto bg-white/5 rounded p-1" />}
|
||||
<div>
|
||||
<h3 className="font-serif text-2xl">{firmName}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
{firm?.contact_phone && (
|
||||
<div className="flex items-center gap-3"><Phone className="h-4 w-4 opacity-70" /><a href={`tel:${firm.contact_phone}`}>{firm.contact_phone}</a></div>
|
||||
)}
|
||||
{firm?.contact_email && (
|
||||
<div className="flex items-center gap-3"><Mail className="h-4 w-4 opacity-70" /><a href={`mailto:${firm.contact_email}`}>{firm.contact_email}</a></div>
|
||||
)}
|
||||
{(firm?.address_line1 || cityLine) && (
|
||||
<div className="flex items-start gap-3"><MapPin className="h-4 w-4 opacity-70 mt-0.5" />
|
||||
<div>{firm?.address_line1}{firm?.address_line2 ? <>, {firm.address_line2}</> : null}{cityLine ? <><br />{cityLine}</> : null}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[hsl(15_60%_55%)] text-white py-3 text-center text-sm">
|
||||
Copyright {new Date().getFullYear()}. All Rights Reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRenderer({ section }: { section: SectionRow }) {
|
||||
const c = section.content || {};
|
||||
switch (section.section_type) {
|
||||
case "hero":
|
||||
return (
|
||||
<section className="bg-slate-50 border-b border-slate-200">
|
||||
<div className="max-w-4xl mx-auto px-6 py-20 text-center">
|
||||
{c.eyebrow && <p className="text-xs uppercase tracking-[0.3em] text-slate-500 mb-4">{c.eyebrow}</p>}
|
||||
<h1 className="font-serif text-4xl md:text-5xl text-[hsl(220_60%_15%)]">{c.heading}</h1>
|
||||
{c.subheading && <p className="mt-4 text-lg text-slate-600 max-w-2xl mx-auto">{c.subheading}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "rich_text":
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto px-6 py-12">
|
||||
<div className="prose prose-slate max-w-none" dangerouslySetInnerHTML={{ __html: c.html ?? "" }} />
|
||||
</section>
|
||||
);
|
||||
case "feature_grid": {
|
||||
const items = Array.isArray(c.items) ? c.items : [];
|
||||
return (
|
||||
<section className="max-w-5xl mx-auto px-6 py-16">
|
||||
{c.heading && <h2 className="font-serif text-3xl text-center mb-10 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{items.map((it: any, i: number) => (
|
||||
<div key={i} className="text-center">
|
||||
<h3 className="font-serif text-xl mb-2">{it.title}</h3>
|
||||
<p className="text-slate-600 text-sm">{it.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
case "cta":
|
||||
return (
|
||||
<section className="bg-[hsl(220_60%_15%)] text-white">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16 text-center">
|
||||
<h2 className="font-serif text-3xl">{c.heading}</h2>
|
||||
{c.subheading && <p className="mt-3 text-white/80">{c.subheading}</p>}
|
||||
{c.button_label && c.button_url && (
|
||||
<Button asChild size="lg" className="mt-6 bg-white text-[hsl(220_60%_15%)] hover:bg-white/90">
|
||||
<a href={c.button_url}>{c.button_label}</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "contact": {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto px-6 py-16">
|
||||
{c.heading && <h2 className="font-serif text-3xl text-center mb-8 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
|
||||
<div className="grid sm:grid-cols-3 gap-6 text-center">
|
||||
{c.phone && <div><Phone className="h-5 w-5 mx-auto mb-2 text-slate-500" /><a href={`tel:${c.phone}`} className="font-medium">{c.phone}</a></div>}
|
||||
{c.email && <div><Mail className="h-5 w-5 mx-auto mb-2 text-slate-500" /><a href={`mailto:${c.email}`} className="font-medium">{c.email}</a></div>}
|
||||
{c.address && <div><MapPin className="h-5 w-5 mx-auto mb-2 text-slate-500" /><div className="font-medium whitespace-pre-line">{c.address}</div></div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
case "faq": {
|
||||
const items = Array.isArray(c.items) ? c.items : [];
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto px-6 py-16">
|
||||
{c.heading && <h2 className="font-serif text-3xl text-center mb-8 text-[hsl(220_60%_15%)]">{c.heading}</h2>}
|
||||
<div className="space-y-4">
|
||||
{items.map((it: any, i: number) => (
|
||||
<div key={i} className="border-b border-slate-200 pb-4">
|
||||
<h3 className="font-serif text-lg mb-2">{it.q}</h3>
|
||||
<p className="text-slate-600 text-sm">{it.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -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: "<p>Write something...</p>" };
|
||||
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<PageRow | null>(null);
|
||||
const [sections, setSections] = useState<SectionRow[]>([]);
|
||||
|
||||
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 <p className="text-sm text-muted-foreground">Admins only.</p>;
|
||||
if (loading) return <div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>;
|
||||
if (!page) return <p className="text-sm text-muted-foreground">Page not found.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/settings/public-pages"><ArrowLeft className="h-4 w-4 mr-2" />All pages</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{page.status === "published" && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/p/${page.slug}`} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4 mr-2" />View</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={savePage} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Page settings</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input value={page.title} onChange={(e) => setPage({ ...page, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slug</Label>
|
||||
<Input value={page.slug} onChange={(e) => setPage({ ...page, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Meta description (SEO)</Label>
|
||||
<Textarea rows={2} value={page.meta_description ?? ""} onChange={(e) => setPage({ ...page, meta_description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Select value={page.status} onValueChange={(v) => setPage({ ...page, status: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sort order</Label>
|
||||
<Input type="number" value={page.sort_order} onChange={(e) => setPage({ ...page, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<Switch checked={page.show_in_nav} onCheckedChange={(v) => setPage({ ...page, show_in_nav: v })} id="nav" />
|
||||
<Label htmlFor="nav">Show in nav</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Sections</CardTitle>
|
||||
<Select onValueChange={addSection}>
|
||||
<SelectTrigger className="w-[220px]"><SelectValue placeholder="+ Add section" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SECTION_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No sections yet. Add one above.</p>
|
||||
)}
|
||||
{sections.map((s, i) => (
|
||||
<Card key={s.id} className="border-2">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{SECTION_TYPES.find(t => t.value === s.section_type)?.label ?? s.section_type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => move(s.id, -1)} disabled={i === 0}><ChevronUp className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => move(s.id, 1)} disabled={i === sections.length - 1}><ChevronDown className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => persistSection(s.id)}><Save className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeSection(s.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionEditor section={s} onChange={(c) => updateSection(s.id, c)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionEditor({ section, onChange }: { section: SectionRow; onChange: (c: any) => void }) {
|
||||
const c = section.content || {};
|
||||
const upd = (patch: any) => onChange({ ...c, ...patch });
|
||||
|
||||
const uploadImage = async (file: File): Promise<string | null> => {
|
||||
const ext = file.name.split(".").pop() || "png";
|
||||
const path = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const { error } = await supabase.storage.from("public-page-images").upload(path, file, { upsert: false, contentType: file.type });
|
||||
if (error) { toast.error("Upload failed", { description: error.message }); return null; }
|
||||
const { data } = supabase.storage.from("public-page-images").getPublicUrl(path);
|
||||
return data.publicUrl;
|
||||
};
|
||||
|
||||
switch (section.section_type) {
|
||||
case "hero":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Eyebrow (small label above heading)</Label><Input value={c.eyebrow ?? ""} onChange={(e) => upd({ eyebrow: e.target.value })} /></div>
|
||||
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
|
||||
<div><Label>Subheading</Label><Textarea rows={2} value={c.subheading ?? ""} onChange={(e) => upd({ subheading: e.target.value })} /></div>
|
||||
</div>
|
||||
);
|
||||
case "rich_text":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Body</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file"; input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]; if (!file) return;
|
||||
const url = await uploadImage(file);
|
||||
if (url) upd({ html: (c.html ?? "") + `<p><img src="${url}" alt="" style="max-width:100%;" /></p>` });
|
||||
};
|
||||
input.click();
|
||||
}}><ImageIcon className="h-4 w-4 mr-2" />Insert image</Button>
|
||||
</div>
|
||||
<RichTextEditor value={c.html ?? ""} onChange={(html) => upd({ html })} minHeight={250} />
|
||||
</div>
|
||||
);
|
||||
case "feature_grid": {
|
||||
const items = Array.isArray(c.items) ? c.items : [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
|
||||
<div className="space-y-2">
|
||||
{items.map((it: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-start border rounded-md p-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input placeholder="Title" value={it.title ?? ""} onChange={(e) => {
|
||||
const next = [...items]; next[idx] = { ...it, title: e.target.value }; upd({ items: next });
|
||||
}} />
|
||||
<Textarea rows={2} placeholder="Description" value={it.body ?? ""} onChange={(e) => {
|
||||
const next = [...items]; next[idx] = { ...it, body: e.target.value }; upd({ items: next });
|
||||
}} />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => upd({ items: items.filter((_: any, i: number) => i !== idx) })}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => upd({ items: [...items, { title: "", body: "" }] })}><Plus className="h-4 w-4 mr-2" />Add feature</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "cta":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
|
||||
<div><Label>Subheading</Label><Textarea rows={2} value={c.subheading ?? ""} onChange={(e) => upd({ subheading: e.target.value })} /></div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div><Label>Button label</Label><Input value={c.button_label ?? ""} onChange={(e) => upd({ button_label: e.target.value })} /></div>
|
||||
<div><Label>Button URL</Label><Input value={c.button_url ?? ""} onChange={(e) => upd({ button_url: e.target.value })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "contact":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div><Label>Phone</Label><Input value={c.phone ?? ""} onChange={(e) => upd({ phone: e.target.value })} /></div>
|
||||
<div><Label>Email</Label><Input value={c.email ?? ""} onChange={(e) => upd({ email: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><Label>Address</Label><Textarea rows={2} value={c.address ?? ""} onChange={(e) => upd({ address: e.target.value })} /></div>
|
||||
</div>
|
||||
);
|
||||
case "faq": {
|
||||
const items = Array.isArray(c.items) ? c.items : [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div><Label>Heading</Label><Input value={c.heading ?? ""} onChange={(e) => upd({ heading: e.target.value })} /></div>
|
||||
<div className="space-y-2">
|
||||
{items.map((it: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-start border rounded-md p-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input placeholder="Question" value={it.q ?? ""} onChange={(e) => {
|
||||
const next = [...items]; next[idx] = { ...it, q: e.target.value }; upd({ items: next });
|
||||
}} />
|
||||
<Textarea rows={2} placeholder="Answer" value={it.a ?? ""} onChange={(e) => {
|
||||
const next = [...items]; next[idx] = { ...it, a: e.target.value }; upd({ items: next });
|
||||
}} />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => upd({ items: items.filter((_: any, i: number) => i !== idx) })}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => upd({ items: [...items, { q: "", a: "" }] })}><Plus className="h-4 w-4 mr-2" />Add Q&A</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: return <p className="text-sm text-muted-foreground">Unknown section type.</p>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, Plus, ExternalLink, Pencil, Trash2, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/public-pages/")({
|
||||
component: PublicPagesSettingsPage,
|
||||
});
|
||||
|
||||
type PageRow = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
status: string;
|
||||
show_in_nav: boolean;
|
||||
sort_order: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function PublicPagesSettingsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pages, setPages] = useState<PageRow[]>([]);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("public_pages")
|
||||
.select("id,slug,title,status,show_in_nav,sort_order,updated_at")
|
||||
.order("sort_order", { ascending: true })
|
||||
.order("title", { ascending: true });
|
||||
setPages((data ?? []) as PageRow[]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
|
||||
const create = async () => {
|
||||
if (!newTitle.trim()) { toast.error("Title is required"); return; }
|
||||
const slug = (newSlug.trim() || slugify(newTitle)).toLowerCase();
|
||||
if (!slug) { toast.error("Invalid slug"); return; }
|
||||
setCreating(true);
|
||||
const { data, error } = await supabase
|
||||
.from("public_pages")
|
||||
.insert({ title: newTitle.trim(), slug, status: "draft" })
|
||||
.select("id")
|
||||
.single();
|
||||
setCreating(false);
|
||||
if (error) { toast.error("Could not create", { description: error.message }); return; }
|
||||
toast.success("Page created");
|
||||
setNewOpen(false);
|
||||
setNewTitle(""); setNewSlug("");
|
||||
navigate({ to: "/settings/public-pages/$pageId", params: { pageId: data.id } });
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm("Delete this page and all its sections?")) return;
|
||||
const { error } = await supabase.from("public_pages").delete().eq("id", id);
|
||||
if (error) { toast.error("Could not delete", { description: error.message }); return; }
|
||||
toast.success("Deleted");
|
||||
load();
|
||||
};
|
||||
|
||||
if (!isAdmin) return <p className="text-sm text-muted-foreground">Admins only.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Public pages</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage marketing pages rendered at <code>/p/{slug}</code>.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-2" />New page</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Pages</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
||||
) : pages.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-muted-foreground">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
No pages yet. Create your first one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{pages.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{p.title}</span>
|
||||
<Badge variant={p.status === "published" ? "default" : "secondary"}>{p.status}</Badge>
|
||||
{!p.show_in_nav && <Badge variant="outline">hidden in nav</Badge>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">/p/{p.slug}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{p.status === "published" && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={`/p/${p.slug}`} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4" /></a>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/settings/public-pages/$pageId" params={{ pageId: p.id }}><Pencil className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(p.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={newOpen} onOpenChange={setNewOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New public page</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input value={newTitle} onChange={(e) => {
|
||||
setNewTitle(e.target.value);
|
||||
if (!newSlug) setNewSlug(slugify(e.target.value));
|
||||
}} placeholder="About Us" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slug</Label>
|
||||
<Input value={newSlug} onChange={(e) => setNewSlug(slugify(e.target.value))} placeholder="about-us" />
|
||||
<p className="text-xs text-muted-foreground mt-1">URL: /p/{newSlug || "your-slug"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancel</Button>
|
||||
<Button onClick={create} disabled={creating}>{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const TABS = [
|
||||
{ to: "/settings/form-templates", label: "Form templates" },
|
||||
{ to: "/settings/workflow", label: "Collections workflow" },
|
||||
{ to: "/settings/workflows", label: "Task workflows" },
|
||||
{ to: "/settings/public-pages", label: "Public pages" },
|
||||
{ to: "/settings/smtp", label: "Email (SMTP)" },
|
||||
{ to: "/settings/imap", label: "Email (IMAP)" },
|
||||
{ to: "/settings/import", label: "CSV import" },
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
-- Public pages CMS tables
|
||||
CREATE TABLE public.public_pages (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
meta_description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
show_in_nav BOOLEAN NOT NULL DEFAULT true,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_public_pages_slug ON public.public_pages(slug);
|
||||
CREATE INDEX idx_public_pages_status ON public.public_pages(status);
|
||||
|
||||
CREATE TABLE public.public_page_sections (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
page_id UUID NOT NULL REFERENCES public.public_pages(id) ON DELETE CASCADE,
|
||||
section_type TEXT NOT NULL CHECK (section_type IN ('hero','rich_text','feature_grid','cta','contact','faq')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
content JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_public_page_sections_page_id ON public.public_page_sections(page_id);
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER trg_public_pages_updated_at
|
||||
BEFORE UPDATE ON public.public_pages
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_public_page_sections_updated_at
|
||||
BEFORE UPDATE ON public.public_page_sections
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.public_pages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.public_page_sections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public can read published pages
|
||||
CREATE POLICY "Anyone can view published pages"
|
||||
ON public.public_pages FOR SELECT
|
||||
USING (status = 'published');
|
||||
|
||||
CREATE POLICY "Admins can view all pages"
|
||||
ON public.public_pages FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can insert pages"
|
||||
ON public.public_pages FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can update pages"
|
||||
ON public.public_pages FOR UPDATE
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can delete pages"
|
||||
ON public.public_pages FOR DELETE
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
-- Sections inherit access from page
|
||||
CREATE POLICY "Anyone can view sections of published pages"
|
||||
ON public.public_page_sections FOR SELECT
|
||||
USING (EXISTS (SELECT 1 FROM public.public_pages p WHERE p.id = page_id AND p.status = 'published'));
|
||||
|
||||
CREATE POLICY "Admins can view all sections"
|
||||
ON public.public_page_sections FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can insert sections"
|
||||
ON public.public_page_sections FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can update sections"
|
||||
ON public.public_page_sections FOR UPDATE
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can delete sections"
|
||||
ON public.public_page_sections FOR DELETE
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
|
||||
-- Storage bucket for inline images
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('public-page-images', 'public-page-images', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE POLICY "Public page images are publicly accessible"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'public-page-images');
|
||||
|
||||
CREATE POLICY "Admins can upload public page images"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can update public page images"
|
||||
ON storage.objects FOR UPDATE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));
|
||||
|
||||
CREATE POLICY "Admins can delete public page images"
|
||||
ON storage.objects FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'public-page-images' AND public.is_admin(auth.uid()));
|
||||
Reference in New Issue
Block a user