Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
95e84ad842
commit
515bf890c6
@@ -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<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user