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: string3 name: string4 description: string5 multiplier: number6 features: string[]7 revenueCatId: string8 minNights?: number9 maxNights?: number10 isHosted?: boolean11 category: 'standard' | 'luxury' | 'hosted' | 'specialty'12}1314export interface PackageTypeTemplate {15 name: string16 description: string17 multiplier: number18 features: string[]19 revenueCatId: string20 minNights?: number21 maxNights?: number22 isHosted?: boolean23 category: 'standard' | 'luxury' | 'hosted' | 'specialty'24}2526// Centralized package types definition based on Plek's core offerings27export const PACKAGE_TYPES: Record<string, PackageTypeTemplate> = {28 // Standard Packages29 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 },4445 // Luxury Packages46 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 },6465 // Multi-night Packages66 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 },8283 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 },102103 // Weekly Packages104 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 },121122 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 },142143 // Extended Stay Packages144 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 },163164 // Specialty Packages165 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 const181182// Helper functions183export const getPackageById = (id: string): PackageTypeTemplate | null => {184 return PACKAGE_TYPES[id] || null185}186187export const getPackagesByCategory = (category: PackageType['category']): Record<string, PackageTypeTemplate> => {188 return Object.fromEntries(189 Object.entries(PACKAGE_TYPES).filter(([_, pkg]) => pkg.category === category)190 )191}192193export const getPackageByDuration = (nights: number): string | null => {194 // Logic to determine appropriate package based on duration195 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 null200}201202export const formatPackageFeatures = (features: string[]): string => {203 return features.join(', ')204}205206// Package validation207export 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.revenueCatId214 )215}216217// Convert template to full package type218export const createPackageFromTemplate = (id: string, template: PackageTypeTemplate): PackageType => {219 return {220 id,221 ...template222 }223}224225// Get all package types as full PackageType objects226export 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 }67 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 });1920 if (!response.ok) {21 throw new Error(`HTTP error! status: ${response.status}`);22 }2324 return await response.json();25 } catch (error) {26 console.error('Failed to update post:', error);27 throw error;28 }29 }3031 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 });3940 if (!response.ok) {41 throw new Error(`HTTP error! status: ${response.status}`);42 }4344 return await response.json();45 } catch (error) {46 console.error('Failed to delete post:', error);47 throw error;48 }49 }50}5152// Usage53const api = new PlekAPI('https://yourdomain.com', 'your_api_key');5455// Update a post56api.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});6364// Delete a post65api.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"67export default async function PlekAdminPage() {8 let userAuth910 try {11 userAuth = await getMeUser()12 } catch (error) {13 redirect('/login?redirect=/plek/adminPage')14 }1516 if (!userAuth?.user) {17 redirect('/login?redirect=/plek/adminPage')18 }1920 const user = userAuth.user2122 // Check if user has customer or admin role23 const userRoles = user.role || []24 if (!userRoles.includes('customer') && !userRoles.includes('admin')) {25 redirect('/?error=unauthorized')26 }2728 const payload = await getPayload({ config })2930 // Fetch user's posts31 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 })4243 // Fetch categories for the form44 const categories = await payload.find({45 collection: 'categories',46 limit: 100,47 sort: 'title',48 })4950 return (51 <div className="min-h-screen bg-background">52 <PlekAdminClient53 user={user}54 initialPosts={userPosts.docs}55 categories={categories.docs}56 />57 </div>58 )59}
plek/adminPage

1"use client"23import 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'2728interface PlekAdminClientProps {29 user: User30 initialPosts: Post[]31 categories: Category[]32}3334interface PackageType {35 name: string36 description: string37 price: number | ''38 multiplier: number39 features: string[]40 revenueCatId: string41}4243interface PostFormData {44 title: string45 content: string46 categories: string[]47 heroImage?: string48 publishedAt?: string49 _status: 'draft' | 'published'50 packageTypes: PackageType[]51 baseRate: number | ''52 meta: {53 title: string54 description: string55 image: string56 }57}5859interface UploadedFile {60 id: string61 url: string62 filename: string63}6465// Replace the existing packageTemplates with centralized types66const 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 acc75}, {})7677const createPackageFromTemplate = (templateKey: string): PackageType => {78 const packageTemplate = getPackageById(templateKey)79 if (!packageTemplate) {80 throw new Error(`Package template not found: ${templateKey}`)81 }8283 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}9293interface PackageFormProps {94 packageTypes: PackageType[]95 onPackageChange: (idx: number, field: keyof PackageType, value: string | number | string[]) => void96 onAddPackageType: () => void97 onRemovePackageType: (idx: number) => void98 onAddPackageTemplate: (templateKey: string) => void99 isEditing?: boolean100}101102export 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)109110 // Form states111 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)118119 // Form data with debounced updates120 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 })133134 // Image upload135 const [uploadedImages, setUploadedImages] = useState<UploadedFile[]>([])136 const [uploading, setUploading] = useState(false)137138 // Debounce timer ref to prevent excessive re-renders139 const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)140141 // Auto-generate SEO meta fields from form data142 const autoInferSEOMeta = useCallback((formData: PostFormData) => {143 const inferredMeta = {144 title: formData.title ? `${formData.title} | Stay at our self built plek` : '',145 description: formData.content146 ? `${formData.content.slice(0, 155)}${formData.content.length > 155 ? '...' : ''}`147 : '',148 image: uploadedImages.length > 0 && uploadedImages[0] ? uploadedImages[0].id : formData.meta.image149 }150151 return inferredMeta152 }, [uploadedImages])153154 // Auto-update SEO meta when title, content, or images change155 const updateSEOMetaAuto = useCallback((updates: Partial<PostFormData>) => {156 const updatedFormData = { ...formData, ...updates }157 const inferredMeta = autoInferSEOMeta(updatedFormData)158159 // Only auto-update if SEO fields are empty (don't override manual changes)160 const shouldUpdateTitle = !formData.meta.title && inferredMeta.title161 const shouldUpdateDescription = !formData.meta.description && inferredMeta.description162 const shouldUpdateImage = !formData.meta.image && inferredMeta.image163164 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 }171172 return { ...updates, meta: metaUpdates }173 }174175 return updates176 }, [formData, autoInferSEOMeta])177178 const clearMessages = useCallback(() => {179 setError(null)180 setSuccess(null)181 }, [])182183 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 }, [])199200 // Debounced form update function to improve performance201 const updateFormData = useCallback((updates: Partial<PostFormData>) => {202 // Simplified: Direct update without debouncing to avoid React Context issues203 const finalUpdates = updateSEOMetaAuto(updates)204 setFormData(prev => ({ ...prev, ...finalUpdates }))205 }, [updateSEOMetaAuto])206207 // Immediate form update for critical fields208 const updateFormDataImmediate = useCallback((updates: Partial<PostFormData>) => {209 setFormData(prev => ({ ...prev, ...updates }))210 }, [])211212 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]: value218 } as PackageType219 console.log('New packageTypes after change:', newPackageTypes)220 updateFormDataImmediate({ packageTypes: newPackageTypes })221 }, [formData.packageTypes, updateFormDataImmediate])222223 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: newPackageTypes231 })232 }, [formData.packageTypes, updateFormDataImmediate])233234 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 package237 const newPackageTypes = formData.packageTypes.filter((_, i) => i !== idx)238 console.log('New packageTypes after removal:', newPackageTypes)239 updateFormDataImmediate({ packageTypes: newPackageTypes })240 }, [formData.packageTypes, updateFormDataImmediate])241242 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: newPackageTypes251 })252 }253 }, [formData.packageTypes, updateFormDataImmediate])254255 const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {256 const files = event.target.files257 if (!files || files.length === 0) return258259 setUploading(true)260 try {261 const uploadPromises = Array.from(files).map(async (file) => {262 const formData = new FormData()263 formData.append('file', file)264265 const response = await fetch('/api/media', {266 method: 'POST',267 body: formData,268 })269270 if (!response.ok) {271 throw new Error('Failed to upload image')272 }273274 const result = await response.json()275 return {276 id: result.doc.id,277 url: result.doc.url,278 filename: result.doc.filename,279 }280 })281282 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 }291292 const removeImage = (imageId: string) => {293 setUploadedImages(prev => prev.filter(img => img.id !== imageId))294 }295296 const handleCreatePost = async () => {297 if (!formData.title.trim()) {298 setError('Title is required')299 return300 }301302 setLoading(true)303 clearMessages()304305 try {306 // Debug: Log the complete formData state before processing307 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)311312 // Debug: Log the packageTypes before processing313 console.log('Raw formData.packageTypes:', formData.packageTypes)314315 // Filter and process packageTypes316 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 }))326327 console.log('Processed packageTypes:', processedPackageTypes)328329 // Create both post and estimate330 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 data339 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: 1363 }364 }365 }366367 // Debug: Log the complete postData being sent368 console.log('Complete postData being sent:', JSON.stringify(postData, null, 2))369370 // Comprehensive validation of all form data371 console.log('Full form data:', formData)372373 // Clean and validate all string fields374 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()379380 // Validate all fields for potential JSON issues381 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 }386387 validateField(cleanTitle, 'title')388 validateField(cleanContent, 'content')389 validateField(cleanMetaTitle, 'meta.title')390 validateField(cleanMetaDescription, 'meta.description')391 validateField(cleanMetaImage, 'meta.image')392393 // Validate data before sending394 if (!cleanTitle) {395 throw new Error('Title cannot be empty')396 }397398 if (formData.categories.some(catId => !catId || typeof catId !== 'string')) {399 throw new Error('Invalid category IDs detected')400 }401402 // Validate JSON structure before sending403 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 }409410 const response = await fetch('/api/posts', {411 method: 'POST',412 headers: {413 'Content-Type': 'application/json',414 },415 body: JSON.stringify(postData),416 })417418 if (!response.ok) {419 const errorData = await response.json()420 throw new Error(errorData.message || 'Failed to create post')421 }422423 const result = await response.json()424425 // Create associated estimate with package types426 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 }))434435 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 }443444 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 }456457 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 }467468 const handleEditPost = async () => {469 if (!editingPost?.id || !formData.title.trim()) {470 setError('Title is required')471 return472 }473474 setLoading(true)475 clearMessages()476477 try {478 // Comprehensive validation of all form data479 console.log('Full form data:', formData)480481 // Clean and validate all string fields482 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()487488 // Validate all fields for potential JSON issues489 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 }494495 validateField(cleanTitle, 'title')496 validateField(cleanContent, 'content')497 validateField(cleanMetaTitle, 'meta.title')498 validateField(cleanMetaDescription, 'meta.description')499 validateField(cleanMetaImage, 'meta.image')500501 // Validate data before sending502 if (!cleanTitle) {503 throw new Error('Title cannot be empty')504 }505506 if (formData.categories.some(catId => !catId || typeof catId !== 'string')) {507 throw new Error('Invalid category IDs detected')508 }509510 // Safely extract hero image ID511 const getImageId = (image: any): string | undefined => {512 if (!image) return undefined513 if (typeof image === 'string') return image514 if (typeof image === 'object' && image.id) return image.id515 return undefined516 }517518 // Safely handle published date519 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 string525 const date = new Date(editingPost.publishedAt)526 return isNaN(date.getTime()) ? undefined : date.toISOString()527 }528 return undefined529 }530531 const heroImageId = uploadedImages.length > 0 && uploadedImages[0]532 ? uploadedImages[0].id533 : getImageId(editingPost.heroImage)534535 const metaImageId = formData.meta.image ||536 (uploadedImages.length > 0 && uploadedImages[0] ? uploadedImages[0].id : getImageId(editingPost.heroImage))537538 // Safely handle baseRate to prevent JSON parsing errors539 const getValidBaseRate = (): number | undefined => {540 if (formData.baseRate === '' || formData.baseRate === null || formData.baseRate === undefined) {541 return undefined542 }543 const numValue = Number(formData.baseRate)544 return !isNaN(numValue) && numValue >= 0 ? numValue : undefined545 }546547 const postData: any = {548 title: cleanTitle,549 content: [550 {551 children: [552 {553 text: cleanContent554 }555 ],556 direction: 'ltr',557 format: '',558 indent: 0,559 type: 'paragraph',560 version: 1561 }562 ],563 meta: {564 title: cleanMetaTitle,565 description: cleanMetaDescription,566 image: cleanMetaImage567 },568 categories: formData.categories.filter(cat => cat && typeof cat === 'string'),569 _status: formData._status,570 // Add packageTypes to the post data571 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 }582583 // Only add baseRate if it's a valid number584 const validBaseRate = getValidBaseRate()585 if (validBaseRate !== undefined) {586 postData.baseRate = validBaseRate587 }588589 // Add other optional fields590 if (heroImageId) {591 postData.heroImage = heroImageId592 }593594 const publishedAt = getPublishedAt()595 if (publishedAt) {596 postData.publishedAt = publishedAt597 }598599 // Validate JSON structure before sending600 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 }606607 // Log the data being sent for debugging608 console.log('Sending PATCH data:', JSON.stringify(postData, null, 2))609610 // Debug: Log the raw JSON string that will be sent611 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))615616 // Check for potential problematic values617 console.log('Form data baseRate:', formData.baseRate, typeof formData.baseRate)618 console.log('Computed baseRate:', getValidBaseRate())619620 const response = await fetch(`/api/posts/${editingPost.id}`, {621 method: 'PATCH',622 headers: {623 'Content-Type': 'application/json',624 },625 body: jsonString,626 })627628 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 }633634 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 }647648 const handleDeletePost = async () => {649 if (!deletingPost?.id) return650651 setLoading(true)652 clearMessages()653654 try {655 const response = await fetch(`/api/posts/${deletingPost.id}`, {656 method: 'DELETE',657 })658659 if (!response.ok) {660 throw new Error('Failed to delete post')661 }662663 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 }673674 const openCreateDialog = () => {675 resetForm()676 setIsCreateDialogOpen(true)677 }678679 const openEditDialog = (post: Post) => {680 setEditingPost(post)681682 // Safely extract base rate683 const getBaseRate = (): number | '' => {684 if (typeof post.baseRate === 'number' && !isNaN(post.baseRate)) {685 return post.baseRate686 }687 return ''688 }689690 // Safely extract package types from post or use default691 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 }706707 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 }723724 const openDeleteDialog = (post: Post) => {725 setDeletingPost(post)726 setIsDeleteDialogOpen(true)727 }728729 // Helper function to extract text from rich text content730 const extractTextFromContent = (content: any): string => {731 if (!content) return ''732 if (typeof content === 'string') return content733734 try {735 const traverse = (node: any): string => {736 if (!node) return ''737 if (typeof node === 'string') return node738 if (node.text) return node.text739 if (node.children && Array.isArray(node.children)) {740 return node.children.map(traverse).join('')741 }742 return ''743 }744745 if (content.root) {746 return traverse(content.root)747 }748 return traverse(content)749 } catch {750 return ''751 }752 }753754 const getPostStatusBadge = (post: Post) => {755 const status = post._status756 const isPublished = status === 'published' && post.publishedAt757758 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 }764765 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 }775776 // Memoized filtered posts for performance777 const publishedPosts = useMemo(() =>778 posts.filter(post => post._status === 'published' && post.publishedAt),779 [posts]780 )781782 const draftPosts = useMemo(() =>783 posts.filter(post => post._status !== 'published' || !post.publishedAt),784 [posts]785 )786787 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 }796797 // Fetch total visitors on component mount798 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 }, [])806807 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 <Button817 variant="outline"818 className="gap-2"819 onClick={() => setIsViewerDialogOpen(true)}820 >821 <Code className="h-4 w-4" />822 Browse Pleks Embed823 </Button>824 <Button onClick={openCreateDialog} className="gap-2">825 <Plus className="h-4 w-4" />826 Create New Plek827 </Button>828 </div>829 </div>830831 {/* 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 )}840841 {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 )}849850 {/* 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>864865 <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>874875 <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>885886 {/* 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>893894 <TabsContent value="all" className="space-y-4">895 <PostsList896 posts={posts}897 onEdit={openEditDialog}898 onDelete={openDeleteDialog}899 onCreateNew={openCreateDialog}900 getPostStatusBadge={getPostStatusBadge}901 formatDate={formatDate}902 extractTextFromContent={extractTextFromContent}903 />904 </TabsContent>905906 <TabsContent value="published" className="space-y-4">907 <PostsList908 posts={publishedPosts}909 onEdit={openEditDialog}910 onDelete={openDeleteDialog}911 onCreateNew={openCreateDialog}912 getPostStatusBadge={getPostStatusBadge}913 formatDate={formatDate}914 extractTextFromContent={extractTextFromContent}915 />916 </TabsContent>917918 <TabsContent value="drafts" className="space-y-4">919 <PostsList920 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>930931 {/* 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>940941 <PostForm942 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 />955956 <DialogFooter>957 <Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>958 Cancel959 </Button>960 <Button onClick={handleCreatePost} disabled={loading}>961 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}962 Create Plek963 </Button>964 </DialogFooter>965 </DialogContent>966 </Dialog>967968 {/* 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>977978 <PostForm979 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 />993994 <DialogFooter>995 <Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>996 Cancel997 </Button>998 <Button onClick={handleEditPost} disabled={loading}>999 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}1000 Update Plek1001 </Button>1002 </DialogFooter>1003 </DialogContent>1004 </Dialog>10051006 {/* 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 Cancel1018 </Button>1019 <Button variant="destructive" onClick={handleDeletePost} disabled={loading}>1020 {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}1021 Delete Post1022 </Button>1023 </DialogFooter>1024 </DialogContent>1025 </Dialog>10261027 {/* 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 Code1034 </DialogTitle>1035 <DialogDescription>1036 Copy these code snippets to embed the Plek viewer in third-party websites.1037 </DialogDescription>1038 </DialogHeader>10391040 <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{`<iframe1047 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 <Button1055 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>10641065 {/* 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 <iframe1072 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 <Button1079 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>10881089 {/* 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": 11113 }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 <Button1127 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": 11149 }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>11671168 <DialogFooter>1169 <Button variant="outline" onClick={() => setIsViewerDialogOpen(false)}>1170 Close1171 </Button>1172 <Button asChild>1173 <Link href="/embed/plek-viewer" target="_blank">1174 Preview Embed1175 </Link>1176 </Button>1177 </DialogFooter>1178 </DialogContent>1179 </Dialog>1180 </div>1181 )1182}11831184// Enhanced PostsList with analytics1185function PostsList({1186 posts,1187 onEdit,1188 onDelete,1189 onCreateNew,1190 getPostStatusBadge,1191 formatDate,1192 extractTextFromContent1193}: {1194 posts: Post[]1195 onEdit: (post: Post) => void1196 onDelete: (post: Post) => void1197 onCreateNew: () => void1198 getPostStatusBadge: (post: Post) => React.JSX.Element1199 formatDate: (dateString: string | undefined) => string1200 extractTextFromContent: (content: any) => string1201}) {1202 const [analyticsData, setAnalyticsData] = useState<Record<string, { views: number; users: number; sessions: number }>>({})12031204 useEffect(() => {1205 // Fetch analytics data for inline display1206 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.sessions1215 }1216 })1217 setAnalyticsData(analyticsMap)1218 })1219 .catch(err => console.error('Failed to fetch inline analytics:', err))1220 }, [])12211222 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 plek1230 </Button>1231 </CardContent>1232 </Card>1233 )1234 }12351236 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} views1252 </Badge>1253 )}1254 </div>12551256 <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>12701271 {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 )}12801281 <div className="text-sm text-muted-foreground">1282 {extractTextFromContent(post.content).slice(0, 150)}1283 {extractTextFromContent(post.content).length > 150 && '...'}1284 </div>1285 </div>12861287 <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 plek1301 </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 Post1309 </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 Post1315 </Button>1316 <Button variant="destructive" className="justify-start gap-2" onClick={() => onDelete(post)}>1317 <Trash2 className="h-4 w-4" />1318 Delete Post1319 </Button>1320 </div>1321 </SheetContent>1322 </Sheet>1323 </div>13241325 {/* 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}13501351function 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 = false1365}: {1366 formData: PostFormData1367 setFormData: (data: PostFormData) => void1368 updateFormData: (updates: Partial<PostFormData>) => void1369 categories: Category[]1370 uploadedImages: UploadedFile[]1371 uploading: boolean1372 onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void1373 onRemoveImage: (imageId: string) => void1374 onPackageChange: (idx: number, field: keyof PackageType, value: string | number | string[]) => void1375 onAddPackageType: () => void1376 onRemovePackageType: (idx: number) => void1377 onAddPackageTemplate: (templateKey: string) => void1378 isEditing?: boolean1379}) {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 <Input1387 id="title"1388 value={formData.title}1389 onChange={(e) => updateFormData({ title: e.target.value })}1390 placeholder="Enter plek title..."1391 />1392 </div>13931394 <div className="space-y-2">1395 <Label htmlFor="baseRate">Base Rate (per night) *</Label>1396 <Input1397 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>14051406 <div className="space-y-2">1407 <Label>Categories</Label>1408 <Popover>1409 <PopoverTrigger asChild>1410 <Button1411 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 ) : null1429 })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} more1437 </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 available1453 </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 <Checkbox1458 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 <label1473 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} selected1490 </span>1491 <Button1492 variant="ghost"1493 size="sm"1494 onClick={() => updateFormData({ categories: [] })}1495 className="h-6 text-xs"1496 >1497 Clear all1498 </Button>1499 </div>1500 </div>1501 )}1502 </PopoverContent>1503 </Popover>1504 </div>15051506 <div className="flex items-center space-x-2">1507 <Switch1508 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>15191520 <div className="space-y-4">1521 <div className="space-y-2">1522 <Label htmlFor="content">Content *</Label>1523 <Textarea1524 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>15311532 <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 <input1536 type="file"1537 accept="image/*"1538 onChange={onImageUpload}1539 disabled={uploading}1540 className="mb-2"1541 multiple1542 />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 <img1554 src={image.url}1555 alt={image.filename}1556 className="w-full h-16 object-cover rounded"1557 />1558 <Button1559 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>15751576 {/* 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 Package1583 </Button>1584 </div>15851586 {/* 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 <Button1592 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>16051606 {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 <Button1612 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>16211622 <div className="grid grid-cols-1 md:grid-cols-3 gap-3">1623 <Input1624 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 <Input1631 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 <Input1640 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 <Input1650 placeholder="RevenueCat ID"1651 value={pkg.revenueCatId}1652 onChange={(e) => onPackageChange(idx, 'revenueCatId', e.target.value)}1653 />1654 </div>16551656 <Textarea1657 placeholder="Package description"1658 value={pkg.description}1659 onChange={(e) => onPackageChange(idx, 'description', e.target.value)}1660 rows={2}1661 />16621663 <div className="space-y-2">1664 <Label className="text-sm">Features</Label>1665 <Textarea1666 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>16761677 {/* 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>16831684 <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>16901691 <div className="grid grid-cols-1 gap-4">1692 <div className="space-y-2">1693 <Label htmlFor="metaTitle">SEO Title</Label>1694 <Input1695 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>17061707 <div className="space-y-2">1708 <Label htmlFor="metaDescription">SEO Description</Label>1709 <Textarea1710 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>17261727 <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 <img1733 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>17461747 {/* 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<iframe2 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>
