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>
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user