diff --git a/admin-spa/src/routes/Appearance/General.tsx b/admin-spa/src/routes/Appearance/General.tsx index d3666cf..2a1b73e 100644 --- a/admin-spa/src/routes/Appearance/General.tsx +++ b/admin-spa/src/routes/Appearance/General.tsx @@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Slider } from '@/components/ui/slider'; +import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -31,6 +32,7 @@ export default function AppearanceGeneral() { const [customBody, setCustomBody] = useState(''); const [fontScale, setFontScale] = useState([1.0]); const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed'); + const [hideAdminBar, setHideAdminBar] = useState(true); const fontPairs = { modern: { name: 'Modern & Clean', fonts: 'Inter' }, @@ -70,6 +72,9 @@ export default function AppearanceGeneral() { if (general.container_width) { setContainerWidth(general.container_width); } + if (general.hide_admin_bar !== undefined) { + setHideAdminBar(!!general.hide_admin_bar); + } if (general.colors) { setColors({ primary: general.colors.primary || '#1a1a1a', @@ -116,6 +121,7 @@ export default function AppearanceGeneral() { scale: fontScale[0], }, containerWidth, + hideAdminBar, colors, }); @@ -176,6 +182,28 @@ export default function AppearanceGeneral() { + {/* Admin Bar */} + +
+
+ +

+ Hides the WordPress admin bar for all users when visiting your store +

+
+ +
+
+ {/* SPA Page */} {/* Section content with Styles */}
)} + {/* Dynamic background placeholder (Featured Image) */} + {section.styles?.backgroundType === 'image' + && section.styles?.dynamicBackground === 'post_featured_image' + && !section.styles?.backgroundImage && ( + <> +
+
+
+ + + + + Featured Image +
+
+ {/* Overlay preview */} +
+ + )} + {/* Content Wrapper */} -
- {children} -
+ {section.styles?.contentWidth === 'boxed' ? ( +
+
+ {children} +
+
+ ) : ( +
+ {children} +
+ )}
{/* Floating Toolbar (Standard Interaction) */} diff --git a/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx index 141bd29..d0f9422 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/CreatePageModal.tsx @@ -5,7 +5,6 @@ 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, @@ -14,8 +13,9 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from 'sonner'; -import { FileText, Layout, Loader2 } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; interface PageItem { id?: number; @@ -23,83 +23,119 @@ interface PageItem { cpt?: string; slug?: string; title: string; + has_template?: boolean; } interface CreatePageModalProps { open: boolean; onOpenChange: (open: boolean) => void; + cptList?: PageItem[]; onCreated: (page: PageItem) => void; } -export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) { - const [pageType, setPageType] = useState<'page' | 'template'>('page'); +export function CreatePageModal({ open, onOpenChange, cptList = [], onCreated }: CreatePageModalProps) { + const [mode, setMode] = useState<'page' | 'template'>('page'); + + // Structural page state const [title, setTitle] = useState(''); const [slug, setSlug] = useState(''); const [selectedTemplateId, setSelectedTemplateId] = useState('blank'); + // CPT template state + const [selectedCpt, setSelectedCpt] = useState(''); + const [selectedCptPreset, setSelectedCptPreset] = useState('single-post'); + // Prevent double submission const isSubmittingRef = useRef(false); - // Get site URL from WordPress config const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin; - // Fetch templates + // Fetch template presets const { data: templates = [] } = useQuery({ queryKey: ['templates-presets'], queryFn: async () => { const res = await api.get('/templates/presets'); - return res as { id: string; label: string; description: string; icon: string }[]; + return res as { id: string; label: string; description: string; icon: string; sections?: any }[]; } }); - // Create page mutation - const createMutation = useMutation({ - mutationFn: async (data: { title: string; slug: string; templateId?: string }) => { - // Guard against double submission - if (isSubmittingRef.current) { - throw new Error('Request already in progress'); - } - isSubmittingRef.current = true; + // CPTs that don't have a template yet + const availableCpts = cptList.filter(p => p.type === 'template' && !p.has_template); + // Set default CPT when list loads + useEffect(() => { + if (availableCpts.length > 0 && !selectedCpt) { + setSelectedCpt(availableCpts[0].cpt || ''); + } + }, [availableCpts, selectedCpt]); + + // Create structural page mutation + const createPageMutation = useMutation({ + mutationFn: async (data: { title: string; slug: string; templateId: string }) => { + if (isSubmittingRef.current) throw new Error('Request already in progress'); + isSubmittingRef.current = true; try { - // api.post returns JSON directly (not wrapped in { data: ... }) const response = await api.post('/pages', { title: data.title, slug: data.slug, - templateId: data.templateId + templateId: data.templateId, }); - return response; // Return response directly, not response.data + return { type: 'page' as const, data: response }; } finally { - // Reset after a delay to prevent race conditions - setTimeout(() => { - isSubmittingRef.current = false; - }, 500); + setTimeout(() => { isSubmittingRef.current = false; }, 500); } }, - onSuccess: (data) => { - if (data?.page) { + onSuccess: (result) => { + if (result?.data?.page) { toast.success(__('Page created successfully')); onCreated({ - id: data.page.id, - type: 'page', - slug: data.page.slug, - title: data.page.title, + id: result.data.page.id, + type: result.type, + slug: result.data.page.slug, + title: result.data.page.title, }); onOpenChange(false); - setTitle(''); - setSlug(''); - setSelectedTemplateId('blank'); } }, onError: (error: any) => { - // Don't show error for duplicate prevention - if (error?.message === 'Request already in progress') { - return; + if (error?.message === 'Request already in progress') return; + const message = error?.response?.data?.message || error?.message || __('Failed to create page'); + toast.error(message); + }, + }); + + // Create CPT template mutation + const createTemplateMutation = useMutation({ + mutationFn: async (data: { cpt: string; presetId: string }) => { + if (isSubmittingRef.current) throw new Error('Request already in progress'); + isSubmittingRef.current = true; + try { + // Get preset sections + const presets = templates as any[]; + const preset = presets.find((t: any) => t.id === data.presetId); + const sections = preset?.sections || []; + + const response = await api.post(`/templates/${data.cpt}`, { sections }); + return { cpt: data.cpt, data: response }; + } finally { + setTimeout(() => { isSubmittingRef.current = false; }, 500); } - // Extract error message from the response - const message = error?.response?.data?.message || - error?.message || - __('Failed to create page'); + }, + onSuccess: (result) => { + toast.success(__('Template created successfully')); + // Find the CPT item from the list to pass back + const cptItem = cptList.find(p => p.cpt === result.cpt); + onCreated({ + type: 'template', + cpt: result.cpt, + title: cptItem?.title || `${result.cpt} Template`, + has_template: true, + }); + onOpenChange(false); + }, + onError: (error: any) => { + if (error?.message === 'Request already in progress') return; + const message = error?.response?.data?.message || error?.message || __('Failed to create template'); toast.error(message); }, }); @@ -107,35 +143,48 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod // Auto-generate slug from title const handleTitleChange = (value: string) => { setTitle(value); - // Auto-generate slug only if slug matches the previously auto-generated value const autoSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); if (!slug || slug === autoSlug) { setSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')); } }; - // Handle form submission - const handleSubmit = () => { - if (createMutation.isPending || isSubmittingRef.current) { - return; - } - if (pageType === 'page' && title && slug) { - createMutation.mutate({ title, slug, templateId: selectedTemplateId }); - } - }; - // Reset form when modal closes useEffect(() => { if (!open) { + setMode('page'); setTitle(''); setSlug(''); - setPageType('page'); setSelectedTemplateId('blank'); + setSelectedCpt(''); + setSelectedCptPreset('single-post'); isSubmittingRef.current = false; } }, [open]); - const isDisabled = pageType === 'page' && (!title || !slug) || createMutation.isPending || isSubmittingRef.current; + const handleSubmit = () => { + if (isSubmittingRef.current) return; + + if (mode === 'page') { + if (title && slug) { + createPageMutation.mutate({ title, slug, templateId: selectedTemplateId }); + } + } else { + if (selectedCpt) { + createTemplateMutation.mutate({ cpt: selectedCpt, presetId: selectedCptPreset }); + } + } + }; + + const isPending = createPageMutation.isPending || createTemplateMutation.isPending; + const isPageDisabled = !title || !slug || isPending; + const isTemplateDisabled = !selectedCpt || isPending; + const isDisabled = mode === 'page' ? isPageDisabled : isTemplateDisabled; + + // Page layout presets (exclude single-post — it's for CPT) + const pagePresets = templates.filter((tpl: any) => tpl.id !== 'single-post'); + // CPT presets (include ALL — user can pick any layout) + const cptPresets = templates as any[]; return ( @@ -147,42 +196,15 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod -
- {/* Page Type Selection */} - setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4"> -
setPageType('page')} - > - -
- -

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

-
-
+
+ setMode(v as 'page' | 'template')}> + + {__('Structural Page')} + {__('CPT Template')} + -
- -
- -

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

-
-
- - - {/* Page Details */} - {pageType === 'page' && ( -
+ {/* ── Structural Page Tab ── */} +
@@ -191,7 +213,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod value={title} onChange={(e) => handleTitleChange(e.target.value)} placeholder={__('e.g., About Us')} - disabled={createMutation.isPending} + disabled={isPending} />
@@ -201,7 +223,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod value={slug} onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} placeholder={__('e.g., about-us')} - disabled={createMutation.isPending} + disabled={isPending} />

{siteUrl}/{slug || 'page'} @@ -210,9 +232,9 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod

- +
- {templates.map((tpl) => ( + {pagePresets.map((tpl: any) => (
setSelectedTemplateId(tpl.id)} > -
- {tpl.label} -
-

- {tpl.description} -

+
{tpl.label}
+

{tpl.description}

))} - {templates.length === 0 && ( -
- {__('Loading templates...')} -
- )}
-
- )} +
+ + {/* ── CPT Template Tab ── */} + + {availableCpts.length === 0 ? ( +
+

{__('All post types already have a template.')}

+

{__('Abort an existing template first to create a new one.')}

+
+ ) : ( + <> +
+ +
+ {availableCpts.map((cpt) => ( +
setSelectedCpt(cpt.cpt || '')} + > +
{cpt.title}
+ {cpt.cpt && ( +

/{cpt.cpt}/

+ )} +
+ ))} +
+
+ +
+ +
+ {cptPresets.map((tpl: any) => ( +
setSelectedCptPreset(tpl.id)} + > +
{tpl.label}
+

{tpl.description}

+
+ ))} +
+
+ + )} +
+
- - diff --git a/admin-spa/src/routes/Appearance/Pages/components/InspectorField.tsx b/admin-spa/src/routes/Appearance/Pages/components/InspectorField.tsx index 4053606..15cb68c 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/InspectorField.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/InspectorField.tsx @@ -54,6 +54,10 @@ export function InspectorField({ } }; + const handleSelectChange = (val: string) => { + onChange({ type: 'dynamic', source: val }); + }; + const handleTypeToggle = (dynamic: boolean) => { if (dynamic) { onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' }); @@ -85,18 +89,20 @@ export function InspectorField({
{isDynamic && supportsDynamic ? ( - +
+ +
) : fieldType === 'rte' ? ( void; onUnsetSpaLanding?: () => void; onDeletePage?: () => void; + onDeleteTemplate?: () => void; onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void; } @@ -127,7 +128,6 @@ const COLOR_SCHEMES = [ { value: 'primary', label: 'Primary' }, { value: 'secondary', label: 'Secondary' }, { value: 'muted', label: 'Muted' }, - { value: 'gradient', label: 'Gradient' }, ]; const STYLABLE_ELEMENTS: Record = { @@ -183,6 +183,7 @@ export function InspectorPanel({ onSetAsSpaLanding, onUnsetSpaLanding, onDeletePage, + onDeleteTemplate, onContainerWidthChange, }: InspectorPanelProps) { if (isCollapsed) { @@ -306,6 +307,25 @@ export function InspectorPanel({
)} + + {/* Danger Zone - Templates */} + {isTemplate && page && onDeleteTemplate && ( +
+ +

+ {__('Deleting this template will disable SPA rendering for this post type. WordPress will handle it natively.')} +

+ +
+ )}
{__('Select any section on the canvas to edit its content and design.')} @@ -433,21 +453,32 @@ export function InspectorPanel({
{/* Feature Grid Repeater */} - {selectedSection.type === 'feature-grid' && ( -
- onSectionPropChange('features', { type: 'static', value: items })} - fields={[ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'description', label: 'Description', type: 'textarea' }, - { name: 'icon', label: 'Icon', type: 'icon' }, - ]} - itemLabelKey="title" - /> -
- )} + {selectedSection.type === 'feature-grid' && (() => { + const featuresProp = selectedSection.props.features; + const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source; + const items = Array.isArray(featuresProp?.value) ? featuresProp.value : []; + return ( +
+ onSectionPropChange('features', { type: 'static', value: newItems })} + fields={[ + { name: 'title', label: 'Title', type: 'text' }, + { name: 'description', label: 'Description', type: 'textarea' }, + { name: 'icon', label: 'Icon', type: 'icon' }, + ]} + itemLabelKey="title" + isDynamic={isDynamicFeatures} + dynamicLabel={ + isDynamicFeatures + ? `⚡ Auto-populated from "${featuresProp.source}" at runtime` + : undefined + } + /> +
+ ); + })()} {/* Design Tab */} @@ -571,48 +602,90 @@ export function InspectorPanel({ )} {/* Image Background */} - {selectedSection.styles?.backgroundType === 'image' && ( - <> -
- - onSectionStylesChange({ backgroundImage: url })}> - {selectedSection.styles?.backgroundImage ? ( -
- Background -
- {__('Change')} -
- -
- ) : ( - - )} -
-
- -
-
- - {selectedSection.styles?.backgroundOverlay ?? 0}% + {selectedSection.styles?.backgroundType === 'image' && (() => { + const isDynamicBg = selectedSection.styles?.dynamicBackground === 'post_featured_image'; + return ( + <> + {/* Source toggle: Upload vs Featured Image */} +
+ +
+ + +
- onSectionStylesChange({ backgroundOverlay: vals[0] })} - /> -
- - )} + + {/* Static upload */} + {!isDynamicBg && ( +
+ onSectionStylesChange({ backgroundImage: url })}> + {selectedSection.styles?.backgroundImage ? ( +
+ Background +
+ {__('Change')} +
+ +
+ ) : ( + + )} +
+
+ )} + + {/* Dynamic source info */} + {isDynamicBg && ( +
+ + At runtime, the background will use this post's featured image. Falls back to no background if no featured image is set. +
+ )} + +
+
+ + {selectedSection.styles?.backgroundOverlay ?? 0}% +
+ onSectionStylesChange({ backgroundOverlay: vals[0] })} + /> +
+ + ); + })()} {/* Spacing Controls */}
@@ -643,7 +716,7 @@ export function InspectorPanel({ onSectionStylesChange({ contentWidth: val })} - className="flex gap-4" + className="flex flex-wrap gap-4" >
@@ -653,6 +726,10 @@ export function InspectorPanel({
+
+ + +
diff --git a/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx b/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx index 04b1bb8..3ba2703 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx @@ -49,6 +49,8 @@ interface InspectorRepeaterProps { fields: RepeaterFieldDef[]; onChange: (items: any[]) => void; itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title') + isDynamic?: boolean; // If true, items come from a dynamic source — hide Add Item + dynamicLabel?: string; // Custom label for the dynamic placeholder } // Sortable Item Component @@ -148,7 +150,7 @@ function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelet ); } -export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) { +export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title', isDynamic = false, dynamicLabel }: InspectorRepeaterProps) { // Generate simple stable IDs for sorting if items don't have them const itemIds = items.map((_, i) => `item-${i}`); @@ -191,10 +193,12 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab
- + {!isDynamic && ( + + )}
@@ -224,8 +228,15 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab {items.length === 0 && ( -
- No items yet. Click "Add Item" to start. +
+ {isDynamic + ? (dynamicLabel || '⚡ Auto-populated from related posts at runtime') + : 'No items yet. Click "Add Item" to start.'}
)}
diff --git a/admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx b/admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx index 40be794..46098c8 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/PageSidebar.tsx @@ -14,7 +14,7 @@ interface PageSidebarProps { export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) { const structuralPages = pages.filter(p => p.type === 'page'); - const templates = pages.filter(p => p.type === 'template'); + const templates = pages.filter(p => p.type === 'template' && p.has_template); if (isLoading) { return ( @@ -69,24 +69,28 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa {__('Templates')}
- {templates.map((template) => ( - - ))} + {templates.length === 0 ? ( +

{__('No templates yet')}

+ ) : ( + templates.map((template) => ( + + )) + )}
diff --git a/admin-spa/src/routes/Appearance/Pages/components/section-renderers/CTABannerRenderer.tsx b/admin-spa/src/routes/Appearance/Pages/components/section-renderers/CTABannerRenderer.tsx index 5ef2346..2e6807d 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/section-renderers/CTABannerRenderer.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/section-renderers/CTABannerRenderer.tsx @@ -9,6 +9,7 @@ interface Section { colorScheme?: string; props: Record; elementStyles?: Record; + styles?: any; } interface CTABannerRendererProps { @@ -21,17 +22,26 @@ const COLOR_SCHEMES: Record = { + 'default': 'py-12 md:py-20', + 'small': 'py-8 md:py-12', + 'medium': 'py-16 md:py-24', + 'large': 'py-24 md:py-36', + 'fullscreen': 'min-h-[50vh] flex flex-col justify-center', + }; + const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom; + const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20'); + // Helper to get text styles (including font family) const getTextStyles = (elementName: string) => { const styles = section.elementStyles?.[elementName] || {}; @@ -56,8 +66,10 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps const textStyle = getTextStyles('text'); const btnStyle = getTextStyles('button_text'); + const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient'; + return ( -
+

; elementStyles?: Record; + styles?: any; } interface ContactFormRendererProps { @@ -21,11 +22,10 @@ const COLOR_SCHEMES: Record = { + 'default': 'py-12 md:py-20', + 'small': 'py-8 md:py-12', + 'medium': 'py-16 md:py-24', + 'large': 'py-24 md:py-36', + 'fullscreen': 'min-h-[50vh] flex flex-col justify-center', + }; + const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom; + const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20'); + + const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient'; + return (

= { primary: { bg: 'wn-primary-bg', text: 'text-white' }, secondary: { bg: 'wn-secondary-bg', text: 'text-white' }, muted: { bg: 'bg-gray-50', text: 'text-gray-700' }, - gradient: { bg: 'wn-gradient-bg', text: 'text-white' }, }; const WIDTH_CLASSES: Record = { @@ -152,7 +151,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record