Implement Site Builder with Puck and Booking Widget
This commit is contained in:
201
frontend/package-lock.json
generated
201
frontend/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
66
frontend/src/components/booking/BookingWidget.tsx
Normal file
66
frontend/src/components/booking/BookingWidget.tsx
Normal 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;
|
||||
33
frontend/src/hooks/useBooking.ts
Normal file
33
frontend/src/hooks/useBooking.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
58
frontend/src/hooks/useSites.ts
Normal file
58
frontend/src/hooks/useSites.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
60
frontend/src/pages/PageEditor.tsx
Normal file
60
frontend/src/pages/PageEditor.tsx
Normal 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;
|
||||
25
frontend/src/pages/PublicPage.tsx
Normal file
25
frontend/src/pages/PublicPage.tsx
Normal 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;
|
||||
87
frontend/src/puckConfig.tsx
Normal file
87
frontend/src/puckConfig.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
17
smoothschedule/smoothschedule/platform/tenant_sites/urls.py
Normal file
17
smoothschedule/smoothschedule/platform/tenant_sites/urls.py
Normal 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'),
|
||||
]
|
||||
121
smoothschedule/smoothschedule/platform/tenant_sites/views.py
Normal file
121
smoothschedule/smoothschedule/platform/tenant_sites/views.py
Normal 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"})
|
||||
@@ -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=[],
|
||||
)
|
||||
]
|
||||
Reference in New Issue
Block a user