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:
Dwindi Ramadhana
2026-03-04 01:14:56 +07:00
parent 7ff429502d
commit 90169b508d
46 changed files with 2337 additions and 1278 deletions

View File

@@ -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"

View File

@@ -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) */}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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

View File

@@ -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 || {}) }} />

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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>