Payload Logo
CX,  Service design,  Components

Creating a listing for a relational database

Date Published

Estimate

⚠️ Important: These endpoints require authentication via Payload CMS authentication headers. External services would need: Read Case study to see example

What makes Plek unique is the Package Types. The package types are found in the library since we will use them repeatedly namely creating a Plek, creating and estimate using these package types, and finally creating a booking, and reflecting the user selection

1export interface PackageType {
2 id: string
3 name: string
4 description: string
5 multiplier: number
6 features: string[]
7 revenueCatId: string
8 minNights?: number
9 maxNights?: number
10 isHosted?: boolean
11 category: 'standard' | 'luxury' | 'hosted' | 'specialty'
12}
13
14export interface PackageTypeTemplate {
15 name: string
16 description: string
17 multiplier: number
18 features: string[]
19 revenueCatId: string
20 minNights?: number
21 maxNights?: number
22 isHosted?: boolean
23 category: 'standard' | 'luxury' | 'hosted' | 'specialty'
24}
25
26// Centralized package types definition based on Plek's core offerings
27export const PACKAGE_TYPES: Record<string, PackageTypeTemplate> = {
28 // Standard Packages
29 per_night: {
30 name: "Per Night",
31 description: "Standard nightly rate for photo studio rental",
32 multiplier: 1.0,
33 features: [
34 "Photo studio access",
35 "Basic lighting equipment",
36 "Self-service setup",
37 "Standard accommodation"
38 ],
39 revenueCatId: "per_night",
40 minNights: 1,
41 maxNights: 1,
42 category: 'standard'
43 },
44
45 // Luxury Packages
46 luxury_night: {
47 name: "Luxury Night",
48 description: "Premium nightly rate with wine sommelier service",
49 multiplier: 1.5,
50 features: [
51 "Premium photo studio access",
52 "Professional lighting setup",
53 "Wine sommelier consultation",
54 "Curated wine selection",
55 "Premium accommodation",
56 "Priority service"
57 ],
58 revenueCatId: "luxury_night",
59 minNights: 1,
60 maxNights: 1,
61 isHosted: true,
62 category: 'luxury'
63 },
64
65 // Multi-night Packages
66 three_nights: {
67 name: "3 Nights Package",
68 description: "Three night stay with studio access",
69 multiplier: 0.95,
70 features: [
71 "3 nights accommodation",
72 "Photo studio access",
73 "Basic equipment included",
74 "5% discount on total",
75 "Flexible scheduling"
76 ],
77 revenueCatId: "3nights",
78 minNights: 3,
79 maxNights: 3,
80 category: 'standard'
81 },
82
83 hosted_3nights: {
84 name: "Hosted 3 Nights",
85 description: "Premium 3-night experience with wine sommelier",
86 multiplier: 1.4,
87 features: [
88 "3 nights premium accommodation",
89 "Professional photo studio setup",
90 "Wine sommelier service",
91 "Daily wine tastings",
92 "Dedicated host assistance",
93 "Enhanced amenities",
94 "Priority service"
95 ],
96 revenueCatId: "hosted3nights",
97 minNights: 3,
98 maxNights: 3,
99 isHosted: true,
100 category: 'hosted'
101 },
102
103 // Weekly Packages
104 weekly: {
105 name: "Weekly Package",
106 description: "Seven night stay with extended studio access",
107 multiplier: 0.85,
108 features: [
109 "7 nights accommodation",
110 "Extended photo studio access",
111 "Equipment storage included",
112 "15% discount on total",
113 "Flexible project scheduling",
114 "Priority booking for future stays"
115 ],
116 revenueCatId: "weekly",
117 minNights: 7,
118 maxNights: 7,
119 category: 'standard'
120 },
121
122 hosted_weekly: {
123 name: "Hosted Weekly",
124 description: "Premium week-long experience with dedicated support",
125 multiplier: 1.3,
126 features: [
127 "7 nights premium accommodation",
128 "Professional studio management",
129 "Wine sommelier service",
130 "Weekly wine experience",
131 "Dedicated host support",
132 "Enhanced amenities",
133 "Priority service",
134 "Custom project planning"
135 ],
136 revenueCatId: "hosted_weekly",
137 minNights: 7,
138 maxNights: 7,
139 isHosted: true,
140 category: 'hosted'
141 },
142
143 // Extended Stay Packages
144 monthly: {
145 name: "Monthly Package",
146 description: "Extended month-long stay with winter benefits",
147 multiplier: 0.7,
148 features: [
149 "30+ nights accommodation",
150 "Unlimited studio access",
151 "Equipment storage",
152 "30% discount on total",
153 "Winter heating included",
154 "Extended stay perks",
155 "Priority booking",
156 "Flexible cancellation"
157 ],
158 revenueCatId: "monthly",
159 minNights: 30,
160 maxNights: 90,
161 category: 'standard'
162 },
163
164 // Specialty Packages
165 wine_package: {
166 name: "Wine Sommelier Package",
167 description: "Specialized wine experience add-on for any stay",
168 multiplier: 1.5,
169 features: [
170 "Professional wine sommelier",
171 "Curated wine selection",
172 "Daily wine tastings",
173 "Wine pairing consultation",
174 "Premium glassware provided",
175 "Wine education sessions"
176 ],
177 revenueCatId: "wine_sommelier",
178 category: 'specialty'
179 }
180} as const
181
182// Helper functions
183export const getPackageById = (id: string): PackageTypeTemplate | null => {
184 return PACKAGE_TYPES[id] || null
185}
186
187export const getPackagesByCategory = (category: PackageType['category']): Record<string, PackageTypeTemplate> => {
188 return Object.fromEntries(
189 Object.entries(PACKAGE_TYPES).filter(([_, pkg]) => pkg.category === category)
190 )
191}
192
193export const getPackageByDuration = (nights: number): string | null => {
194 // Logic to determine appropriate package based on duration
195 if (nights === 1) return 'per_night'
196 if (nights === 3) return 'three_nights'
197 if (nights === 7) return 'weekly'
198 if (nights >= 30) return 'monthly'
199 return null
200}
201
202export const formatPackageFeatures = (features: string[]): string => {
203 return features.join(', ')
204}
205
206// Package validation
207export const validatePackageType = (packageType: Partial<PackageType>): boolean => {
208 return !!(
209 packageType.name &&
210 packageType.description &&
211 typeof packageType.multiplier === 'number' &&
212 Array.isArray(packageType.features) &&
213 packageType.revenueCatId
214 )
215}
216
217// Convert template to full package type
218export const createPackageFromTemplate = (id: string, template: PackageTypeTemplate): PackageType => {
219 return {
220 id,
221 ...template
222 }
223}
224
225// Get all package types as full PackageType objects
226export const getAllPackageTypes = (): Record<string, PackageType> => {
227 return Object.fromEntries(
228 Object.entries(PACKAGE_TYPES).map(([id, template]) => [
229 id,
230 createPackageFromTemplate(id, template)
231 ])
232 )
233}


Extend Blog Post Library

Extending payloads existing blog function for 3rd parties we can use their posts API endpoints. Finding one of the few proven recyclable engineered UX pattern

Update: PATCH http://localhost:3000/api/posts/{id}
Delete: DELETE http://localhost:3000/api/posts/{id}

Two methods of adding a listing.
- As a customer
- As admin

Opening up an api route :

Replace localhost:3000 with your production domain:

https://yourdomain.com/api/posts/{id}


Consume this data and filter it on your own app/domain.

Examples:
-Filter a list of Pleks based on a date range / categories / package type

- Increase offering and use Pleks monthly payment facility

Note: External services will need to authenticate with your Payload CMS first to get valid credentials for these protected endpoints.

Expose the backend and api hook the existing post data, while using access control to consume the data on frontend

|_ 📁 app
| |_ 📁 api
| | |_ 📁 post
| | | |_ 📁 [id]
| | | || _ 📄route.ts
| | | |_ 📄 route.ts
|_ 📁 (frontend)
|_ 📁 (payload)
|_ 📄 paylaod types.ts
|_ 📄 payload.config.ts

1class PlekAPI {
2 constructor(baseUrl, apiKey) {
3 this.baseUrl = baseUrl;
4 this.apiKey = apiKey;
5 }
6
7 async updatePost(postId, postData) {
8 try {
9 const response = await fetch(`${this.baseUrl}/api/posts/${postId}`, {
10 method: 'PATCH',
11 headers: {
12 'Content-Type': 'application/json',
13 'Authorization': `Bearer ${this.apiKey}`,
14 // Or if using cookies:
15 // 'Cookie': 'payload-token=your_session_token'
16 },
17 body: JSON.stringify(postData)
18 });
19
20 if (!response.ok) {
21 throw new Error(`HTTP error! status: ${response.status}`);
22 }
23
24 return await response.json();
25 } catch (error) {
26 console.error('Failed to update post:', error);
27 throw error;
28 }
29 }
30
31 async deletePost(postId) {
32 try {
33 const response = await fetch(`${this.baseUrl}/api/posts/${postId}`, {
34 method: 'DELETE',
35 headers: {
36 'Authorization': `Bearer ${this.apiKey}`,
37 }
38 });
39
40 if (!response.ok) {
41 throw new Error(`HTTP error! status: ${response.status}`);
42 }
43
44 return await response.json();
45 } catch (error) {
46 console.error('Failed to delete post:', error);
47 throw error;
48 }
49 }
50}
51
52// Usage
53const api = new PlekAPI('https://yourdomain.com', 'your_api_key');
54
55// Update a post
56api.updatePost('post_123', {
57 title: 'Updated from external app',
58 content: 'New content here...',
59 _status: 'published'
60}).then(result => {
61 console.log('Post updated:', result);
62});
63
64// Delete a post
65api.deletePost('post_123').then(result => {
66 console.log('Post deleted:', result);
67});


Complete Embeddable Service Coverage:

✅ CREATE: Admin can create new pleks with package types

✅ READ: Public can browse and view plek listings & details

✅ UPDATE: Admin can update existing pleks via embed manager

✅ DELETE: Admin can delete pleks via embed manager


Admin Dashboard

Creating a listing required customer role, reavealing the conditional Manage button. Routining to the frontend admin Page dashboard the user has functionality enabling to Create, Read, Update, and Delete the listing in the MongoDatabse

|_ 📁 app
| |_ 📁 Plek
| | |_ 📁 adminPage
| | | |_ 📄 page.client.tsx
| | | |_ 📄 page.tsx
|_ 📁 collections
|_ 📁 fields
|_ 📄 paylaod types.ts
|_ 📄 payload.config.ts

1import { getMeUser } from "@/utilities/getMeUser"
2import { redirect } from "next/navigation"
3import { getPayload } from "payload"
4import config from "@payload-config"
5import PlekAdminClient from "./page.client"
6
7export default async function PlekAdminPage() {
8 let userAuth
9
10 try {
11 userAuth = await getMeUser()
12 } catch (error) {
13 redirect('/login?redirect=/plek/adminPage')
14 }
15
16 if (!userAuth?.user) {
17 redirect('/login?redirect=/plek/adminPage')
18 }
19
20 const user = userAuth.user
21
22 // Check if user has customer or admin role
23 const userRoles = user.role || []
24 if (!userRoles.includes('customer') && !userRoles.includes('admin')) {
25 redirect('/?error=unauthorized')
26 }
27
28 const payload = await getPayload({ config })
29
30 // Fetch user's posts
31 const userPosts = await payload.find({
32 collection: 'posts',
33 where: {
34 authors: {
35 contains: user.id,
36 },
37 },
38 depth: 2,
39 limit: 100,
40 sort: '-updatedAt',
41 })
42
43 // Fetch categories for the form
44 const categories = await payload.find({
45 collection: 'categories',
46 limit: 100,
47 sort: 'title',
48 })
49
50 return (
51 <div className="min-h-screen bg-background">
52 <PlekAdminClient
53 user={user}
54 initialPosts={userPosts.docs}
55 categories={categories.docs}
56 />
57 </div>
58 )
59}

plek/adminPage

1"use client"
2
3import React, { useState, useCallback, useMemo, useRef, useEffect } from "react"
4import { useRouter } from "next/navigation"
5import type { User, Post, Category } from "@/payload-types"
6import { Button } from "@/components/ui/button"
7import { Input } from "@/components/ui/input"
8import { Textarea } from "@/components/ui/textarea"
9import { Label } from "@/components/ui/label"
10import { Badge } from "@/components/ui/badge"
11import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
12import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
13import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
14import { Alert, AlertDescription } from "@/components/ui/alert"
15import { Switch } from "@/components/ui/switch"
16import { Checkbox } from "@/components/ui/checkbox"
17import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
18import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
19import { Loader2, Plus, Edit, Trash2, Eye, Calendar, BarChart3, Settings, Upload, X, Package, DollarSign, ExternalLink, Code, Copy, ChevronDown, Check, MoreVertical, Users } from "lucide-react"
20import { cn } from "@/lib/utils"
21import Link from "next/link"
22import Image from 'next/image'
23import DatePicker from 'react-datepicker'
24import "react-datepicker/dist/react-datepicker.css"
25import { toast } from 'sonner'
26import { PACKAGE_TYPES, getPackageById, getAllPackageTypes } from '@/lib/package-types'
27
28interface PlekAdminClientProps {
29 user: User
30 initialPosts: Post[]
31 categories: Category[]
32}
33
34interface PackageType {
35 name: string
36 description: string
37 price: number | ''
38 multiplier: number
39 features: string[]
40 revenueCatId: string
41}
42
43interface PostFormData {
44 title: string
45 content: string
46 categories: string[]
47 heroImage?: string
48 publishedAt?: string
49 _status: 'draft' | 'published'
50 packageTypes: PackageType[]
51 baseRate: number | ''
52 meta: {
53 title: string
54 description: string
55 image: string
56 }
57}
58
59interface UploadedFile {
60 id: string
61 url: string
62 filename: string
63}
64
65// Replace the existing packageTemplates with centralized types
66const packageTemplates = Object.values(getAllPackageTypes() || {}).reduce((acc: Record<string, any>, pkg) => {
67 acc[pkg.id] = {
68 name: pkg.name,
69 description: pkg.description,
70 multiplier: pkg.multiplier,
71 features: pkg.features,
72 revenueCatId: pkg.revenueCatId,
73 }
74 return acc
75}, {})
76
77const createPackageFromTemplate = (templateKey: string): PackageType => {
78 const packageTemplate = getPackageById(templateKey)
79 if (!packageTemplate) {
80 throw new Error(`Package template not found: ${templateKey}`)
81 }
82
83 return {
84 name: packageTemplate.name,
85 description: packageTemplate.description,
86 price: '',
87 multiplier: packageTemplate.multiplier,
88 features: packageTemplate.features,
89 revenueCatId: packageTemplate.revenueCatId,
90 }
91}
92
93interface PackageFormProps {
94 packageTypes: PackageType[]
95 onPackageChange: (idx: number, field: keyof PackageType, value: string | number | string[]) => void
96 onAddPackageType: () => void
97 onRemovePackageType: (idx: number) => void
98 onAddPackageTemplate: (templateKey: string) => void
99 isEditing?: boolean
100}
101
102export default function PlekAdminClient({ user, initialPosts, categories }: PlekAdminClientProps) {
103 const router = useRouter()
104 const [posts, setPosts] = useState<Post[]>(initialPosts)
105 const [loading, setLoading] = useState(false)
106 const [error, setError] = useState<string | null>(null)
107 const [success, setSuccess] = useState<string | null>(null)
108 const [totalVisitors, setTotalVisitors] = useState<number>(0)
109
110 // Form states
111 const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
112 const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
113 const [editingPost, setEditingPost] = useState<Post | null>(null)
114 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
115 const [deletingPost, setDeletingPost] = useState<Post | null>(null)
116 const [isViewerDialogOpen, setIsViewerDialogOpen] = useState(false)
117 const [copiedScript, setCopiedScript] = useState<string | null>(null)
118
119 // Form data with debounced updates
120 const [formData, setFormData] = useState<PostFormData>({
121 title: '',
122 content: '',
123 categories: [],
124 _status: 'draft',
125 packageTypes: [createPackageFromTemplate('per_night')],
126 baseRate: '',
127 meta: {
128 title: '',
129 description: '',
130 image: ''
131 }
132 })
133
134 // Image upload
135 const [uploadedImages, setUploadedImages] = useState<UploadedFile[]>([])
136 const [uploading, setUploading] = useState(false)
137
138 // Debounce timer ref to prevent excessive re-renders
139 const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
140
141 // Auto-generate SEO meta fields from form data
142 const autoInferSEOMeta = useCallback((formData: PostFormData) => {
143 const inferredMeta = {
144 title: formData.title ? `${formData.title} | Stay at our self built plek` : '',
145 description: formData.content
146 ? `${formData.content.slice(0, 155)}${formData.content.length > 155 ? '...' : ''}`
147 : '',
148 image: uploadedImages.length > 0 && uploadedImages[0] ? uploadedImages[0].id : formData.meta.image
149 }
150
151 return inferredMeta
152 }, [uploadedImages])
153
154 // Auto-update SEO meta when title, content, or images change
155 const updateSEOMetaAuto = useCallback((updates: Partial<PostFormData>) => {
156 const updatedFormData = { ...formData, ...updates }
157 const inferredMeta = autoInferSEOMeta(updatedFormData)
158
159 // Only auto-update if SEO fields are empty (don't override manual changes)
160 const shouldUpdateTitle = !formData.meta.title && inferredMeta.title
161 const shouldUpdateDescription = !formData.meta.description && inferredMeta.description
162 const shouldUpdateImage = !formData.meta.image && inferredMeta.image
163
164 if (shouldUpdateTitle || shouldUpdateDescription || shouldUpdateImage) {
165 const metaUpdates = {
166 ...formData.meta,
167 ...(shouldUpdateTitle && { title: inferredMeta.title }),
168 ...(shouldUpdateDescription && { description: inferredMeta.description }),
169 ...(shouldUpdateImage && { image: inferredMeta.image })
170 }
171
172 return { ...updates, meta: metaUpdates }
173 }
174
175 return updates
176 }, [formData, autoInferSEOMeta])
177
178 const clearMessages = useCallback(() => {
179 setError(null)
180 setSuccess(null)
181 }, [])
182
183 const resetForm = useCallback(() => {
184 setFormData({
185 title: '',
186 content: '',
187 categories: [],
188 _status: 'draft',
189 packageTypes: [createPackageFromTemplate('per_night')],
190 baseRate: '',
191 meta: {
192 title: '',
193 description: '',
194 image: ''
195 }
196 })
197 setUploadedImages([])
198 }, [])
199
200 // Debounced form update function to improve performance
201 const updateFormData = useCallback((updates: Partial<PostFormData>) => {
202 // Simplified: Direct update without debouncing to avoid React Context issues
203 const finalUpdates = updateSEOMetaAuto(updates)
204 setFormData(prev => ({ ...prev, ...finalUpdates }))
205 }, [updateSEOMetaAuto])
206
207 // Immediate form update for critical fields
208 const updateFormDataImmediate = useCallback((updates: Partial<PostFormData>) => {
209 setFormData(prev => ({ ...prev, ...updates }))
210 }, [])
211
212 const handlePackageChange = useCallback((idx: number, field: keyof PackageType, value: string | number | string[]) => {
213 console.log('handlePackageChange called:', { idx, field, value })
214 const newPackageTypes = [...formData.packageTypes]
215 newPackageTypes[idx] = {
216 ...newPackageTypes[idx],
217 [field]: value
218 } as PackageType
219 console.log('New packageTypes after change:', newPackageTypes)
220 updateFormDataImmediate({ packageTypes: newPackageTypes })
221 }, [formData.packageTypes, updateFormDataImmediate])
222
223 const addPackageType = useCallback(() => {
224 console.log('addPackageType called, current packages:', formData.packageTypes)
225 const newPackage = createPackageFromTemplate('per_night')
226 console.log('Adding new package:', newPackage)
227 const newPackageTypes = [...formData.packageTypes, newPackage]
228 console.log('New packageTypes array:', newPackageTypes)
229 updateFormDataImmediate({
230 packageTypes: newPackageTypes
231 })
232 }, [formData.packageTypes, updateFormDataImmediate])
233
234 const removePackageType = useCallback((idx: number) => {
235 console.log('removePackageType called:', { idx, currentLength: formData.packageTypes.length })
236 if (formData.packageTypes.length === 1) return // Keep at least one package
237 const newPackageTypes = formData.packageTypes.filter((_, i) => i !== idx)
238 console.log('New packageTypes after removal:', newPackageTypes)
239 updateFormDataImmediate({ packageTypes: newPackageTypes })
240 }, [formData.packageTypes, updateFormDataImmediate])
241
242 const addPackageTemplate = useCallback((templateKey: string) => {
243 console.log('addPackageTemplate called:', { templateKey, currentPackages: formData.packageTypes })
244 if (templateKey in packageTemplates) {
245 const newPackage = createPackageFromTemplate(templateKey)
246 console.log('Adding template package:', newPackage)
247 const newPackageTypes = [...formData.packageTypes, newPackage]
248 console.log('New packageTypes with template:', newPackageTypes)
249 updateFormDataImmediate({
250 packageTypes: newPackageTypes
251 })
252 }
253 }, [formData.packageTypes, updateFormDataImmediate])
254
255 const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
256 const files = event.target.files
257 if (!files || files.length === 0) return
258
259 setUploading(true)
260 try {
261 const uploadPromises = Array.from(files).map(async (file) => {
262 const formData = new FormData()
263 formData.append('file', file)
264
265 const response = await fetch('/api/media', {
266 method: 'POST',
267 body: formData,
268 })
269
270 if (!response.ok) {
271 throw new Error('Failed to upload image')
272 }
273
274 const result = await response.json()
275 return {
276 id: result.doc.id,
277 url: result.doc.url,
278 filename: result.doc.filename,
279 }
280 })
281
282 const results = await Promise.all(uploadPromises)
283 setUploadedImages(prev => [...prev, ...results])
284 setSuccess('Images uploaded successfully!')
285 } catch (err) {
286 setError('Failed to upload images. Please try again.')
287 } finally {
288 setUploading(false)
289 }
290 }
291
292 const removeImage = (imageId: string) => {
293 setUploadedImages(prev => prev.filter(img => img.id !== imageId))
294 }
295
296 const handleCreatePost = async () => {
297 if (!formData.title.trim()) {
298 setError('Title is required')
299 return
300 }
301
302 setLoading(true)
303 clearMessages()
304
305 try {
306 // Debug: Log the complete formData state before processing
307 console.log('=== FORM SUBMISSION DEBUG ===')
308 console.log('Complete formData state:', JSON.stringify(formData, null, 2))
309 console.log('formData.packageTypes length:', formData.packageTypes.length)
310 console.log('formData.packageTypes:', formData.packageTypes)
311
312 // Debug: Log the packageTypes before processing
313 console.log('Raw formData.packageTypes:', formData.packageTypes)
314
315 // Filter and process packageTypes
316 const processedPackageTypes = formData.packageTypes.filter(pkg =>
317 pkg.name && pkg.price !== ''
318 ).map(pkg => ({
319 name: pkg.name,
320 description: pkg.description,
321 price: Number(pkg.price),
322 multiplier: pkg.multiplier,
323 features: pkg.features.filter(f => f && f.trim()),
324 revenueCatId: pkg.revenueCatId,
325 }))
326
327 console.log('Processed packageTypes:', processedPackageTypes)
328
329 // Create both post and estimate
330 const postData: any = {
331 title: formData.title,
332 authors: [user.id],
333 categories: formData.categories,
334 heroImage: uploadedImages.length > 0 ? uploadedImages[0]?.id : undefined,
335 publishedAt: formData._status === 'published' ? new Date().toISOString() : undefined,
336 _status: formData._status,
337 ...(formData.baseRate !== '' && { baseRate: Number(formData.baseRate) }),
338 // Add packageTypes to the post data
339 packageTypes: processedPackageTypes,
340 meta: {
341 title: formData.meta.title || formData.title,
342 description: formData.meta.description || formData.content.slice(0, 155),
343 image: formData.meta.image || (uploadedImages.length > 0 ? uploadedImages[0]?.id : undefined)
344 },
345 content: {
346 root: {
347 type: "root",
348 children: [
349 {
350 type: "paragraph",
351 children: [
352 {
353 type: "text",
354 text: formData.content || ''
355 }
356 ]
357 }
358 ],
359 direction: null,
360 format: '',
361 indent: 0,
362 version: 1
363 }
364 }
365 }
366
367 // Debug: Log the complete postData being sent
368 console.log('Complete postData being sent:', JSON.stringify(postData, null, 2))
369
370 // Comprehensive validation of all form data
371 console.log('Full form data:', formData)
372
373 // Clean and validate all string fields
374 const cleanTitle = (formData.title || '').trim()
375 const cleanContent = (formData.content || '').trim()
376 const cleanMetaTitle = (formData.meta.title || '').trim()
377 const cleanMetaDescription = (formData.meta.description || '').trim()
378 const cleanMetaImage = (formData.meta.image || '').trim()
379
380 // Validate all fields for potential JSON issues
381 const validateField = (value: any, fieldName: string) => {
382 if (typeof value === 'string' && (value === '-' || value.match(/^-+$/))) {
383 throw new Error(`Invalid value "${value}" in field ${fieldName}`)
384 }
385 }
386
387 validateField(cleanTitle, 'title')
388 validateField(cleanContent, 'content')
389 validateField(cleanMetaTitle, 'meta.title')
390 validateField(cleanMetaDescription, 'meta.description')
391 validateField(cleanMetaImage, 'meta.image')
392
393 // Validate data before sending
394 if (!cleanTitle) {
395 throw new Error('Title cannot be empty')
396 }
397
398 if (formData.categories.some(catId => !catId || typeof catId !== 'string')) {
399 throw new Error('Invalid category IDs detected')
400 }
401
402 // Validate JSON structure before sending
403 try {
404 JSON.stringify(postData)
405 } catch (jsonError) {
406 console.error('JSON serialization error:', jsonError)
407 throw new Error('Invalid data format - please check your input values')
408 }
409
410 const response = await fetch('/api/posts', {
411 method: 'POST',
412 headers: {
413 'Content-Type': 'application/json',
414 },
415 body: JSON.stringify(postData),
416 })
417
418 if (!response.ok) {
419 const errorData = await response.json()
420 throw new Error(errorData.message || 'Failed to create post')
421 }
422
423 const result = await response.json()
424
425 // Create associated estimate with package types
426 if (formData.packageTypes.length > 0) {
427 try {
428 const validPackageTypes = formData.packageTypes.filter(pkg =>
429 pkg.name && pkg.price !== ''
430 ).map(pkg => ({
431 ...pkg,
432 price: Number(pkg.price)
433 }))
434
435 if (validPackageTypes.length > 0) {
436 const estimateData = {
437 customer: user.id,
438 post: result.doc.id,
439 baseRate: Number(formData.baseRate) || 0,
440 packageTypes: validPackageTypes,
441 title: `${formData.title} - Package Options`
442 }
443
444 await fetch('/api/estimates', {
445 method: 'POST',
446 headers: {
447 'Content-Type': 'application/json',
448 },
449 body: JSON.stringify(estimateData),
450 })
451 }
452 } catch (estimateError) {
453 console.warn('Failed to create estimate, but post was created successfully:', estimateError)
454 }
455 }
456
457 setPosts(prev => [result.doc, ...prev])
458 setSuccess('Plek created successfully!')
459 setIsCreateDialogOpen(false)
460 resetForm()
461 } catch (err: any) {
462 setError(err.message || 'Failed to create plek')
463 } finally {
464 setLoading(false)
465 }
466 }
467
468 const handleEditPost = async () => {
469 if (!editingPost?.id || !formData.title.trim()) {
470 setError('Title is required')
471 return
472 }
473
474 setLoading(true)
475 clearMessages()
476
477 try {
478 // Comprehensive validation of all form data
479 console.log('Full form data:', formData)
480
481 // Clean and validate all string fields
482 const cleanTitle = (formData.title || '').trim()
483 const cleanContent = (formData.content || '').trim()
484 const cleanMetaTitle = (formData.meta.title || '').trim()
485 const cleanMetaDescription = (formData.meta.description || '').trim()
486 const cleanMetaImage = (formData.meta.image || '').trim()
487
488 // Validate all fields for potential JSON issues
489 const validateField = (value: any, fieldName: string) => {
490 if (typeof value === 'string' && (value === '-' || value.match(/^-+$/))) {
491 throw new Error(`Invalid value "${value}" in field ${fieldName}`)
492 }
493 }
494
495 validateField(cleanTitle, 'title')
496 validateField(cleanContent, 'content')
497 validateField(cleanMetaTitle, 'meta.title')
498 validateField(cleanMetaDescription, 'meta.description')
499 validateField(cleanMetaImage, 'meta.image')
500
501 // Validate data before sending
502 if (!cleanTitle) {
503 throw new Error('Title cannot be empty')
504 }
505
506 if (formData.categories.some(catId => !catId || typeof catId !== 'string')) {
507 throw new Error('Invalid category IDs detected')
508 }
509
510 // Safely extract hero image ID
511 const getImageId = (image: any): string | undefined => {
512 if (!image) return undefined
513 if (typeof image === 'string') return image
514 if (typeof image === 'object' && image.id) return image.id
515 return undefined
516 }
517
518 // Safely handle published date
519 const getPublishedAt = (): string | undefined => {
520 if (formData._status === 'published' && !editingPost.publishedAt) {
521 return new Date().toISOString()
522 }
523 if (editingPost.publishedAt) {
524 // Ensure it's a valid date string
525 const date = new Date(editingPost.publishedAt)
526 return isNaN(date.getTime()) ? undefined : date.toISOString()
527 }
528 return undefined
529 }
530
531 const heroImageId = uploadedImages.length > 0 && uploadedImages[0]
532 ? uploadedImages[0].id
533 : getImageId(editingPost.heroImage)
534
535 const metaImageId = formData.meta.image ||
536 (uploadedImages.length > 0 && uploadedImages[0] ? uploadedImages[0].id : getImageId(editingPost.heroImage))
537
538 // Safely handle baseRate to prevent JSON parsing errors
539 const getValidBaseRate = (): number | undefined => {
540 if (formData.baseRate === '' || formData.baseRate === null || formData.baseRate === undefined) {
541 return undefined
542 }
543 const numValue = Number(formData.baseRate)
544 return !isNaN(numValue) && numValue >= 0 ? numValue : undefined
545 }
546
547 const postData: any = {
548 title: cleanTitle,
549 content: [
550 {
551 children: [
552 {
553 text: cleanContent
554 }
555 ],
556 direction: 'ltr',
557 format: '',
558 indent: 0,
559 type: 'paragraph',
560 version: 1
561 }
562 ],
563 meta: {
564 title: cleanMetaTitle,
565 description: cleanMetaDescription,
566 image: cleanMetaImage
567 },
568 categories: formData.categories.filter(cat => cat && typeof cat === 'string'),
569 _status: formData._status,
570 // Add packageTypes to the post data
571 packageTypes: formData.packageTypes.filter(pkg =>
572 pkg.name && pkg.price !== ''
573 ).map(pkg => ({
574 name: pkg.name,
575 description: pkg.description,
576 price: Number(pkg.price),
577 multiplier: pkg.multiplier,
578 features: pkg.features.filter(f => f && f.trim()),
579 revenueCatId: pkg.revenueCatId,
580 }))
581 }
582
583 // Only add baseRate if it's a valid number
584 const validBaseRate = getValidBaseRate()
585 if (validBaseRate !== undefined) {
586 postData.baseRate = validBaseRate
587 }
588
589 // Add other optional fields
590 if (heroImageId) {
591 postData.heroImage = heroImageId
592 }
593
594 const publishedAt = getPublishedAt()
595 if (publishedAt) {
596 postData.publishedAt = publishedAt
597 }
598
599 // Validate JSON structure before sending
600 try {
601 JSON.stringify(postData)
602 } catch (jsonError) {
603 console.error('JSON serialization error:', jsonError)
604 throw new Error('Invalid data format - please check your input values')
605 }
606
607 // Log the data being sent for debugging
608 console.log('Sending PATCH data:', JSON.stringify(postData, null, 2))
609
610 // Debug: Log the raw JSON string that will be sent
611 const jsonString = JSON.stringify(postData)
612 console.log('Raw JSON string:', jsonString)
613 console.log('JSON string length:', jsonString.length)
614 console.log('First 50 characters:', jsonString.substring(0, 50))
615
616 // Check for potential problematic values
617 console.log('Form data baseRate:', formData.baseRate, typeof formData.baseRate)
618 console.log('Computed baseRate:', getValidBaseRate())
619
620 const response = await fetch(`/api/posts/${editingPost.id}`, {
621 method: 'PATCH',
622 headers: {
623 'Content-Type': 'application/json',
624 },
625 body: jsonString,
626 })
627
628 if (!response.ok) {
629 const errorData = await response.json()
630 console.error('Server response error:', errorData)
631 throw new Error(errorData.error || errorData.message || `Server returned ${response.status}: ${response.statusText}`)
632 }
633
634 const result = await response.json()
635 setPosts(prev => prev.map(post => post.id === editingPost.id ? result.doc : post))
636 setSuccess('Plek updated successfully!')
637 setIsEditDialogOpen(false)
638 setEditingPost(null)
639 resetForm()
640 } catch (err: any) {
641 console.error('Edit post error:', err)
642 setError(err.message || 'Failed to update plek')
643 } finally {
644 setLoading(false)
645 }
646 }
647
648 const handleDeletePost = async () => {
649 if (!deletingPost?.id) return
650
651 setLoading(true)
652 clearMessages()
653
654 try {
655 const response = await fetch(`/api/posts/${deletingPost.id}`, {
656 method: 'DELETE',
657 })
658
659 if (!response.ok) {
660 throw new Error('Failed to delete post')
661 }
662
663 setPosts(prev => prev.filter(post => post.id !== deletingPost.id))
664 setSuccess('Plek deleted successfully!')
665 setIsDeleteDialogOpen(false)
666 setDeletingPost(null)
667 } catch (err: any) {
668 setError(err.message || 'Failed to delete plek')
669 } finally {
670 setLoading(false)
671 }
672 }
673
674 const openCreateDialog = () => {
675 resetForm()
676 setIsCreateDialogOpen(true)
677 }
678
679 const openEditDialog = (post: Post) => {
680 setEditingPost(post)
681
682 // Safely extract base rate
683 const getBaseRate = (): number | '' => {
684 if (typeof post.baseRate === 'number' && !isNaN(post.baseRate)) {
685 return post.baseRate
686 }
687 return ''
688 }
689
690 // Safely extract package types from post or use default
691 const getPackageTypes = (): PackageType[] => {
692 if (post.packageTypes && Array.isArray(post.packageTypes) && post.packageTypes.length > 0) {
693 return post.packageTypes.map((pkg: any) => ({
694 name: pkg.name || '',
695 description: pkg.description || '',
696 price: pkg.price || 0,
697 multiplier: pkg.multiplier || 1,
698 features: Array.isArray(pkg.features)
699 ? pkg.features.map((f: any) => typeof f === 'string' ? f : f.feature || '').filter((f: string) => f.trim())
700 : [],
701 revenueCatId: pkg.revenueCatId || '',
702 }))
703 }
704 return [createPackageFromTemplate('per_night')]
705 }
706
707 setFormData({
708 title: post.title || '',
709 content: extractTextFromContent(post.content),
710 categories: post.categories?.map(cat => typeof cat === 'string' ? cat : cat.id) || [],
711 _status: post._status as 'draft' | 'published' || 'draft',
712 packageTypes: getPackageTypes(),
713 baseRate: getBaseRate(),
714 meta: {
715 title: post.meta?.title || '',
716 description: post.meta?.description || '',
717 image: typeof post.meta?.image === 'string' ? post.meta.image : post.meta?.image?.id || ''
718 }
719 })
720 setUploadedImages([])
721 setIsEditDialogOpen(true)
722 }
723
724 const openDeleteDialog = (post: Post) => {
725 setDeletingPost(post)
726 setIsDeleteDialogOpen(true)
727 }
728
729 // Helper function to extract text from rich text content
730 const extractTextFromContent = (content: any): string => {
731 if (!content) return ''
732 if (typeof content === 'string') return content
733
734 try {
735 const traverse = (node: any): string => {
736 if (!node) return ''
737 if (typeof node === 'string') return node
738 if (node.text) return node.text
739 if (node.children && Array.isArray(node.children)) {
740 return node.children.map(traverse).join('')
741 }
742 return ''
743 }
744
745 if (content.root) {
746 return traverse(content.root)
747 }
748 return traverse(content)
749 } catch {
750 return ''
751 }
752 }
753
754 const getPostStatusBadge = (post: Post) => {
755 const status = post._status
756 const isPublished = status === 'published' && post.publishedAt
757
758 if (isPublished) {
759 return <Badge variant="default" className="bg-green-100 text-green-800">Published</Badge>
760 } else {
761 return <Badge variant="secondary">Draft</Badge>
762 }
763 }
764
765 const formatDate = (dateString: string | undefined) => {
766 if (!dateString) return 'Not set'
767 return new Date(dateString).toLocaleDateString('en-US', {
768 year: 'numeric',
769 month: 'short',
770 day: 'numeric',
771 hour: '2-digit',
772 minute: '2-digit'
773 })
774 }
775
776 // Memoized filtered posts for performance
777 const publishedPosts = useMemo(() =>
778 posts.filter(post => post._status === 'published' && post.publishedAt),
779 [posts]
780 )
781
782 const draftPosts = useMemo(() =>
783 posts.filter(post => post._status !== 'published' || !post.publishedAt),
784 [posts]
785 )
786
787 const copyToClipboard = async (text: string, type: string) => {
788 try {
789 await navigator.clipboard.writeText(text)
790 setCopiedScript(type)
791 setTimeout(() => setCopiedScript(null), 2000)
792 } catch (err) {
793 console.error('Failed to copy:', err)
794 }
795 }
796
797 // Fetch total visitors on component mount
798 useEffect(() => {
799 fetch('/api/analytics/posts')
800 .then(res => res.json())
801 .then(data => {
802 setTotalVisitors(data.totalUsers || 0)
803 })
804 .catch(err => console.error('Failed to fetch total visitors:', err))
805 }, [])
806
807 return (
808 <div className="container max-w-7xl mx-auto py-8">
809 {/* Header */}
810 <div className="flex items-center justify-between mb-8">
811 <div>
812 <h1 className="text-3xl font-bold">Plek Dashboard</h1>
813 <p className="text-muted-foreground">Manage your posts and content</p>
814 </div>
815 <div className="flex gap-3">
816 <Button
817 variant="outline"
818 className="gap-2"
819 onClick={() => setIsViewerDialogOpen(true)}
820 >
821 <Code className="h-4 w-4" />
822 Browse Pleks Embed
823 </Button>
824 <Button onClick={openCreateDialog} className="gap-2">
825 <Plus className="h-4 w-4" />
826 Create New Plek
827 </Button>
828 </div>
829 </div>
830
831 {/* Alerts */}
832 {error && (
833 <Alert variant="destructive" className="mb-6">
834 <AlertDescription>{error}</AlertDescription>
835 <Button variant="ghost" size="sm" onClick={clearMessages} className="ml-auto">
836 <X className="h-4 w-4" />
837 </Button>
838 </Alert>
839 )}
840
841 {success && (
842 <Alert className="mb-6 border-green-200 bg-green-50 text-green-800">
843 <AlertDescription>{success}</AlertDescription>
844 <Button variant="ghost" size="sm" onClick={clearMessages} className="ml-auto">
845 <X className="h-4 w-4" />
846 </Button>
847 </Alert>
848 )}
849
850 {/* Stats Cards */}
851 <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
852 <Card>
853 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
854 <CardTitle className="text-sm font-medium">Total Posts</CardTitle>
855 <BarChart3 className="h-4 w-4 text-muted-foreground" />
856 </CardHeader>
857 <CardContent>
858 <div className="text-2xl font-bold">{posts.length}</div>
859 <p className="text-xs text-muted-foreground">
860 {totalVisitors > 0 ? `${totalVisitors.toLocaleString()} total visitors` : ''}
861 </p>
862 </CardContent>
863 </Card>
864
865 <Card>
866 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
867 <CardTitle className="text-sm font-medium">Published</CardTitle>
868 <Eye className="h-4 w-4 text-muted-foreground" />
869 </CardHeader>
870 <CardContent>
871 <div className="text-2xl font-bold">{publishedPosts.length}</div>
872 </CardContent>
873 </Card>
874
875 <Card>
876 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
877 <CardTitle className="text-sm font-medium">Drafts</CardTitle>
878 <Edit className="h-4 w-4 text-muted-foreground" />
879 </CardHeader>
880 <CardContent>
881 <div className="text-2xl font-bold">{draftPosts.length}</div>
882 </CardContent>
883 </Card>
884 </div>
885
886 {/* Posts Management */}
887 <Tabs defaultValue="all" className="space-y-6">
888 <TabsList>
889 <TabsTrigger value="all">All Posts ({posts.length})</TabsTrigger>
890 <TabsTrigger value="published">Published ({publishedPosts.length})</TabsTrigger>
891 <TabsTrigger value="drafts">Drafts ({draftPosts.length})</TabsTrigger>
892 </TabsList>
893
894 <TabsContent value="all" className="space-y-4">
895 <PostsList
896 posts={posts}
897 onEdit={openEditDialog}
898 onDelete={openDeleteDialog}
899 onCreateNew={openCreateDialog}
900 getPostStatusBadge={getPostStatusBadge}
901 formatDate={formatDate}
902 extractTextFromContent={extractTextFromContent}
903 />
904 </TabsContent>
905
906 <TabsContent value="published" className="space-y-4">
907 <PostsList
908 posts={publishedPosts}
909 onEdit={openEditDialog}
910 onDelete={openDeleteDialog}
911 onCreateNew={openCreateDialog}
912 getPostStatusBadge={getPostStatusBadge}
913 formatDate={formatDate}
914 extractTextFromContent={extractTextFromContent}
915 />
916 </TabsContent>
917
918 <TabsContent value="drafts" className="space-y-4">
919 <PostsList
920 posts={draftPosts}
921 onEdit={openEditDialog}
922 onDelete={openDeleteDialog}
923 onCreateNew={openCreateDialog}
924 getPostStatusBadge={getPostStatusBadge}
925 formatDate={formatDate}
926 extractTextFromContent={extractTextFromContent}
927 />
928 </TabsContent>
929 </Tabs>
930
931 {/* Create Post Dialog */}
932 <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
933 <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
934 <DialogHeader>
935 <DialogTitle>Create New Plek</DialogTitle>
936 <DialogDescription>
937 Create a new post and configure package options for bookings.
938 </DialogDescription>
939 </DialogHeader>
940
941 <PostForm
942 formData={formData}
943 setFormData={updateFormDataImmediate}
944 updateFormData={updateFormData}
945 categories={categories}
946 uploadedImages={uploadedImages}
947 uploading={uploading}
948 onImageUpload={handleImageUpload}
949 onRemoveImage={removeImage}
950 onPackageChange={handlePackageChange}
951 onAddPackageType={addPackageType}
952 onRemovePackageType={removePackageType}
953 onAddPackageTemplate={addPackageTemplate}
954 />
955
956 <DialogFooter>
957 <Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
958 Cancel
959 </Button>
960 <Button onClick={handleCreatePost} disabled={loading}>
961 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
962 Create Plek
963 </Button>
964 </DialogFooter>
965 </DialogContent>
966 </Dialog>
967
968 {/* Edit Post Dialog */}
969 <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
970 <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
971 <DialogHeader>
972 <DialogTitle>Edit Plek</DialogTitle>
973 <DialogDescription>
974 Update your post content and package settings.
975 </DialogDescription>
976 </DialogHeader>
977
978 <PostForm
979 formData={formData}
980 setFormData={updateFormDataImmediate}
981 updateFormData={updateFormData}
982 categories={categories}
983 uploadedImages={uploadedImages}
984 uploading={uploading}
985 onImageUpload={handleImageUpload}
986 onRemoveImage={removeImage}
987 onPackageChange={handlePackageChange}
988 onAddPackageType={addPackageType}
989 onRemovePackageType={removePackageType}
990 onAddPackageTemplate={addPackageTemplate}
991 isEditing={true}
992 />
993
994 <DialogFooter>
995 <Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
996 Cancel
997 </Button>
998 <Button onClick={handleEditPost} disabled={loading}>
999 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
1000 Update Plek
1001 </Button>
1002 </DialogFooter>
1003 </DialogContent>
1004 </Dialog>
1005
1006 {/* Delete Post Dialog */}
1007 <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
1008 <DialogContent>
1009 <DialogHeader>
1010 <DialogTitle>Delete Post</DialogTitle>
1011 <DialogDescription>
1012 Are you sure you want to delete "{deletingPost?.title}"? This action cannot be undone.
1013 </DialogDescription>
1014 </DialogHeader>
1015 <DialogFooter>
1016 <Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
1017 Cancel
1018 </Button>
1019 <Button variant="destructive" onClick={handleDeletePost} disabled={loading}>
1020 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
1021 Delete Post
1022 </Button>
1023 </DialogFooter>
1024 </DialogContent>
1025 </Dialog>
1026
1027 {/* Browse Pleks Embed Code Dialog */}
1028 <Dialog open={isViewerDialogOpen} onOpenChange={setIsViewerDialogOpen}>
1029 <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
1030 <DialogHeader>
1031 <DialogTitle className="flex items-center gap-2">
1032 <Code className="h-5 w-5" />
1033 Browse Pleks - Embed Code
1034 </DialogTitle>
1035 <DialogDescription>
1036 Copy these code snippets to embed the Plek viewer in third-party websites.
1037 </DialogDescription>
1038 </DialogHeader>
1039
1040 <div className="space-y-6">
1041 {/* Basic Iframe */}
1042 <div className="space-y-3">
1043 <h4 className="font-semibold">Basic iframe Embed</h4>
1044 <div className="relative">
1045 <pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">
1046{`<iframe
1047 src="${typeof window !== 'undefined' ? window.location.origin : 'https://yourdomain.com'}/embed/plek-viewer"
1048 width="100%"
1049 height="700"
1050 frameborder="0"
1051 style="border: 1px solid #e5e7eb; border-radius: 8px;">
1052</iframe>`}
1053 </pre>
1054 <Button
1055 size="sm"
1056 variant="outline"
1057 className="absolute top-2 right-2"
1058 onClick={() => copyToClipboard(`<iframe src="${typeof window !== 'undefined' ? window.location.origin : 'https://yourdomain.com'}/embed/plek-viewer" width="100%" height="700" frameborder="0" style="border: 1px solid #e5e7eb; border-radius: 8px;"></iframe>`, 'viewer-basic')}
1059 >
1060 {copiedScript === 'viewer-basic' ? 'Copied!' : <Copy className="h-4 w-4" />}
1061 </Button>
1062 </div>
1063 </div>
1064
1065 {/* Responsive Iframe */}
1066 <div className="space-y-3">
1067 <h4 className="font-semibold">Responsive iframe Embed</h4>
1068 <div className="relative">
1069 <pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">
1070{`<div style="position: relative; width: 100%; height: 700px;">
1071 <iframe
1072 src="${typeof window !== 'undefined' ? window.location.origin : 'https://yourdomain.com'}/embed/plek-viewer"
1073 style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 1px solid #e5e7eb; border-radius: 8px;"
1074 frameborder="0">
1075 </iframe>
1076</div>`}
1077 </pre>
1078 <Button
1079 size="sm"
1080 variant="outline"
1081 className="absolute top-2 right-2"
1082 onClick={() => copyToClipboard(`<div style="position: relative; width: 100%; height: 700px;"><iframe src="${typeof window !== 'undefined' ? window.location.origin : 'https://yourdomain.com'}/embed/plek-viewer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 1px solid #e5e7eb; border-radius: 8px;" frameborder="0"></iframe></div>`, 'viewer-responsive')}
1083 >
1084 {copiedScript === 'viewer-responsive' ? 'Copied!' : <Copy className="h-4 w-4" />}
1085 </Button>
1086 </div>
1087 </div>
1088
1089 {/* JSON Data Structure */}
1090 <div className="space-y-3">
1091 <h4 className="font-semibold">Plek JSON Data Structure</h4>
1092 <div className="relative">
1093 <pre className="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">
1094{`{
1095 "title": "Knysna Beach House",
1096 "heroImage": "68551f42433af111fcd627fc",
1097 "content": {
1098 "root": {
1099 "children": [
1100 {
1101 "type": "paragraph",
1102 "children": [
1103 {
1104 "type": "text",
1105 "text": "Beautiful beachfront property..."
1106 }
1107 ]
1108 }
1109 ],
1110 "format": "",
1111 "type": "root",
1112 "version": 1
1113 }
1114 },
1115 "categories": ["684aca2d59ac17af425eb5f7"],
1116 "meta": {},
1117 "publishedAt": "2025-06-20T06:55:45.508Z",
1118 "authors": ["684ac67759ac17af425eaf63"],
1119 "baseRate": 8000,
1120 "slug": "knysna-beach-house",
1121 "slugLock": true,
1122 "_status": "published",
1123 "id": "6855052c80b3955db58780ec"
1124}`}
1125 </pre>
1126 <Button
1127 size="sm"
1128 variant="outline"
1129 className="absolute top-2 right-2"
1130 onClick={() => copyToClipboard(`{
1131 "title": "Knysna Beach House",
1132 "heroImage": "68551f42433af111fcd627fc",
1133 "content": {
1134 "root": {
1135 "children": [
1136 {
1137 "type": "paragraph",
1138 "children": [
1139 {
1140 "type": "text",
1141 "text": "Beautiful beachfront property..."
1142 }
1143 ]
1144 }
1145 ],
1146 "format": "",
1147 "type": "root",
1148 "version": 1
1149 }
1150 },
1151 "categories": ["684aca2d59ac17af425eb5f7"],
1152 "meta": {},
1153 "publishedAt": "2025-06-20T06:55:45.508Z",
1154 "authors": ["684ac67759ac17af425eaf63"],
1155 "baseRate": 8000,
1156 "slug": "knysna-beach-house",
1157 "slugLock": true,
1158 "_status": "published",
1159 "id": "6855052c80b3955db58780ec"
1160}`, 'viewer-json')}
1161 >
1162 {copiedScript === 'viewer-json' ? 'Copied!' : <Copy className="h-4 w-4" />}
1163 </Button>
1164 </div>
1165 </div>
1166 </div>
1167
1168 <DialogFooter>
1169 <Button variant="outline" onClick={() => setIsViewerDialogOpen(false)}>
1170 Close
1171 </Button>
1172 <Button asChild>
1173 <Link href="/embed/plek-viewer" target="_blank">
1174 Preview Embed
1175 </Link>
1176 </Button>
1177 </DialogFooter>
1178 </DialogContent>
1179 </Dialog>
1180 </div>
1181 )
1182}
1183
1184// Enhanced PostsList with analytics
1185function PostsList({
1186 posts,
1187 onEdit,
1188 onDelete,
1189 onCreateNew,
1190 getPostStatusBadge,
1191 formatDate,
1192 extractTextFromContent
1193}: {
1194 posts: Post[]
1195 onEdit: (post: Post) => void
1196 onDelete: (post: Post) => void
1197 onCreateNew: () => void
1198 getPostStatusBadge: (post: Post) => React.JSX.Element
1199 formatDate: (dateString: string | undefined) => string
1200 extractTextFromContent: (content: any) => string
1201}) {
1202 const [analyticsData, setAnalyticsData] = useState<Record<string, { views: number; users: number; sessions: number }>>({})
1203
1204 useEffect(() => {
1205 // Fetch analytics data for inline display
1206 fetch('/api/analytics/posts')
1207 .then(res => res.json())
1208 .then(data => {
1209 const analyticsMap: Record<string, { views: number; users: number; sessions: number }> = {}
1210 data.postAnalytics?.forEach((item: any) => {
1211 analyticsMap[item.slug] = {
1212 views: item.views,
1213 users: item.users,
1214 sessions: item.sessions
1215 }
1216 })
1217 setAnalyticsData(analyticsMap)
1218 })
1219 .catch(err => console.error('Failed to fetch inline analytics:', err))
1220 }, [])
1221
1222 if (posts.length === 0) {
1223 return (
1224 <Card>
1225 <CardContent className="flex flex-col items-center justify-center py-12">
1226 <div className="text-muted-foreground mb-4">No posts found</div>
1227 <Button onClick={onCreateNew} variant="outline" className="gap-2">
1228 <Plus className="h-4 w-4" />
1229 Create your first plek
1230 </Button>
1231 </CardContent>
1232 </Card>
1233 )
1234 }
1235
1236 return (
1237 <div className="grid gap-4">
1238 {posts.map((post) => {
1239 const analytics = analyticsData[post.slug || '']
1240 return (
1241 <Card key={post.id}>
1242 <CardContent className="p-6">
1243 <div className="flex items-start justify-between">
1244 <div className="flex-1 min-w-0">
1245 <div className="flex items-center gap-3 mb-2">
1246 <h3 className="text-lg font-semibold truncate">{post.title}</h3>
1247 {getPostStatusBadge(post)}
1248 {analytics && post._status === 'published' && (
1249 <Badge variant="outline" className="gap-1">
1250 <Eye className="h-3 w-3" />
1251 {analytics.views} views
1252 </Badge>
1253 )}
1254 </div>
1255
1256 <div className="text-sm text-muted-foreground mb-4">
1257 <div className="flex items-center gap-4">
1258 <span className="flex items-center gap-1">
1259 <Calendar className="h-3 w-3" />
1260 Created: {formatDate(post.createdAt)}
1261 </span>
1262 {post.publishedAt && (
1263 <span className="flex items-center gap-1">
1264 <Eye className="h-3 w-3" />
1265 Published: {formatDate(post.publishedAt)}
1266 </span>
1267 )}
1268 </div>
1269 </div>
1270
1271 {post.categories && post.categories.length > 0 && (
1272 <div className="flex gap-2 flex-wrap mb-4">
1273 {post.categories.map((category) => (
1274 <Badge key={typeof category === 'string' ? category : category.id} variant="outline" className="text-xs">
1275 {typeof category === 'string' ? category : category.title}
1276 </Badge>
1277 ))}
1278 </div>
1279 )}
1280
1281 <div className="text-sm text-muted-foreground">
1282 {extractTextFromContent(post.content).slice(0, 150)}
1283 {extractTextFromContent(post.content).length > 150 && '...'}
1284 </div>
1285 </div>
1286
1287 <div className="flex items-center gap-2 ml-4">
1288 {/* Mobile: Sheet with actions */}
1289 <div className="md:hidden">
1290 <Sheet>
1291 <SheetTrigger asChild>
1292 <Button variant="ghost" size="sm">
1293 <MoreVertical className="h-4 w-4" />
1294 </Button>
1295 </SheetTrigger>
1296 <SheetContent side="bottom" className="h-auto">
1297 <SheetHeader>
1298 <SheetTitle>{post.title}</SheetTitle>
1299 <SheetDescription>
1300 Choose an action for this plek
1301 </SheetDescription>
1302 </SheetHeader>
1303 <div className="grid gap-3 py-4">
1304 {post.slug && (
1305 <Button variant="outline" className="justify-start gap-2" asChild>
1306 <Link href={`/posts/${post.slug}`} target="_blank">
1307 <Eye className="h-4 w-4" />
1308 View Post
1309 </Link>
1310 </Button>
1311 )}
1312 <Button variant="outline" className="justify-start gap-2" onClick={() => onEdit(post)}>
1313 <Edit className="h-4 w-4" />
1314 Edit Post
1315 </Button>
1316 <Button variant="destructive" className="justify-start gap-2" onClick={() => onDelete(post)}>
1317 <Trash2 className="h-4 w-4" />
1318 Delete Post
1319 </Button>
1320 </div>
1321 </SheetContent>
1322 </Sheet>
1323 </div>
1324
1325 {/* Desktop: Inline actions */}
1326 <div className="hidden md:flex items-center gap-2">
1327 {post.slug && (
1328 <Button variant="ghost" size="sm" asChild>
1329 <Link href={`/posts/${post.slug}`} target="_blank">
1330 <Eye className="h-4 w-4" />
1331 </Link>
1332 </Button>
1333 )}
1334 <Button variant="ghost" size="sm" onClick={() => onEdit(post)}>
1335 <Edit className="h-4 w-4" />
1336 </Button>
1337 <Button variant="ghost" size="sm" onClick={() => onDelete(post)}>
1338 <Trash2 className="h-4 w-4" />
1339 </Button>
1340 </div>
1341 </div>
1342 </div>
1343 </CardContent>
1344 </Card>
1345 )
1346 })}
1347 </div>
1348 )
1349}
1350
1351function PostForm({
1352 formData,
1353 setFormData,
1354 updateFormData,
1355 categories,
1356 uploadedImages,
1357 uploading,
1358 onImageUpload,
1359 onRemoveImage,
1360 onPackageChange,
1361 onAddPackageType,
1362 onRemovePackageType,
1363 onAddPackageTemplate,
1364 isEditing = false
1365}: {
1366 formData: PostFormData
1367 setFormData: (data: PostFormData) => void
1368 updateFormData: (updates: Partial<PostFormData>) => void
1369 categories: Category[]
1370 uploadedImages: UploadedFile[]
1371 uploading: boolean
1372 onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void
1373 onRemoveImage: (imageId: string) => void
1374 onPackageChange: (idx: number, field: keyof PackageType, value: string | number | string[]) => void
1375 onAddPackageType: () => void
1376 onRemovePackageType: (idx: number) => void
1377 onAddPackageTemplate: (templateKey: string) => void
1378 isEditing?: boolean
1379}) {
1380 return (
1381 <div className="space-y-6">
1382 <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
1383 <div className="space-y-4">
1384 <div className="space-y-2">
1385 <Label htmlFor="title">Title *</Label>
1386 <Input
1387 id="title"
1388 value={formData.title}
1389 onChange={(e) => updateFormData({ title: e.target.value })}
1390 placeholder="Enter plek title..."
1391 />
1392 </div>
1393
1394 <div className="space-y-2">
1395 <Label htmlFor="baseRate">Base Rate (per night) *</Label>
1396 <Input
1397 id="baseRate"
1398 type="number"
1399 min={0}
1400 value={formData.baseRate}
1401 onChange={(e) => updateFormData({ baseRate: e.target.value === '' ? '' : Number(e.target.value) })}
1402 placeholder="Enter base rate..."
1403 />
1404 </div>
1405
1406 <div className="space-y-2">
1407 <Label>Categories</Label>
1408 <Popover>
1409 <PopoverTrigger asChild>
1410 <Button
1411 variant="outline"
1412 role="combobox"
1413 className={cn(
1414 "w-full justify-between",
1415 formData.categories.length === 0 && "text-muted-foreground"
1416 )}
1417 >
1418 <div className="flex flex-wrap gap-1 max-w-full">
1419 {formData.categories.length === 0 ? (
1420 "Select categories..."
1421 ) : formData.categories.length <= 2 ? (
1422 formData.categories.map((categoryId) => {
1423 const category = categories.find(c => c.id === categoryId)
1424 return category ? (
1425 <Badge key={categoryId} variant="secondary" className="text-xs">
1426 {category.title}
1427 </Badge>
1428 ) : null
1429 })
1430 ) : (
1431 <div className="flex items-center gap-1">
1432 <Badge variant="secondary" className="text-xs">
1433 {categories.find(c => c.id === formData.categories[0])?.title}
1434 </Badge>
1435 <span className="text-xs text-muted-foreground">
1436 +{formData.categories.length - 1} more
1437 </span>
1438 </div>
1439 )}
1440 </div>
1441 <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
1442 </Button>
1443 </PopoverTrigger>
1444 <PopoverContent className="w-full p-0" align="start">
1445 <div className="p-3 border-b">
1446 <h4 className="font-medium text-sm">Select categories</h4>
1447 <p className="text-xs text-muted-foreground">Choose one or more categories for your plek</p>
1448 </div>
1449 <div className="p-1">
1450 {categories.length === 0 ? (
1451 <div className="p-2 text-sm text-muted-foreground text-center">
1452 No categories available
1453 </div>
1454 ) : (
1455 categories.map((category) => (
1456 <div key={category.id} className="flex items-center space-x-2 p-2 hover:bg-accent rounded-sm cursor-pointer">
1457 <Checkbox
1458 id={`category-${category.id}`}
1459 checked={formData.categories.includes(category.id)}
1460 onCheckedChange={(checked) => {
1461 if (checked) {
1462 updateFormData({
1463 categories: [...formData.categories, category.id]
1464 })
1465 } else {
1466 updateFormData({
1467 categories: formData.categories.filter(id => id !== category.id)
1468 })
1469 }
1470 }}
1471 />
1472 <label
1473 htmlFor={`category-${category.id}`}
1474 className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer flex-1"
1475 >
1476 {category.title}
1477 </label>
1478 {formData.categories.includes(category.id) && (
1479 <Check className="h-4 w-4 text-primary" />
1480 )}
1481 </div>
1482 ))
1483 )}
1484 </div>
1485 {formData.categories.length > 0 && (
1486 <div className="p-3 border-t bg-muted/50">
1487 <div className="flex items-center justify-between">
1488 <span className="text-sm text-muted-foreground">
1489 {formData.categories.length} selected
1490 </span>
1491 <Button
1492 variant="ghost"
1493 size="sm"
1494 onClick={() => updateFormData({ categories: [] })}
1495 className="h-6 text-xs"
1496 >
1497 Clear all
1498 </Button>
1499 </div>
1500 </div>
1501 )}
1502 </PopoverContent>
1503 </Popover>
1504 </div>
1505
1506 <div className="flex items-center space-x-2">
1507 <Switch
1508 id="publish"
1509 checked={formData._status === 'published'}
1510 onCheckedChange={(checked) =>
1511 updateFormData({ _status: checked ? 'published' : 'draft' })
1512 }
1513 />
1514 <Label htmlFor="publish">
1515 {formData._status === 'published' ? 'Publish immediately' : 'Save as draft'}
1516 </Label>
1517 </div>
1518 </div>
1519
1520 <div className="space-y-4">
1521 <div className="space-y-2">
1522 <Label htmlFor="content">Content *</Label>
1523 <Textarea
1524 id="content"
1525 value={formData.content}
1526 onChange={(e) => updateFormData({ content: e.target.value })}
1527 placeholder="Write your plek description..."
1528 rows={6}
1529 />
1530 </div>
1531
1532 <div className="space-y-2">
1533 <Label>Hero Image</Label>
1534 <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4">
1535 <input
1536 type="file"
1537 accept="image/*"
1538 onChange={onImageUpload}
1539 disabled={uploading}
1540 className="mb-2"
1541 multiple
1542 />
1543 {uploading && (
1544 <div className="flex items-center gap-2 text-sm text-muted-foreground">
1545 <Loader2 className="h-4 w-4 animate-spin" />
1546 Uploading images...
1547 </div>
1548 )}
1549 {uploadedImages && uploadedImages.length > 0 && uploadedImages[0] && (
1550 <div className="grid grid-cols-3 gap-2 mt-2">
1551 {uploadedImages.map((image) => (
1552 <div key={image.id} className="relative">
1553 <img
1554 src={image.url}
1555 alt={image.filename}
1556 className="w-full h-16 object-cover rounded"
1557 />
1558 <Button
1559 type="button"
1560 variant="destructive"
1561 size="sm"
1562 className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
1563 onClick={() => onRemoveImage(image.id)}
1564 >
1565 <X className="h-3 w-3" />
1566 </Button>
1567 </div>
1568 ))}
1569 </div>
1570 )}
1571 </div>
1572 </div>
1573 </div>
1574 </div>
1575
1576 {/* Package Types Section */}
1577 <div className="space-y-4">
1578 <div className="flex items-center justify-between">
1579 <h3 className="text-lg font-medium">Package Types</h3>
1580 <Button type="button" onClick={onAddPackageType} variant="outline" size="sm">
1581 <Plus className="h-4 w-4 mr-1" />
1582 Add Package
1583 </Button>
1584 </div>
1585
1586 {/* Package Template Quick Add */}
1587 <div className="p-4 border rounded-lg bg-muted/50">
1588 <p className="text-sm font-medium mb-2">Quick Add Templates:</p>
1589 <div className="flex flex-wrap gap-2">
1590 {Object.entries(packageTemplates).map(([key, template]) => (
1591 <Button
1592 key={key}
1593 type="button"
1594 variant="outline"
1595 size="sm"
1596 onClick={() => onAddPackageTemplate(key)}
1597 className="gap-1"
1598 >
1599 <Package className="h-3 w-3" />
1600 {template.name}
1601 </Button>
1602 ))}
1603 </div>
1604 </div>
1605
1606 {formData.packageTypes.map((pkg, idx) => (
1607 <Card key={idx} className="p-4">
1608 <div className="space-y-3">
1609 <div className="flex items-center justify-between">
1610 <Label className="text-sm font-medium">Package {idx + 1}</Label>
1611 <Button
1612 type="button"
1613 variant="destructive"
1614 size="sm"
1615 onClick={() => onRemovePackageType(idx)}
1616 disabled={formData.packageTypes.length === 1}
1617 >
1618 <Trash2 className="h-4 w-4" />
1619 </Button>
1620 </div>
1621
1622 <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
1623 <Input
1624 placeholder="Package name"
1625 value={pkg.name}
1626 onChange={(e) => onPackageChange(idx, 'name', e.target.value)}
1627 />
1628 <div className="flex gap-2">
1629 <div className="flex-1">
1630 <Input
1631 placeholder="Price"
1632 type="number"
1633 min={0}
1634 value={pkg.price}
1635 onChange={(e) => onPackageChange(idx, 'price', e.target.value === '' ? '' : Number(e.target.value))}
1636 />
1637 </div>
1638 <div className="flex-1">
1639 <Input
1640 placeholder="Multiplier"
1641 type="number"
1642 min={0}
1643 step={0.1}
1644 value={pkg.multiplier}
1645 onChange={(e) => onPackageChange(idx, 'multiplier', Number(e.target.value))}
1646 />
1647 </div>
1648 </div>
1649 <Input
1650 placeholder="RevenueCat ID"
1651 value={pkg.revenueCatId}
1652 onChange={(e) => onPackageChange(idx, 'revenueCatId', e.target.value)}
1653 />
1654 </div>
1655
1656 <Textarea
1657 placeholder="Package description"
1658 value={pkg.description}
1659 onChange={(e) => onPackageChange(idx, 'description', e.target.value)}
1660 rows={2}
1661 />
1662
1663 <div className="space-y-2">
1664 <Label className="text-sm">Features</Label>
1665 <Textarea
1666 placeholder="Enter features (one per line)"
1667 value={pkg.features.join('\n')}
1668 onChange={(e) => onPackageChange(idx, 'features', e.target.value.split('\n').filter(f => f.trim()))}
1669 rows={3}
1670 />
1671 </div>
1672 </div>
1673 </Card>
1674 ))}
1675 </div>
1676
1677 {/* SEO Meta Section */}
1678 <div className="space-y-4">
1679 <div className="flex items-center gap-2">
1680 <h3 className="text-lg font-medium">SEO Meta Fields</h3>
1681 <Badge variant="secondary" className="text-xs">Auto-inferred</Badge>
1682 </div>
1683
1684 <Alert className="border-blue-200 bg-blue-50">
1685 <AlertDescription className="text-sm">
1686 SEO fields are automatically generated from your title, content, and hero image.
1687 You can override them by editing the fields below.
1688 </AlertDescription>
1689 </Alert>
1690
1691 <div className="grid grid-cols-1 gap-4">
1692 <div className="space-y-2">
1693 <Label htmlFor="metaTitle">SEO Title</Label>
1694 <Input
1695 id="metaTitle"
1696 value={formData.meta.title}
1697 onChange={(e) => updateFormData({ meta: { ...formData.meta, title: e.target.value } })}
1698 placeholder={formData.title ? `${formData.title} | Stay at our self built plek` : "Will be auto-generated from title..."}
1699 />
1700 {!formData.meta.title && formData.title && (
1701 <p className="text-xs text-muted-foreground">
1702 Auto-generated: "{formData.title} | Stay at our self built plek"
1703 </p>
1704 )}
1705 </div>
1706
1707 <div className="space-y-2">
1708 <Label htmlFor="metaDescription">SEO Description</Label>
1709 <Textarea
1710 id="metaDescription"
1711 value={formData.meta.description}
1712 onChange={(e) => updateFormData({ meta: { ...formData.meta, description: e.target.value } })}
1713 placeholder={formData.content ? formData.content.slice(0, 155) + (formData.content.length > 155 ? '...' : '') : "Will be auto-generated from content..."}
1714 rows={3}
1715 maxLength={160}
1716 />
1717 <div className="flex justify-between text-xs text-muted-foreground">
1718 <span>
1719 {!formData.meta.description && formData.content && (
1720 <>Auto-generated from content (first 155 characters)</>
1721 )}
1722 </span>
1723 <span>{formData.meta.description.length}/160</span>
1724 </div>
1725 </div>
1726
1727 <div className="space-y-2">
1728 <Label>SEO Image</Label>
1729 <div className="text-sm text-muted-foreground">
1730 {uploadedImages && uploadedImages.length > 0 && uploadedImages[0] ? (
1731 <div className="flex items-center gap-2">
1732 <img
1733 src={uploadedImages[0].url}
1734 alt="SEO preview"
1735 className="w-16 h-16 object-cover rounded border"
1736 />
1737 <span>Using hero image as SEO image</span>
1738 </div>
1739 ) : formData.meta.image ? (
1740 <span>Custom SEO image set</span>
1741 ) : (
1742 <span>No SEO image - will use hero image when uploaded</span>
1743 )}
1744 </div>
1745 </div>
1746
1747 {/* SEO Preview */}
1748 <div className="p-4 border rounded-lg bg-muted/50">
1749 <h4 className="text-sm font-medium mb-2">Search Engine Preview</h4>
1750 <div className="space-y-1">
1751 <div className="text-blue-600 text-sm font-medium">
1752 {formData.meta.title || (formData.title ? `${formData.title} | Stay at our self built plek` : 'Your plek title | Stay at our self built plek')}
1753 </div>
1754 <div className="text-green-700 text-xs">
1755 yoursite.com/posts/{formData.title ? formData.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') : 'your-plek-slug'}
1756 </div>
1757 <div className="text-gray-600 text-sm">
1758 {formData.meta.description || (formData.content ? `${formData.content.slice(0, 155)}${formData.content.length > 155 ? '...' : ''}` : 'Your plek description will appear here...')}
1759 </div>
1760 </div>
1761 </div>
1762 </div>
1763 </div>
1764 </div>
1765 )
1766}


Third-Party Integration Options:

For Public Viewing (Read-only):

1<iframe src="https://yourdomain.com/embed/plek-viewer" width="100%" height="700px"></iframe>

For Admin Management (CRUD):

1<iframe src="https://yourdomain.com/embed/post-manager" width="100%" height="600px"></iframe>
1<iframe
2 src="http://localhost:3000/embed/plek-viewer"
3 width="100%"
4 height="700"
5 frameborder="0"
6 style="border: 1px solid #e5e7eb; border-radius: 8px;">
7</iframe>