From 76c0d71aa0b1242bb94efcf49fc13c6439839244 Mon Sep 17 00:00:00 2001 From: poduck Date: Wed, 10 Dec 2025 23:54:10 -0500 Subject: [PATCH] Implement Site Builder with Puck and Booking Widget --- frontend/package-lock.json | 201 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.tsx | 14 +- .../src/components/booking/BookingWidget.tsx | 66 ++++++ frontend/src/hooks/useBooking.ts | 33 +++ frontend/src/hooks/useSites.ts | 58 +++++ frontend/src/pages/PageEditor.tsx | 60 ++++++ frontend/src/pages/PublicPage.tsx | 25 +++ frontend/src/puckConfig.tsx | 87 ++++++++ .../config/settings/multitenancy.py | 1 + smoothschedule/config/urls.py | 2 + ...omains_plan_max_custom_domains_and_more.py | 28 +++ .../smoothschedule/commerce/billing/models.py | 4 + .../platform/tenant_sites/__init__.py | 0 .../platform/tenant_sites/apps.py | 6 + .../tenant_sites/migrations/0001_initial.py | 64 ++++++ .../tenant_sites/migrations/__init__.py | 0 .../platform/tenant_sites/models.py | 54 +++++ .../platform/tenant_sites/serializers.py | 45 ++++ .../platform/tenant_sites/tests/__init__.py | 0 .../platform/tenant_sites/tests/test_api.py | 93 ++++++++ .../tenant_sites/tests/test_models.py | 87 ++++++++ .../platform/tenant_sites/urls.py | 17 ++ .../platform/tenant_sites/views.py | 121 +++++++++++ ...it_amount_remove_service_price_and_more.py | 37 ++++ 25 files changed, 1103 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/booking/BookingWidget.tsx create mode 100644 frontend/src/hooks/useBooking.ts create mode 100644 frontend/src/hooks/useSites.ts create mode 100644 frontend/src/pages/PageEditor.tsx create mode 100644 frontend/src/pages/PublicPage.tsx create mode 100644 frontend/src/puckConfig.tsx create mode 100644 smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/__init__.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/apps.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/migrations/0001_initial.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/migrations/__init__.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/models.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/serializers.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/tests/__init__.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/tests/test_api.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/tests/test_models.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/urls.py create mode 100644 smoothschedule/smoothschedule/platform/tenant_sites/views.py create mode 100755 smoothschedule/smoothschedule/scheduling/schedule/migrations/0033_remove_service_deposit_amount_remove_service_price_and_more.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8986f2..21c3621 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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 + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 170d68d..ebe71cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba60f7c..4eda744 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( }> - } /> + } /> } /> } /> } /> @@ -869,6 +871,16 @@ const AppContent: React.FC = () => { ) } /> + + ) : ( + + ) + } + /> {/* Settings Routes with Nested Layout */} {hasAccess(['owner']) ? ( }> diff --git a/frontend/src/components/booking/BookingWidget.tsx b/frontend/src/components/booking/BookingWidget.tsx new file mode 100644 index 0000000..22df305 --- /dev/null +++ b/frontend/src/components/booking/BookingWidget.tsx @@ -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 = ({ + headline = "Book Appointment", + subheading = "Select a service", + accentColor = "#2563eb", + buttonLabel = "Book Now" +}) => { + const { data: services, isLoading } = usePublicServices(); + const createBooking = useCreateBooking(); + const [selectedService, setSelectedService] = useState(null); + + if (isLoading) return
; + + 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 ( +
+

{headline}

+

{subheading}

+ +
+ {services?.length === 0 &&

No services available.

} + {services?.map((service: any) => ( +
setSelectedService(service)} + > +

{service.name}

+

{service.duration} min - ${(service.price_cents / 100).toFixed(2)}

+
+ ))} +
+ + +
+ ); +}; + +export default BookingWidget; diff --git a/frontend/src/hooks/useBooking.ts b/frontend/src/hooks/useBooking.ts new file mode 100644 index 0000000..6a6fe1b --- /dev/null +++ b/frontend/src/hooks/useBooking.ts @@ -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; + }, + }); +}; diff --git a/frontend/src/hooks/useSites.ts b/frontend/src/hooks/useSites.ts new file mode 100644 index 0000000..5e66a9d --- /dev/null +++ b/frontend/src/hooks/useSites.ts @@ -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, + }); +}; diff --git a/frontend/src/pages/PageEditor.tsx b/frontend/src/pages/PageEditor.tsx new file mode 100644 index 0000000..4892be0 --- /dev/null +++ b/frontend/src/pages/PageEditor.tsx @@ -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(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
; + } + + if (!homePage) { + return
No page found. Please contact support.
; + } + + if (!data) return null; + + return ( +
+ +
+ ); +}; + +export default PageEditor; diff --git a/frontend/src/pages/PublicPage.tsx b/frontend/src/pages/PublicPage.tsx new file mode 100644 index 0000000..47127c8 --- /dev/null +++ b/frontend/src/pages/PublicPage.tsx @@ -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
; + } + + if (error || !data) { + return
Page not found or site disabled.
; + } + + return ( +
+ +
+ ); +}; + +export default PublicPage; diff --git a/frontend/src/puckConfig.tsx b/frontend/src/puckConfig.tsx new file mode 100644 index 0000000..9a53d1d --- /dev/null +++ b/frontend/src/puckConfig.tsx @@ -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 = { + 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 }) => ( +
+

{title}

+

{subtitle}

+
+ ), + }, + 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 }) => ( +
+
+

{heading}

+
{body}
+
+
+ ), + }, + 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 }) => ( +
+ +
+ ), + }, + }, +}; diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index c1656ff..aa88aa0 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -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', diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index 8422b33..757936a 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -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 diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py b/smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py new file mode 100644 index 0000000..11fcca2 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py @@ -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), + ), + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/models.py b/smoothschedule/smoothschedule/commerce/billing/models.py index c405e02..d7e53c0 100644 --- a/smoothschedule/smoothschedule/commerce/billing/models.py +++ b/smoothschedule/smoothschedule/commerce/billing/models.py @@ -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"] diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/__init__.py b/smoothschedule/smoothschedule/platform/tenant_sites/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/apps.py b/smoothschedule/smoothschedule/platform/tenant_sites/apps.py new file mode 100644 index 0000000..f5470a4 --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/apps.py @@ -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" \ No newline at end of file diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/migrations/0001_initial.py b/smoothschedule/smoothschedule/platform/tenant_sites/migrations/0001_initial.py new file mode 100644 index 0000000..ad3524c --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/migrations/__init__.py b/smoothschedule/smoothschedule/platform/tenant_sites/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/models.py b/smoothschedule/smoothschedule/platform/tenant_sites/models.py new file mode 100644 index 0000000..b1857ce --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/models.py @@ -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 diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/serializers.py b/smoothschedule/smoothschedule/platform/tenant_sites/serializers.py new file mode 100644 index 0000000..8c80dda --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/serializers.py @@ -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'] \ No newline at end of file diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/tests/__init__.py b/smoothschedule/smoothschedule/platform/tenant_sites/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_api.py b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_api.py new file mode 100644 index 0000000..4a43778 --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_api.py @@ -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") diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_models.py b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_models.py new file mode 100644 index 0000000..28d00b9 --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_models.py @@ -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) diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/urls.py b/smoothschedule/smoothschedule/platform/tenant_sites/urls.py new file mode 100644 index 0000000..e0d8c01 --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/urls.py @@ -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'), +] \ No newline at end of file diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/views.py b/smoothschedule/smoothschedule/platform/tenant_sites/views.py new file mode 100644 index 0000000..c6bd818 --- /dev/null +++ b/smoothschedule/smoothschedule/platform/tenant_sites/views.py @@ -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"}) \ No newline at end of file diff --git a/smoothschedule/smoothschedule/scheduling/schedule/migrations/0033_remove_service_deposit_amount_remove_service_price_and_more.py b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0033_remove_service_deposit_amount_remove_service_price_and_more.py new file mode 100755 index 0000000..b5fcd27 --- /dev/null +++ b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0033_remove_service_deposit_amount_remove_service_price_and_more.py @@ -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=[], + ) + ] \ No newline at end of file