From bdded6122158f383008aad05238fabddaee867b2 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sun, 11 Jan 2026 22:44:00 +0700 Subject: [PATCH] feat: Page Editor Phase 2 - Admin UI - Add AppearancePages component with 3-column layout - Add PageSidebar for listing structural pages and CPT templates - Add SectionEditor with add/delete/reorder functionality - Add PageSettings with layout/color scheme and static/dynamic toggle - Add CreatePageModal for creating new structural pages - Add route at /appearance/pages in admin App.tsx - Build admin-spa successfully --- admin-spa/src/App.tsx | 2 + .../Pages/components/CreatePageModal.tsx | 159 ++++++++ .../Pages/components/PageSettings.tsx | 349 ++++++++++++++++++ .../Pages/components/PageSidebar.tsx | 99 +++++ .../Pages/components/SectionEditor.tsx | 189 ++++++++++ .../src/routes/Appearance/Pages/index.tsx | 253 +++++++++++++ 6 files changed, 1051 insertions(+) create mode 100644 admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/PageSettings.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/SectionEditor.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/index.tsx diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index ee2a8be..aa2cc89 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -261,6 +261,7 @@ import AppearanceCart from '@/routes/Appearance/Cart'; import AppearanceCheckout from '@/routes/Appearance/Checkout'; import AppearanceThankYou from '@/routes/Appearance/ThankYou'; import AppearanceAccount from '@/routes/Appearance/Account'; +import AppearancePages from '@/routes/Appearance/Pages'; import MarketingIndex from '@/routes/Marketing'; import Newsletter from '@/routes/Marketing/Newsletter'; import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; @@ -608,6 +609,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Marketing */} } /> diff --git a/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx new file mode 100644 index 0000000..a12607e --- /dev/null +++ b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { __ } from '@/lib/i18n'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; +import { FileText, Layout } from 'lucide-react'; + +interface PageItem { + id?: number; + type: 'page' | 'template'; + cpt?: string; + slug?: string; + title: string; +} + +interface CreatePageModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (page: PageItem) => void; +} + +export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) { + const [pageType, setPageType] = useState<'page' | 'template'>('page'); + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + + // Create page mutation + const createMutation = useMutation({ + mutationFn: async () => { + if (pageType === 'page') { + const response = await api.post('/pages', { title, slug }); + return response.data; + } + // For templates, we don't create them - they're auto-created for each CPT + return null; + }, + onSuccess: (data) => { + if (data?.page) { + toast.success(__('Page created successfully')); + onCreated({ + id: data.page.id, + type: 'page', + slug: data.page.slug, + title: data.page.title, + }); + onOpenChange(false); + setTitle(''); + setSlug(''); + } + }, + onError: (error: any) => { + toast.error(error?.message || __('Failed to create page')); + }, + }); + + // Auto-generate slug from title + const handleTitleChange = (value: string) => { + setTitle(value); + if (!slug || slug === title.toLowerCase().replace(/\s+/g, '-')) { + setSlug(value.toLowerCase().replace(/\s+/g, '-')); + } + }; + + return ( + + + + {__('Create New Page')} + + {__('Choose what type of page you want to create.')} + + + +
+ {/* Page Type Selection */} + setPageType(v as 'page' | 'template')}> +
+ +
+ +

+ {__('Static content like About, Contact, Terms')} +

+
+
+ +
+ +
+ +

+ {__('Templates are auto-created for each post type')} +

+
+
+
+ + {/* Page Details */} + {pageType === 'page' && ( +
+
+ + handleTitleChange(e.target.value)} + placeholder={__('e.g., About Us')} + /> +
+ +
+ + setSlug(e.target.value)} + placeholder={__('e.g., about-us')} + /> +

+ {__('URL will be: ')}yoursite.com/{slug || 'page-slug'} +

+
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/admin-spa/src/routes/Appearance/Pages/components/PageSettings.tsx b/admin-spa/src/routes/Appearance/Pages/components/PageSettings.tsx new file mode 100644 index 0000000..eb76fad --- /dev/null +++ b/admin-spa/src/routes/Appearance/Pages/components/PageSettings.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import { __ } from '@/lib/i18n'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Settings, Eye, Smartphone, Monitor, ExternalLink } from 'lucide-react'; + +interface Section { + id: string; + type: string; + layoutVariant?: string; + colorScheme?: string; + props: Record; +} + +interface PageItem { + id?: number; + type: 'page' | 'template'; + cpt?: string; + slug?: string; + title: string; + url?: string; +} + +interface AvailableSource { + value: string; + label: string; +} + +interface PageSettingsProps { + page: PageItem | null; + section: Section | null; + onSectionUpdate: (section: Section) => void; + isTemplate?: boolean; + availableSources?: AvailableSource[]; +} + +// Section field configs +const SECTION_FIELDS: Record = { + hero: [ + { name: 'title', type: 'text', dynamic: true }, + { name: 'subtitle', type: 'text', dynamic: true }, + { name: 'image', type: 'image', dynamic: true }, + { name: 'cta_text', type: 'text' }, + { name: 'cta_url', type: 'url' }, + ], + content: [ + { name: 'content', type: 'textarea', dynamic: true }, + ], + 'image-text': [ + { name: 'title', type: 'text', dynamic: true }, + { name: 'text', type: 'textarea', dynamic: true }, + { name: 'image', type: 'image', dynamic: true }, + ], + 'feature-grid': [ + { name: 'heading', type: 'text' }, + ], + 'cta-banner': [ + { name: 'title', type: 'text' }, + { name: 'text', type: 'text' }, + { name: 'button_text', type: 'text' }, + { name: 'button_url', type: 'url' }, + ], + 'contact-form': [ + { name: 'title', type: 'text' }, + { name: 'webhook_url', type: 'url' }, + { name: 'redirect_url', type: 'url' }, + ], +}; + +const LAYOUT_OPTIONS: Record = { + hero: [ + { value: 'default', label: 'Centered' }, + { value: 'hero-left-image', label: 'Image Left' }, + { value: 'hero-right-image', label: 'Image Right' }, + ], + 'image-text': [ + { value: 'image-left', label: 'Image Left' }, + { value: 'image-right', label: 'Image Right' }, + ], + 'feature-grid': [ + { value: 'grid-2', label: '2 Columns' }, + { value: 'grid-3', label: '3 Columns' }, + { value: 'grid-4', label: '4 Columns' }, + ], + content: [ + { value: 'default', label: 'Full Width' }, + { value: 'narrow', label: 'Narrow' }, + { value: 'medium', label: 'Medium' }, + ], +}; + +const COLOR_SCHEMES = [ + { value: 'default', label: 'Default' }, + { value: 'primary', label: 'Primary' }, + { value: 'secondary', label: 'Secondary' }, + { value: 'muted', label: 'Muted' }, + { value: 'gradient', label: 'Gradient' }, +]; + +export function PageSettings({ + page, + section, + onSectionUpdate, + isTemplate = false, + availableSources = [], +}: PageSettingsProps) { + const [previewMode, setPreviewMode] = React.useState<'desktop' | 'mobile'>('desktop'); + + // Update section prop + const updateProp = (name: string, value: any, isDynamic?: boolean) => { + if (!section) return; + + const newProps = { ...section.props }; + if (isDynamic) { + newProps[name] = { type: 'dynamic', source: value }; + } else { + newProps[name] = { type: 'static', value }; + } + + onSectionUpdate({ ...section, props: newProps }); + }; + + // Get prop value + const getPropValue = (name: string): string => { + const prop = section?.props[name]; + if (!prop) return ''; + if (typeof prop === 'object') { + return prop.type === 'dynamic' ? prop.source : prop.value || ''; + } + return String(prop); + }; + + // Check if prop is dynamic + const isPropDynamic = (name: string): boolean => { + const prop = section?.props[name]; + return typeof prop === 'object' && prop?.type === 'dynamic'; + }; + + // Render field based on type + const renderField = (field: { name: string; type: string; dynamic?: boolean }) => { + const value = getPropValue(field.name); + const isDynamic = isPropDynamic(field.name); + const fieldLabel = field.name.charAt(0).toUpperCase() + field.name.slice(1).replace('_', ' '); + + return ( +
+
+ + {field.dynamic && isTemplate && ( +
+ + {isDynamic ? '◆ Dynamic' : 'Static'} + + { + if (checked) { + updateProp(field.name, 'post_title', true); + } else { + updateProp(field.name, '', false); + } + }} + /> +
+ )} +
+ + {isDynamic && isTemplate ? ( + + ) : field.type === 'textarea' ? ( +