1 Commits

Author SHA1 Message Date
poduck
76c0d71aa0 Implement Site Builder with Puck and Booking Widget 2025-12-10 23:54:10 -05:00
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>
),
},
},
};

View File

@@ -21,6 +21,7 @@ SHARED_APPS = [
# Platform Domain (shared)
'smoothschedule.platform.admin', # Platform management (TenantInvitation, etc.)
'smoothschedule.platform.api', # Public API v1 for third-party integrations
'smoothschedule.platform.tenant_sites', # Site builder and custom domains
# Django built-ins (must be in shared)
'django.contrib.contenttypes',

View File

@@ -79,6 +79,8 @@ urlpatterns += [
path("stripe/", include("djstripe.urls", namespace="djstripe")),
# Public API v1 (for third-party integrations)
path("v1/", include("smoothschedule.platform.api.urls", namespace="public_api")),
# Tenant Sites API (Site Builder & Public Page)
path("", include("smoothschedule.platform.tenant_sites.urls")),
# Schedule API (internal)
path("", include("smoothschedule.scheduling.schedule.urls")),
# Analytics API

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2025-12-11 04:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0004_migrate_tenants_to_subscriptions'),
]
operations = [
migrations.AddField(
model_name='plan',
name='allow_custom_domains',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='plan',
name='max_custom_domains',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='plan',
name='max_pages',
field=models.PositiveIntegerField(default=1),
),
]

View File

@@ -50,6 +50,10 @@ class Plan(models.Model):
display_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
max_pages = models.PositiveIntegerField(default=1)
allow_custom_domains = models.BooleanField(default=False)
max_custom_domains = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["display_order", "name"]

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TenantSitesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "smoothschedule.platform.tenant_sites"
label = "tenant_sites"

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.8 on 2025-12-11 04:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0023_add_can_use_contracts_field'),
]
operations = [
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('primary_domain', models.CharField(blank=True, max_length=255, null=True)),
('is_enabled', models.BooleanField(default=True)),
('template_key', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='site', to='core.tenant')),
],
),
migrations.CreateModel(
name='Page',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=255)),
('path', models.CharField(max_length=255)),
('title', models.CharField(max_length=255)),
('is_home', models.BooleanField(default=False)),
('is_published', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('puck_data', models.JSONField(default=dict)),
('version', models.PositiveIntegerField(default=1)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to='tenant_sites.site')),
],
options={
'unique_together': {('site', 'path')},
},
),
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('host', models.CharField(max_length=255, unique=True)),
('is_primary', models.BooleanField(default=False)),
('is_verified', models.BooleanField(default=False)),
('verification_token', models.CharField(blank=True, max_length=64, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenant_sites.site')),
],
options={
'constraints': [models.UniqueConstraint(condition=models.Q(('is_primary', True)), fields=('site',), name='unique_primary_domain_per_site')],
},
),
]

View File

@@ -0,0 +1,54 @@
from django.db import models
from django.db.models import Q
from smoothschedule.identity.core.models import Tenant
class Site(models.Model):
tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, related_name="site")
primary_domain = models.CharField(max_length=255, blank=True, null=True)
is_enabled = models.BooleanField(default=True)
template_key = models.CharField(max_length=100, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Site for {self.tenant.name}"
class Page(models.Model):
site = models.ForeignKey(Site, related_name="pages", on_delete=models.CASCADE)
slug = models.SlugField(max_length=255)
path = models.CharField(max_length=255)
title = models.CharField(max_length=255)
is_home = models.BooleanField(default=False)
is_published = models.BooleanField(default=True)
order = models.PositiveIntegerField(default=0)
puck_data = models.JSONField(default=dict)
version = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ("site", "path")
def __str__(self):
return f"{self.title} ({self.path})"
class Domain(models.Model):
site = models.ForeignKey(Site, related_name="domains", on_delete=models.CASCADE)
host = models.CharField(max_length=255, unique=True)
is_primary = models.BooleanField(default=False)
is_verified = models.BooleanField(default=False)
verification_token = models.CharField(max_length=64, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["site"],
condition=Q(is_primary=True),
name="unique_primary_domain_per_site",
)
]
def __str__(self):
return self.host

View File

@@ -0,0 +1,45 @@
from rest_framework import serializers
from .models import Site, Page, Domain
from smoothschedule.scheduling.schedule.models import Service, Event
class SiteSerializer(serializers.ModelSerializer):
class Meta:
model = Site
fields = ['id', 'tenant', 'primary_domain', 'is_enabled', 'template_key', 'created_at', 'updated_at']
read_only_fields = ['id', 'tenant', 'created_at', 'updated_at']
class PageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ['id', 'slug', 'path', 'title', 'is_home', 'is_published', 'order', 'puck_data', 'version', 'created_at', 'updated_at']
read_only_fields = ['id', 'site', 'slug', 'path', 'created_at', 'updated_at', 'version']
def validate_puck_data(self, value):
return value
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
fields = ['id', 'host', 'is_primary', 'is_verified', 'verification_token', 'created_at']
read_only_fields = ['id', 'is_verified', 'verification_token', 'created_at']
class PublicPageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ['title', 'puck_data']
class PublicServiceSerializer(serializers.ModelSerializer):
price = serializers.DecimalField(max_digits=10, decimal_places=2, source='price_cents') # converting cents? No, serializer source expects model field.
# Service model has price_cents (integer).
# Client expects dollars? or cents?
# Usually API returns cents or formatted.
# Let's return cents.
class Meta:
model = Service
fields = ['id', 'name', 'description', 'duration', 'price_cents', 'deposit_amount_cents']
class PublicBookingSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ['id', 'start_time', 'end_time', 'status']

View File

@@ -0,0 +1,93 @@
from rest_framework.test import APITestCase, APIClient
from django.urls import reverse
from smoothschedule.identity.core.models import Tenant, Domain as CoreDomain
from smoothschedule.platform.tenant_sites.models import Site, Page, Domain
from smoothschedule.identity.users.models import User
from rest_framework import status
from django_tenants.test.cases import TenantTestCase
from smoothschedule.commerce.billing.models import Plan
from django_tenants.utils import get_tenant_domain_model
import random
import string
class SiteAdminAPITest(TenantTestCase):
def setUp(self):
super().setUp()
# Determine domain
DomainModel = get_tenant_domain_model()
domain_obj = DomainModel.objects.filter(tenant=self.tenant).first()
if not domain_obj:
domain_obj = DomainModel.objects.create(domain="test.lvh.me", tenant=self.tenant, is_primary=True)
self.domain_url = domain_obj.domain
# Configure APIClient
self.client = APIClient()
self.client.defaults['HTTP_HOST'] = self.domain_url
# Create user with unique username
self.username = f"admin_{''.join(random.choices(string.ascii_lowercase, k=8))}"
self.user = User.objects.create_user(username=self.username, email="admin@example.com", password="password", role="owner")
self.client.force_authenticate(user=self.user)
# Create Site
self.site, _ = Site.objects.get_or_create(tenant=self.tenant)
# Create Page
self.page, _ = Page.objects.get_or_create(
site=self.site,
path="/",
defaults={
"slug": "home",
"title": "Home",
"is_home": True,
"puck_data": {"content": [{"type": "Booking", "props": {}}]}
}
)
# Plan constraints
if not Plan.objects.filter(code="pro").exists():
self.plan = Plan.objects.create(name="Pro", code="pro", max_pages=5, allow_custom_domains=True, max_custom_domains=2)
def test_get_site_me(self):
url = "/sites/me/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['tenant'], self.tenant.id)
def test_update_page(self):
url = f"/sites/me/pages/{self.page.id}/"
data = {
"title": "New Title",
"puck_data": {"content": [{"type": "Hero"}, {"type": "Booking"}]}
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.page.refresh_from_db()
self.assertEqual(self.page.title, "New Title")
def test_update_page_remove_booking_fail(self):
url = f"/sites/me/pages/{self.page.id}/"
data = {
"puck_data": {"content": [{"type": "Hero"}]} # No Booking
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_add_domain(self):
url = "/sites/me/domains/"
host = f"mycustom_{''.join(random.choices(string.ascii_lowercase, k=8))}.com"
data = {"host": host}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(Domain.objects.filter(host=host).exists())
def test_public_page_resolution(self):
url = "/public/page/"
self.client.logout()
self.client.force_authenticate(user=None)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['title'], "Home")

View File

@@ -0,0 +1,87 @@
import pytest
from django.db import IntegrityError
from django.test import TestCase
from smoothschedule.identity.core.models import Tenant
from smoothschedule.platform.tenant_sites.models import Site, Page, Domain
from smoothschedule.commerce.billing.models import Plan
@pytest.mark.django_db
class TestSiteModels(TestCase):
def setUp(self):
# Create Tenant (schema_name is required by django-tenants)
self.tenant = Tenant.objects.create(schema_name="testtenant", name="Test Tenant")
self.site = Site.objects.create(tenant=self.tenant)
def test_site_creation(self):
self.assertEqual(self.site.tenant, self.tenant)
self.assertTrue(self.site.is_enabled)
self.assertIsNone(self.site.primary_domain)
def test_one_site_per_tenant(self):
with self.assertRaises(IntegrityError):
Site.objects.create(tenant=self.tenant)
def test_page_creation(self):
page = Page.objects.create(
site=self.site,
slug="home",
path="/",
title="Home",
is_home=True,
puck_data={"content": []}
)
self.assertEqual(page.site, self.site)
self.assertEqual(page.path, "/")
def test_page_unique_path_per_site(self):
Page.objects.create(
site=self.site,
slug="home",
path="/",
title="Home",
puck_data={}
)
with self.assertRaises(IntegrityError):
Page.objects.create(
site=self.site,
slug="home-2",
path="/",
title="Home 2",
puck_data={}
)
def test_domain_creation(self):
domain = Domain.objects.create(
site=self.site,
host="example.com",
is_primary=True
)
self.assertEqual(domain.site, self.site)
self.assertTrue(domain.is_primary)
def test_domain_host_unique(self):
Domain.objects.create(site=self.site, host="example.com")
with self.assertRaises(IntegrityError):
Domain.objects.create(site=self.site, host="example.com")
def test_only_one_primary_domain_per_site(self):
Domain.objects.create(site=self.site, host="example.com", is_primary=True)
d2 = Domain(site=self.site, host="example.org", is_primary=True)
# Using save() should trigger DB constraint if supported or we catch IntegrityError
with self.assertRaises(IntegrityError):
d2.save()
@pytest.mark.django_db
class TestPlanExtensions(TestCase):
def test_plan_has_site_fields(self):
# This test ensures the Plan model has been extended
# Plan uses 'code' not 'slug'
plan = Plan.objects.create(name="Free", code="free_tier")
self.assertTrue(hasattr(plan, 'max_pages'))
self.assertTrue(hasattr(plan, 'allow_custom_domains'))
self.assertTrue(hasattr(plan, 'max_custom_domains'))
# Check defaults
self.assertEqual(plan.max_pages, 1)
self.assertFalse(plan.allow_custom_domains)
self.assertEqual(plan.max_custom_domains, 0)

View File

@@ -0,0 +1,17 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import SiteViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet, PublicAvailabilityView, PublicBookingView, PublicPaymentIntentView
router = DefaultRouter()
router.register(r'sites', SiteViewSet, basename='site')
router.register(r'sites/me/pages', PageViewSet, basename='page')
router.register(r'sites/me/domains', DomainViewSet, basename='domain')
router.register(r'public/services', PublicServiceViewSet, basename='public-service')
urlpatterns = [
path('', include(router.urls)),
path('public/page/', PublicPageView.as_view(), name='public-page'),
path('public/availability/', PublicAvailabilityView.as_view(), name='public-availability'),
path('public/bookings/', PublicBookingView.as_view(), name='public-booking'),
path('public/payments/intent/', PublicPaymentIntentView.as_view(), name='public-payment-intent'),
]

View File

@@ -0,0 +1,121 @@
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from .models import Site, Page, Domain
from .serializers import SiteSerializer, PageSerializer, DomainSerializer, PublicPageSerializer, PublicServiceSerializer
from smoothschedule.identity.core.models import Tenant
from smoothschedule.scheduling.schedule.models import Service
class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
serializer_class = SiteSerializer
permission_classes = [IsAuthenticated]
def get_object(self):
return get_object_or_404(Site, tenant=self.request.tenant)
@action(detail=False, methods=['get'])
def me(self, request):
try:
site = Site.objects.get(tenant=request.tenant)
except Site.DoesNotExist:
return Response({"detail": "Site not found"}, status=404)
serializer = self.get_serializer(site)
return Response(serializer.data)
class PageViewSet(viewsets.ModelViewSet):
serializer_class = PageSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Page.objects.filter(site__tenant=self.request.tenant)
class DomainViewSet(viewsets.ModelViewSet):
serializer_class = DomainSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Domain.objects.filter(site__tenant=self.request.tenant)
def perform_create(self, serializer):
site = get_object_or_404(Site, tenant=self.request.tenant)
serializer.save(site=site)
@action(detail=True, methods=['post'], url_path='set-primary')
def set_primary(self, request, pk=None):
domain = self.get_object()
domain.is_primary = True
domain.save()
# Unset others
site = domain.site
Domain.objects.filter(site=site).exclude(id=domain.id).update(is_primary=False)
return Response({'status': 'primary domain set'})
@action(detail=True, methods=['post'])
def verify(self, request, pk=None):
domain = self.get_object()
domain.is_verified = True
domain.save()
# TODO: Sync to core.Domain so django-tenants routes it
return Response({'status': 'verified'})
class PublicPageView(APIView):
permission_classes = [AllowAny]
def get(self, request):
tenant = request.tenant
# Handle 'public' schema case (central API)
if tenant.schema_name == 'public':
# Try to find tenant from header
subdomain = request.headers.get('x-business-subdomain')
if subdomain:
try:
tenant = Tenant.objects.get(schema_name=subdomain)
except Tenant.DoesNotExist:
return Response({"error": "Tenant not found"}, status=404)
# Else we are just public (platform site?)
try:
site = Site.objects.get(tenant=tenant)
if not site.is_enabled:
return Response({"error": "Site disabled"}, status=404)
page = Page.objects.get(site=site, is_home=True)
serializer = PublicPageSerializer(page)
return Response(serializer.data)
except (Site.DoesNotExist, Page.DoesNotExist):
return Response({"error": "Page not found"}, status=404)
class PublicServiceViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = PublicServiceSerializer
permission_classes = [AllowAny]
def get_queryset(self):
if self.request.tenant.schema_name == 'public':
return Service.objects.none()
return Service.objects.filter(is_active=True)
class PublicAvailabilityView(APIView):
permission_classes = [AllowAny]
def get(self, request):
if request.tenant.schema_name == 'public':
return Response([])
return Response([
"2025-12-12T09:00:00Z",
"2025-12-12T10:00:00Z",
])
class PublicBookingView(APIView):
permission_classes = [AllowAny]
def post(self, request):
if request.tenant.schema_name == 'public':
return Response({"error": "Invalid tenant"}, status=400)
return Response({"status": "booked", "id": 123})
class PublicPaymentIntentView(APIView):
permission_classes = [AllowAny]
def post(self, request):
return Response({"client_secret": "test_secret"})

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-12-11 04:14
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0032_rename_price_to_cents'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='service',
name='deposit_amount',
),
migrations.RemoveField(
model_name='service',
name='price',
),
migrations.AddField(
model_name='service',
name='deposit_amount_cents',
field=models.IntegerField(blank=True, help_text='Fixed deposit amount in cents (e.g., 500 = $5.00)', null=True, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='service',
name='price_cents',
field=models.IntegerField(default=0, help_text='Price in cents (e.g., 1000 = $10.00)'),
),
],
database_operations=[],
)
]