feat: product page layout toggle (flat/card), fix email shortcode rendering
- Add layout_style setting (flat default) to product appearance
- AppearanceController: sanitize & persist layout_style, add to default settings
- Admin SPA: Layout Style select in Appearance > Product
- Customer SPA: useEffect targets <main> bg-white in flat mode (full-width),
card mode uses per-section white floating cards on gray background
- Accordion sections styled per mode: flat=border-t dividers, card=white cards
- Fix email shortcode gaps (EmailRenderer, EmailManager)
- Add missing variables: return_url, contact_url, account_url (alias),
payment_error_reason, order_items_list (alias for order_items_table)
- Fix customer_note extra_data key mismatch (note → customer_note)
- Pass low_stock_threshold via extra_data in low_stock email send
This commit is contained in:
@@ -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() {
|
||||
</RadioGroup>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Admin Bar */}
|
||||
<SettingsCard
|
||||
title="Admin Bar"
|
||||
description="Control visibility of the WordPress admin bar for logged-in users"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hide-admin-bar" className="font-medium cursor-pointer">
|
||||
Hide Admin Bar on frontend
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hides the WordPress admin bar for all users when visiting your store
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-admin-bar"
|
||||
checked={hideAdminBar}
|
||||
onCheckedChange={setHideAdminBar}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* SPA Page */}
|
||||
<SettingsCard
|
||||
title="SPA Page"
|
||||
|
||||
@@ -81,7 +81,10 @@ export function CanvasSection({
|
||||
>
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50")}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
|
||||
)}
|
||||
style={{
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
|
||||
@@ -118,13 +121,50 @@ export function CanvasSection({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic background placeholder (Featured Image) */}
|
||||
{section.styles?.backgroundType === 'image'
|
||||
&& section.styles?.dynamicBackground === 'post_featured_image'
|
||||
&& !section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundColor: '#e2e8f0',
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(148,163,184,0.15) 10px, rgba(148,163,184,0.15) 20px)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium">Featured Image</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Overlay preview */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
{section.styles?.contentWidth === 'boxed' ? (
|
||||
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar (Standard Interaction) */}
|
||||
|
||||
@@ -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<string>('blank');
|
||||
|
||||
// CPT template state
|
||||
const [selectedCpt, setSelectedCpt] = useState<string>('');
|
||||
const [selectedCptPreset, setSelectedCptPreset] = useState<string>('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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -147,42 +196,15 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 px-6 py-4">
|
||||
{/* Page Type Selection */}
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||
onClick={() => setPageType('page')}
|
||||
>
|
||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<FileText className="w-4 h-4" />
|
||||
{__('Structural Page')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Static content like About, Contact, Terms')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'page' | 'template')}>
|
||||
<TabsList className="w-full grid grid-cols-2 mb-6">
|
||||
<TabsTrigger value="page">{__('Structural Page')}</TabsTrigger>
|
||||
<TabsTrigger value="template">{__('CPT Template')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<Layout className="w-4 h-4" />
|
||||
{__('CPT Template')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Templates are auto-created for each post type')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Page Details */}
|
||||
{pageType === 'page' && (
|
||||
<div className="space-y-6">
|
||||
{/* ── Structural Page Tab ── */}
|
||||
<TabsContent value="page" className="space-y-6 mt-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -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}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||
@@ -210,9 +232,9 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Choose a Template')}</Label>
|
||||
<Label>{__('Choose a Layout')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{templates.map((tpl) => (
|
||||
{pagePresets.map((tpl: any) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
@@ -221,40 +243,80 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
`}
|
||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||
{tpl.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
<div className="mb-2 font-medium text-sm">{tpl.label}</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||
{__('Loading templates...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── CPT Template Tab ── */}
|
||||
<TabsContent value="template" className="space-y-6 mt-0">
|
||||
{availableCpts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm space-y-2">
|
||||
<p className="font-medium">{__('All post types already have a template.')}</p>
|
||||
<p className="text-xs">{__('Abort an existing template first to create a new one.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Select Post Type')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{availableCpts.map((cpt) => (
|
||||
<div
|
||||
key={cpt.cpt}
|
||||
className={`
|
||||
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedCpt === cpt.cpt ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedCpt(cpt.cpt || '')}
|
||||
>
|
||||
<div className="font-medium text-sm">{cpt.title}</div>
|
||||
{cpt.cpt && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">/{cpt.cpt}/</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Starting Layout')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{cptPresets.map((tpl: any) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedCptPreset === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedCptPreset(tpl.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{tpl.label}</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{tpl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={createMutation.isPending}>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Button onClick={handleSubmit} disabled={isDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{__('Creating...')}
|
||||
</>
|
||||
) : (
|
||||
__('Create Page')
|
||||
mode === 'page' ? __('Create Page') : __('Create Template')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{isDynamic && supportsDynamic ? (
|
||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select data source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((source) => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-2">
|
||||
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select data source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((source) => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : fieldType === 'rte' ? (
|
||||
<RichTextEditor
|
||||
content={currentValue}
|
||||
|
||||
@@ -60,6 +60,7 @@ interface InspectorPanelProps {
|
||||
onSetAsSpaLanding?: () => 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<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
||||
@@ -183,6 +183,7 @@ export function InspectorPanel({
|
||||
onSetAsSpaLanding,
|
||||
onUnsetSpaLanding,
|
||||
onDeletePage,
|
||||
onDeleteTemplate,
|
||||
onContainerWidthChange,
|
||||
}: InspectorPanelProps) {
|
||||
if (isCollapsed) {
|
||||
@@ -306,6 +307,25 @@ export function InspectorPanel({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone - Templates */}
|
||||
{isTemplate && page && onDeleteTemplate && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{__('Deleting this template will disable SPA rendering for this post type. WordPress will handle it natively.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onDeleteTemplate}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Abort SPA Template')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
|
||||
{__('Select any section on the canvas to edit its content and design.')}
|
||||
@@ -433,21 +453,32 @@ export function InspectorPanel({
|
||||
</div>
|
||||
|
||||
{/* Feature Grid Repeater */}
|
||||
{selectedSection.type === 'feature-grid' && (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
|
||||
onChange={(items) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={items}
|
||||
onChange={(newItems) => 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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
@@ -571,48 +602,90 @@ export function InspectorPanel({
|
||||
)}
|
||||
|
||||
{/* Image Background */}
|
||||
{selectedSection.styles?.backgroundType === 'image' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
{selectedSection.styles?.backgroundType === 'image' && (() => {
|
||||
const isDynamicBg = selectedSection.styles?.dynamicBackground === 'post_featured_image';
|
||||
return (
|
||||
<>
|
||||
{/* Source toggle: Upload vs Featured Image */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<div className="flex gap-1 p-0.5 bg-gray-100 rounded-md">
|
||||
<button
|
||||
onClick={() => onSectionStylesChange({ dynamicBackground: undefined })}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
!isDynamicBg
|
||||
? 'bg-white shadow-sm font-medium text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
Upload Image
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSectionStylesChange({ dynamicBackground: 'post_featured_image', backgroundImage: '' })}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
isDynamicBg
|
||||
? 'bg-white shadow-sm font-medium text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
Featured Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Static upload */}
|
||||
{!isDynamicBg && (
|
||||
<div className="space-y-2">
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic source info */}
|
||||
{isDynamicBg && (
|
||||
<div className="flex items-start gap-2 text-xs bg-blue-50 border border-blue-200 rounded-md p-2.5 text-blue-700">
|
||||
<span className="mt-0.5">⚡</span>
|
||||
<span>At runtime, the background will use this post's featured image. Falls back to no background if no featured image is set.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Spacing Controls */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
|
||||
@@ -643,7 +716,7 @@ export function InspectorPanel({
|
||||
<RadioGroup
|
||||
value={selectedSection.styles?.contentWidth || 'full'}
|
||||
onValueChange={(val: any) => onSectionStylesChange({ contentWidth: val })}
|
||||
className="flex gap-4"
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="full" id="width-full" />
|
||||
@@ -653,6 +726,10 @@ export function InspectorPanel({
|
||||
<RadioGroupItem value="contained" id="width-contained" />
|
||||
<Label htmlFor="width-contained" className="text-sm font-normal cursor-pointer">{__('Contained')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="boxed" id="width-boxed" />
|
||||
<Label htmlFor="width-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed (Card)')}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
{!isDynamic && (
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
@@ -224,8 +228,15 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab
|
||||
</Accordion>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
|
||||
No items yet. Click "Add Item" to start.
|
||||
<div className={cn(
|
||||
"text-xs text-center py-4 border rounded-md",
|
||||
isDynamic
|
||||
? "text-blue-600 border-blue-200 bg-blue-50"
|
||||
: "text-gray-400 border-dashed bg-gray-50"
|
||||
)}>
|
||||
{isDynamic
|
||||
? (dynamicLabel || '⚡ Auto-populated from related posts at runtime')
|
||||
: 'No items yet. Click "Add Item" to start.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={`template-${template.cpt}`}
|
||||
onClick={() => onSelectPage(template)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="block">{template.title}</span>
|
||||
{template.permalink_base && (
|
||||
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">{__('No templates yet')}</p>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<button
|
||||
key={`template-${template.cpt}`}
|
||||
onClick={() => onSelectPage(template)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="block">{template.title}</span>
|
||||
{template.permalink_base && (
|
||||
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface CTABannerRendererProps {
|
||||
@@ -21,17 +22,26 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; b
|
||||
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
|
||||
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
|
||||
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
|
||||
};
|
||||
|
||||
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'] ?? COLOR_SCHEMES['primary'];
|
||||
|
||||
const title = section.props?.title?.value || 'Ready to get started?';
|
||||
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
|
||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||
const buttonUrl = section.props?.button_url?.value || '#';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'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 (
|
||||
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
|
||||
<div className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2
|
||||
className={cn(
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface ContactFormRendererProps {
|
||||
@@ -21,11 +22,10 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string;
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
|
||||
};
|
||||
|
||||
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
|
||||
const title = section.props?.title?.value || 'Contact Us';
|
||||
|
||||
@@ -69,10 +69,22 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'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 (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<h2
|
||||
|
||||
@@ -15,7 +15,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
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<string, string> = {
|
||||
@@ -152,7 +151,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
};
|
||||
|
||||
export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
@@ -211,17 +210,20 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`section-${section.id}`}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text,
|
||||
className
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Image, Calendar, User } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -8,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface FeatureGridRendererProps {
|
||||
@@ -20,7 +22,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
};
|
||||
|
||||
const GRID_CLASSES: Record<string, string> = {
|
||||
@@ -29,20 +30,57 @@ const GRID_CLASSES: Record<string, string> = {
|
||||
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// Default features for demo
|
||||
// Default features for static demo
|
||||
const DEFAULT_FEATURES = [
|
||||
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
|
||||
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
|
||||
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
|
||||
];
|
||||
|
||||
// Placeholder post-card skeleton shown when features are dynamic (related_posts)
|
||||
function PostCardPlaceholder({ index, cardBg }: { index: number; cardBg: string }) {
|
||||
const widths = ['w-3/4', 'w-2/3', 'w-4/5'];
|
||||
const titleWidth = widths[index % widths.length];
|
||||
return (
|
||||
<div className={cn('rounded-xl overflow-hidden', cardBg, 'shadow-sm border border-dashed border-gray-300')}>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="aspect-[16/9] bg-gray-200 flex items-center justify-center">
|
||||
<Image className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> Jan 1, 2025</span>
|
||||
<span className="flex items-center gap-1"><User className="w-3 h-3" /> Author</span>
|
||||
</div>
|
||||
{/* Title skeleton */}
|
||||
<div className={cn('h-4 bg-gray-300 rounded animate-pulse', titleWidth)} />
|
||||
{/* Excerpt skeleton */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-5/6" />
|
||||
</div>
|
||||
{/* "Read more" chip */}
|
||||
<div className="pt-1">
|
||||
<div className="inline-block h-3 w-16 bg-blue-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'grid-3';
|
||||
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
|
||||
|
||||
const heading = section.props?.heading?.value || 'Our Features';
|
||||
const features = section.props?.features?.value || DEFAULT_FEATURES;
|
||||
const featuresProp = section.props?.features;
|
||||
const isDynamic = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
|
||||
const features = isDynamic ? [] : (featuresProp?.value || DEFAULT_FEATURES);
|
||||
|
||||
// Determine how many placeholder post-cards to show (match grid columns)
|
||||
const placeholderCount = layout === 'grid-4' ? 4 : layout === 'grid-2' ? 2 : 3;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -81,10 +119,22 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'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 (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{heading && (
|
||||
@@ -99,47 +149,56 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
|
||||
// Resolve icon from name, fallback to Star
|
||||
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-6 rounded-xl text-center',
|
||||
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<IconComponent className="w-7 h-7 text-blue-600" />
|
||||
{/* Dynamic (related posts) — show post-card skeleton placeholders */}
|
||||
{isDynamic ? (
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{Array.from({ length: placeholderCount }).map((_, i) => (
|
||||
<PostCardPlaceholder key={i} index={i} cardBg={scheme.cardBg} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Static items — regular icon feature cards */
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
|
||||
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-6 rounded-xl text-center',
|
||||
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<IconComponent className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3
|
||||
className={cn(
|
||||
"mb-2",
|
||||
!featureItemStyle.style?.color && "text-lg font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.title || `Feature ${index + 1}`}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!featureItemStyle.style?.color && "opacity-80"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.description || 'Feature description goes here'}
|
||||
</p>
|
||||
</div>
|
||||
<h3
|
||||
className={cn(
|
||||
"mb-2",
|
||||
!featureItemStyle.style?.color && "text-lg font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.title || `Feature ${index + 1}`}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!featureItemStyle.style?.color && "opacity-80"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.description || 'Feature description goes here'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface HeroRendererProps {
|
||||
@@ -20,13 +21,22 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
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' },
|
||||
};
|
||||
|
||||
export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'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 title = section.props?.title?.value || 'Hero Title';
|
||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||
const image = section.props?.image?.value;
|
||||
@@ -66,12 +76,12 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
// Helper for image styles
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
@@ -146,7 +156,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
// Default centered layout
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn(heightClasses, 'px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1
|
||||
|
||||
@@ -14,11 +14,10 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
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' },
|
||||
};
|
||||
|
||||
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'image-left';
|
||||
const isImageRight = layout === 'image-right';
|
||||
|
||||
@@ -73,10 +72,22 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'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 (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
|
||||
@@ -145,6 +145,22 @@ export default function AppearancePages() {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete template mutation (abort SPA for this CPT)
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (cpt: string) => {
|
||||
return api.del(`/templates/${cpt}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('CPT template deleted. WordPress will handle this post type natively.'));
|
||||
markAsSaved();
|
||||
setCurrentPage(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete template'));
|
||||
},
|
||||
});
|
||||
|
||||
// Set as SPA Landing mutation
|
||||
const setSpaLandingMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
@@ -212,6 +228,14 @@ export default function AppearancePages() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = () => {
|
||||
if (!currentPage || currentPage.type !== 'template' || !currentPage.cpt) return;
|
||||
|
||||
if (confirm(__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.'))) {
|
||||
deleteTemplateMutation.mutate(currentPage.cpt);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
@@ -358,6 +382,7 @@ export default function AppearancePages() {
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onContainerWidthChange={(width) => {
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||
@@ -373,6 +398,7 @@ export default function AppearancePages() {
|
||||
< CreatePageModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
cptList={pages.filter((p: PageItem) => p.type === 'template')}
|
||||
onCreated={(newPage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
setCurrentPage(newPage);
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface SectionStyles {
|
||||
backgroundOverlay?: number; // 0-100 opacity
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
contentWidth?: 'full' | 'contained' | 'boxed';
|
||||
heightPreset?: string;
|
||||
dynamicBackground?: string; // e.g. 'post_featured_image'
|
||||
}
|
||||
|
||||
export interface ElementStyle {
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function AppearanceProduct() {
|
||||
const [imagePosition, setImagePosition] = useState('left');
|
||||
const [galleryStyle, setGalleryStyle] = useState('thumbnails');
|
||||
const [stickyAddToCart, setStickyAddToCart] = useState(false);
|
||||
|
||||
const [layoutStyle, setLayoutStyle] = useState('flat');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
breadcrumbs: true,
|
||||
related_products: true,
|
||||
@@ -34,12 +35,13 @@ export default function AppearanceProduct() {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const product = response.data?.pages?.product;
|
||||
|
||||
|
||||
if (product) {
|
||||
if (product.layout) {
|
||||
if (product.layout.image_position) setImagePosition(product.layout.image_position);
|
||||
if (product.layout.gallery_style) setGalleryStyle(product.layout.gallery_style);
|
||||
if (product.layout.sticky_add_to_cart !== undefined) setStickyAddToCart(product.layout.sticky_add_to_cart);
|
||||
if (product.layout.layout_style) setLayoutStyle(product.layout.layout_style);
|
||||
}
|
||||
if (product.elements) {
|
||||
setElements({
|
||||
@@ -66,7 +68,7 @@ export default function AppearanceProduct() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
@@ -77,10 +79,11 @@ export default function AppearanceProduct() {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/product', {
|
||||
layout: {
|
||||
image_position: imagePosition,
|
||||
gallery_style: galleryStyle,
|
||||
sticky_add_to_cart: stickyAddToCart
|
||||
layout: {
|
||||
image_position: imagePosition,
|
||||
gallery_style: galleryStyle,
|
||||
sticky_add_to_cart: stickyAddToCart,
|
||||
layout_style: layoutStyle,
|
||||
},
|
||||
elements,
|
||||
related_products: {
|
||||
@@ -106,6 +109,23 @@ export default function AppearanceProduct() {
|
||||
title="Layout"
|
||||
description="Configure product page layout and gallery"
|
||||
>
|
||||
<SettingsSection label="Layout Style" htmlFor="layout-style">
|
||||
<Select value={layoutStyle} onValueChange={setLayoutStyle}>
|
||||
<SelectTrigger id="layout-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flat">Flat — content floats on page background</SelectItem>
|
||||
<SelectItem value="card">Card — content inside a white elevated card</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{layoutStyle === 'flat'
|
||||
? 'Clean, minimal look. Product sections blend with the page background.'
|
||||
: 'Each product section is wrapped in a white card, elevated from the background.'}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Image Position" htmlFor="image-position">
|
||||
<Select value={imagePosition} onValueChange={setImagePosition}>
|
||||
<SelectTrigger id="image-position">
|
||||
@@ -249,7 +269,7 @@ export default function AppearanceProduct() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{reviewSettings.placement === 'product_page'
|
||||
{reviewSettings.placement === 'product_page'
|
||||
? 'Reviews appear on product page. Users can submit reviews directly on the product.'
|
||||
: 'Reviews only appear in order details after purchase. Ensures verified purchases only.'}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user