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

@@ -10,6 +10,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
@@ -579,6 +580,17 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/abstract": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.1.21.tgz",
"integrity": "sha512-6sJut6/D21xPIK8EFMu+JJeF+fBCOmQKN1BRpeUYFi5m9P1CJpTYbBwfI107h7PHObI6a5bsckiKkRpF2orHpw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/geometry": "^0.1.21",
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -591,6 +603,17 @@
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/collision": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.1.21.tgz",
"integrity": "sha512-9AJ4NbuwGDexxMCZXZyKdNQhbAe93p6C6IezQaDaWmdCqZHMHmC3+ul7pGefBQfOooSarGwIf8Bn182o9SMa1A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.21",
"@dnd-kit/geometry": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
@@ -606,6 +629,65 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/dom": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.1.21.tgz",
"integrity": "sha512-6UDc1y2Y3oLQKArGlgCrZxz5pdEjRSiQujXOn5JdbuWvKqTdUR5RTYDeicr+y2sVm3liXjTqs3WlUoV+eqhqUQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.21",
"@dnd-kit/collision": "^0.1.21",
"@dnd-kit/geometry": "^0.1.21",
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/geometry": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.1.21.tgz",
"integrity": "sha512-Tir97wNJbopN2HgkD7AjAcoB3vvrVuUHvwdPALmNDUH0fWR637c4MKQ66YjjZAbUEAR8KL6mlDiHH4MzTLd7CQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/helpers": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@dnd-kit/helpers/-/helpers-0.1.18.tgz",
"integrity": "sha512-k4hVXIb8ysPt+J0KOxbBTc6rG0JSlsrNevI/fCHLbyXvEyj1imxl7yOaAQX13cAZnte88db6JvbgsSWlVjtxbw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.18",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/react": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.1.18.tgz",
"integrity": "sha512-OCeCO9WbKnN4rVlEOEe9QWxSIFzP0m/fBFmVYfu2pDSb4pemRkfrvCsI/FH3jonuESYS8qYnN9vc8Vp3EiCWCA==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.18",
"@dnd-kit/dom": "^0.1.18",
"@dnd-kit/state": "^0.1.18",
"tslib": "^2.6.2"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@dnd-kit/state": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.1.21.tgz",
"integrity": "sha512-pdhntEPvn/QttcF295bOJpWiLsRqA/Iczh1ODOJUxGiR+E4GkYVz9VapNNm9gDq6ST0tr/e1Q2xBztUHlJqQgA==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.10.0",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
@@ -1320,6 +1402,27 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@measured/puck": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@measured/puck/-/puck-0.20.2.tgz",
"integrity": "sha512-/GuzlsGs1T2S3lY9so4GyHpDBlWnC1h/4rkYuelrLNHvacnXBZyn50hvgRhWAqlLn/xOuJvJeuY740Zemxdt3Q==",
"license": "MIT",
"dependencies": {
"@dnd-kit/helpers": "0.1.18",
"@dnd-kit/react": "0.1.18",
"deep-diff": "^1.0.2",
"fast-deep-equal": "^3.1.3",
"flat": "^5.0.2",
"object-hash": "^3.0.0",
"react-hotkeys-hook": "^4.6.1",
"use-debounce": "^9.0.4",
"uuid": "^9.0.1",
"zustand": "^5.0.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
@@ -1336,6 +1439,16 @@
"node": ">=18"
}
},
"node_modules/@preact/signals-core": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz",
"integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@react-google-maps/api": {
"version": "2.20.7",
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz",
@@ -3258,6 +3371,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3771,6 +3890,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"license": "BSD-3-Clause",
"bin": {
"flat": "cli.js"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -5175,6 +5303,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5561,6 +5698,16 @@
"react-dom": ">=16"
}
},
"node_modules/react-hotkeys-hook": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.2.tgz",
"integrity": "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.1",
"react-dom": ">=16.8.1"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
@@ -6211,6 +6358,18 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-debounce": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz",
"integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -6220,6 +6379,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
@@ -6592,6 +6764,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -6,6 +6,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",

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