Implement Site Builder with Puck and Booking Widget

This commit is contained in:
poduck
2025-12-10 23:54:10 -05:00
parent 384fe0fd86
commit 76c0d71aa0
25 changed files with 1103 additions and 1 deletions

View File

@@ -110,6 +110,8 @@ const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Im
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -346,7 +348,7 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
<Route path="/" element={<PublicPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
@@ -869,6 +871,16 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<PageEditor />
) : (
<Navigate to="/" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/settings" element={<SettingsLayout />}>

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { usePublicServices, useCreateBooking } from '../../hooks/useBooking';
import { Loader2 } from 'lucide-react';
interface BookingWidgetProps {
headline?: string;
subheading?: string;
accentColor?: string;
buttonLabel?: string;
}
export const BookingWidget: React.FC<BookingWidgetProps> = ({
headline = "Book Appointment",
subheading = "Select a service",
accentColor = "#2563eb",
buttonLabel = "Book Now"
}) => {
const { data: services, isLoading } = usePublicServices();
const createBooking = useCreateBooking();
const [selectedService, setSelectedService] = useState<any>(null);
if (isLoading) return <div className="flex justify-center"><Loader2 className="animate-spin" /></div>;
const handleBook = async () => {
if (!selectedService) return;
try {
await createBooking.mutateAsync({ service_id: selectedService.id });
alert("Booking created (stub)!");
} catch (e) {
console.error(e);
alert("Error creating booking");
}
};
return (
<div className="booking-widget p-6 bg-white rounded-lg shadow-md max-w-md mx-auto text-left">
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
<p className="text-gray-600 mb-6">{subheading}</p>
<div className="space-y-4 mb-6">
{services?.length === 0 && <p>No services available.</p>}
{services?.map((service: any) => (
<div
key={service.id}
className={`p-4 border rounded cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
onClick={() => setSelectedService(service)}
>
<h3 className="font-semibold">{service.name}</h3>
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
</div>
))}
</div>
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: accentColor }}
>
{buttonLabel}
</button>
</div>
);
};
export default BookingWidget;

View File

@@ -0,0 +1,33 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import api from '../api/client';
export const usePublicServices = () => {
return useQuery({
queryKey: ['publicServices'],
queryFn: async () => {
const response = await api.get('/public/services/');
return response.data;
},
retry: false,
});
};
export const usePublicAvailability = (serviceId: string, date: string) => {
return useQuery({
queryKey: ['publicAvailability', serviceId, date],
queryFn: async () => {
const response = await api.get(`/public/availability/?service_id=${serviceId}&date=${date}`);
return response.data;
},
enabled: !!serviceId && !!date,
});
};
export const useCreateBooking = () => {
return useMutation({
mutationFn: async (data: any) => {
const response = await api.post('/public/bookings/', data);
return response.data;
},
});
};

View File

@@ -0,0 +1,58 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
export const useSite = () => {
return useQuery({
queryKey: ['site'],
queryFn: async () => {
const response = await api.get('/sites/me/');
return response.data;
},
});
};
export const usePages = () => {
return useQuery({
queryKey: ['pages'],
queryFn: async () => {
const response = await api.get('/sites/me/pages/');
return response.data;
},
});
};
export const usePage = (pageId: string) => {
return useQuery({
queryKey: ['page', pageId],
queryFn: async () => {
const response = await api.get(`/sites/me/pages/${pageId}/`);
return response.data;
},
enabled: !!pageId,
});
};
export const useUpdatePage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const response = await api.patch(`/sites/me/pages/${id}/`, data);
return response.data;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['page', variables.id] });
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
});
};
export const usePublicPage = () => {
return useQuery({
queryKey: ['publicPage'],
queryFn: async () => {
const response = await api.get('/public/page/');
return response.data;
},
retry: false,
});
};

View File

@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import { Puck } from "@measured/puck";
import "@measured/puck/puck.css";
import { config } from "../puckConfig";
import { usePages, useUpdatePage } from "../hooks/useSites";
import { Loader2 } from "lucide-react";
import toast from 'react-hot-toast';
export const PageEditor: React.FC = () => {
const { data: pages, isLoading } = usePages();
const updatePage = useUpdatePage();
const [data, setData] = useState<any>(null);
const homePage = pages?.find((p: any) => p.is_home) || pages?.[0];
useEffect(() => {
if (homePage?.puck_data) {
// Ensure data structure is valid for Puck
const puckData = homePage.puck_data;
if (!puckData.content) puckData.content = [];
if (!puckData.root) puckData.root = {};
setData(puckData);
} else if (homePage) {
setData({ content: [], root: {} });
}
}, [homePage]);
const handlePublish = async (newData: any) => {
if (!homePage) return;
try {
await updatePage.mutateAsync({ id: homePage.id, data: { puck_data: newData } });
toast.success("Page published successfully!");
} catch (error) {
toast.error("Failed to publish page.");
console.error(error);
}
};
if (isLoading) {
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
}
if (!homePage) {
return <div>No page found. Please contact support.</div>;
}
if (!data) return null;
return (
<div className="h-screen flex flex-col">
<Puck
config={config}
data={data}
onPublish={handlePublish}
/>
</div>
);
};
export default PageEditor;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Render } from "@measured/puck";
import { config } from "../puckConfig";
import { usePublicPage } from "../hooks/useSites";
import { Loader2 } from "lucide-react";
export const PublicPage: React.FC = () => {
const { data, isLoading, error } = usePublicPage();
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
}
if (error || !data) {
return <div className="min-h-screen flex items-center justify-center">Page not found or site disabled.</div>;
}
return (
<div className="public-page">
<Render config={config} data={data.puck_data} />
</div>
);
};
export default PublicPage;

View File

@@ -0,0 +1,87 @@
import React from "react";
import type { Config } from "@measured/puck";
import BookingWidget from "./components/booking/BookingWidget";
type Props = {
Hero: { title: string; subtitle: string; align: "left" | "center" | "right"; backgroundColor: string; textColor: string };
TextSection: { heading: string; body: string; backgroundColor: string };
Booking: { headline: string; subheading: string; accentColor: string; buttonLabel: string };
};
export const config: Config<Props> = {
components: {
Hero: {
fields: {
title: { type: "text" },
subtitle: { type: "text" },
align: {
type: "radio",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
backgroundColor: { type: "text" }, // Puck doesn't have color picker by default? Or use "custom"?
textColor: { type: "text" },
},
defaultProps: {
title: "Welcome to our site",
subtitle: "We provide great services",
align: "center",
backgroundColor: "#ffffff",
textColor: "#000000",
},
render: ({ title, subtitle, align, backgroundColor, textColor }) => (
<div style={{ backgroundColor, color: textColor, padding: "4rem 2rem", textAlign: align }}>
<h1 style={{ fontSize: "3rem", fontWeight: "bold", marginBottom: "1rem" }}>{title}</h1>
<p style={{ fontSize: "1.5rem" }}>{subtitle}</p>
</div>
),
},
TextSection: {
fields: {
heading: { type: "text" },
body: { type: "textarea" },
backgroundColor: { type: "text" },
},
defaultProps: {
heading: "About Us",
body: "Enter your text here...",
backgroundColor: "#f9fafb",
},
render: ({ heading, body, backgroundColor }) => (
<div style={{ backgroundColor, padding: "3rem 2rem" }}>
<div style={{ maxWidth: "800px", margin: "0 auto" }}>
<h2 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{heading}</h2>
<div style={{ whiteSpace: "pre-wrap" }}>{body}</div>
</div>
</div>
),
},
Booking: {
fields: {
headline: { type: "text" },
subheading: { type: "text" },
accentColor: { type: "text" },
buttonLabel: { type: "text" },
},
defaultProps: {
headline: "Book an Appointment",
subheading: "Select a service below",
accentColor: "#2563eb",
buttonLabel: "Book Now",
},
render: ({ headline, subheading, accentColor, buttonLabel }) => (
<div style={{ padding: "3rem 2rem", textAlign: "center" }}>
<BookingWidget
headline={headline}
subheading={subheading}
accentColor={accentColor}
buttonLabel={buttonLabel}
/>
</div>
),
},
},
};