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 */}
{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>
</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,7 +89,8 @@ export function InspectorField({
</div>
{isDynamic && supportsDynamic ? (
<Select value={currentValue} onValueChange={handleValueChange}>
<div className="space-y-2">
<Select value={currentValue} onValueChange={handleSelectChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select data source" />
</SelectTrigger>
@@ -97,6 +102,7 @@ export function InspectorField({
))}
</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' && (
{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={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
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,10 +602,42 @@ export function InspectorPanel({
)}
{/* Image Background */}
{selectedSection.styles?.backgroundType === 'image' && (
{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>
{/* 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">
@@ -597,6 +660,15 @@ export function InspectorPanel({
)}
</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">
@@ -612,7 +684,8 @@ export function InspectorPanel({
/>
</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>
{!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,7 +69,10 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
{__('Templates')}
</h3>
<div className="space-y-1">
{templates.map((template) => (
{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)}
@@ -86,7 +89,8 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
<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,11 +149,18 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
</h2>
)}
{/* 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) => {
// Resolve icon from name, fallback to Star
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
return (
<div
key={index}
@@ -139,7 +196,9 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
);
})}
</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,6 +13,7 @@ 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,
@@ -40,6 +41,7 @@ export default function AppearanceProduct() {
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({
@@ -80,7 +82,8 @@ export default function AppearanceProduct() {
layout: {
image_position: imagePosition,
gallery_style: galleryStyle,
sticky_add_to_cart: stickyAddToCart
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">

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -62,7 +63,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1057,7 +1057,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1079,7 +1078,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1089,14 +1087,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1107,7 +1103,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -1121,7 +1116,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -1131,7 +1125,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -2660,6 +2653,31 @@
"win32"
]
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz",
@@ -3099,14 +3117,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -3120,7 +3136,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -3365,7 +3380,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3388,7 +3402,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -3495,7 +3508,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3543,7 +3555,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -3568,7 +3579,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -3638,7 +3648,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3686,7 +3695,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -3827,14 +3835,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -4433,7 +4439,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -4450,7 +4455,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -4477,7 +4481,6 @@
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -4500,7 +4503,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4581,7 +4583,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4596,7 +4597,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4723,7 +4723,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -4867,7 +4866,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5012,7 +5010,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -5055,7 +5052,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -5106,7 +5102,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5152,7 +5147,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -5191,7 +5185,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -5395,7 +5388,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -5511,7 +5503,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -5524,7 +5515,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -5595,7 +5585,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -5605,7 +5594,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -5642,7 +5630,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -5654,7 +5641,6 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -5687,7 +5673,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5707,7 +5692,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5717,7 +5701,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -5926,21 +5909,18 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -5953,7 +5933,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5963,7 +5942,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -5983,7 +5961,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6012,7 +5989,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -6030,7 +6006,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -6051,7 +6026,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6077,7 +6051,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6120,7 +6093,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -6146,7 +6118,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -6160,7 +6131,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@@ -6199,7 +6169,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6405,7 +6374,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -6415,7 +6383,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -6500,7 +6467,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -6553,7 +6519,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6824,7 +6789,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -6959,7 +6923,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -6995,7 +6958,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7018,7 +6980,6 @@
"version": "3.4.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -7066,7 +7027,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -7087,7 +7047,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -7097,7 +7056,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -7110,7 +7068,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -7127,7 +7084,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -7145,7 +7101,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -7158,7 +7113,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -7184,7 +7138,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
@@ -7421,7 +7374,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vaul": {

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -20,6 +20,7 @@ import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import OrderPay from './pages/OrderPay';
import Subscribe from './pages/Subscribe';
import { DynamicPageRenderer } from './pages/DynamicPage';
// Create QueryClient instance
@@ -116,6 +117,9 @@ function AppRoutes() {
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* Newsletter / Notifications */}
<Route path="/subscribe" element={<Subscribe />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />

View File

@@ -12,7 +12,7 @@ interface SharedContentProps {
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
// Layout
containerWidth?: 'full' | 'contained';
containerWidth?: 'full' | 'contained' | 'boxed';
// Styles
className?: string;
@@ -53,15 +53,19 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom';
// Wrapper classes
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-7xl' : ''
containerWidth === 'contained' ? 'max-w-4xl'
: containerWidth === 'boxed' ? 'max-w-5xl'
: '' // full = no max-width cap
);
const gridClasses = cn(
'mx-auto',
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
hasImage && (isImageLeft || isImageRight)
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
);
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
@@ -74,6 +78,8 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
return (
<div className={containerClasses}>
{containerWidth === 'boxed' ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
@@ -96,7 +102,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
@@ -111,8 +117,9 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
@@ -151,5 +158,81 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
</div>
</div>
</div>
) : (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -121,6 +121,7 @@ export function useProductSettings() {
image_position: 'left' as string,
gallery_style: 'thumbnails' as string,
sticky_add_to_cart: false,
layout_style: 'flat' as string,
},
elements: {
breadcrumbs: true,

View File

@@ -12,7 +12,7 @@ export interface SectionStyleResult {
*/
export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult {
if (!styles) {
return { style: {}, hasOverlay: false, overlayStyle: undefined };
return { style: {}, hasOverlay: false, overlayOpacity: 0 };
}
const bgType = styles.backgroundType || 'solid';
@@ -56,3 +56,30 @@ export function getSectionBackground(styles?: Record<string, any>): SectionStyle
return { style, hasOverlay, overlayOpacity, backgroundImage };
}
/**
* Returns inner container class names for the three content width modes:
* - full: edge-to-edge, no max-width
* - contained: centered max-w-6xl (matches Product page / SPA default)
* - boxed: centered max-w-5xl, wrapped in a white rounded-2xl card (matches product accordion cards)
*
* For 'boxed', apply this to the inner container div; no extra wrapper needed.
*/
export function getContentWidthClasses(contentWidth?: string): string {
switch (contentWidth) {
case 'full':
return 'w-full px-4 md:px-8';
case 'boxed':
return 'container mx-auto px-4 max-w-5xl';
case 'contained':
default:
return 'container mx-auto px-4';
}
}
/**
* Returns whether the section uses the boxed (card) layout.
*/
export function isBoxedLayout(contentWidth?: string): boolean {
return contentWidth === 'boxed';
}

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Helmet } from 'react-helmet-async';
import { cn } from '@/lib/utils';
// Section Components
import { HeroSection } from './sections/HeroSection';
@@ -121,14 +122,25 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
const navigate = useNavigate();
const [notFound, setNotFound] = useState(false);
// Use prop slug if provided, otherwise use param slug
const effectiveSlug = propSlug || paramSlug;
// Get page type from DOM (injected by TemplateOverride.php)
const appEl = document.getElementById('woonoow-customer-app');
const dataPageType = appEl?.getAttribute('data-page');
const dataCptType = appEl?.getAttribute('data-cpt-type'); // e.g. 'post', 'portfolio'
const dataCptSlug = appEl?.getAttribute('data-cpt-slug'); // e.g. 'my-post-slug'
// Determine content type:
// Priority: pathBase from router > data-cpt-type from DOM > fallback
const contentType = pathBase
? (pathBase === 'blog' ? 'post' : pathBase)
: (dataPageType === 'cpt' && dataCptType ? dataCptType : undefined);
// Effective slug: prefer router param, then DOM cpt-slug
const effectiveSlug = propSlug || paramSlug || (dataPageType === 'cpt' ? dataCptSlug : undefined) || '';
// Determine if this is a page or CPT content
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
const isStructuralPage = !pathBase || !!propSlug;
const contentType = pathBase === 'blog' ? 'post' : pathBase;
const contentSlug = effectiveSlug || '';
const isStructuralPage = dataPageType === 'page' || dataPageType === 'shop' || contentType === undefined;
const contentSlug = effectiveSlug;
// Fetch page/content data
const { data: pageData, isLoading, error } = useQuery<PageData>({
@@ -138,11 +150,12 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
// Fetch structural page - api.get returns JSON directly
const response = await api.get<PageData>(`/pages/${contentSlug}`);
return response;
} else {
} else if (contentType) {
// Fetch CPT content with template
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
return response;
}
throw new Error("Unable to determine content type");
},
retry: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
@@ -175,6 +188,16 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
<p className="text-gray-600 mb-8">Page not found</p>
<div className="bg-gray-100 p-4 rounded text-left mb-8 text-sm">
<strong>DEBUG INFO:</strong><br />
pathBase: {pathBase ?? 'undefined'}<br />
propSlug: {propSlug ?? 'undefined'}<br />
paramSlug: {paramSlug ?? 'undefined'}<br />
effectiveSlug: {effectiveSlug ?? 'undefined'}<br />
dataPageType: {dataPageType ?? 'undefined'}<br />
contentType: {contentType ?? 'undefined'}<br />
isStructuralPage: {isStructuralPage ? 'true' : 'false'}<br />
</div>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
@@ -226,15 +249,15 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
return (
<div
key={section.id}
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
className="relative overflow-hidden"
style={{
backgroundColor: section.styles?.backgroundColor,
// Only explicit custom padding overrides from the padding fields
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
{/* Full-bleed background image & overlay */}
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
@@ -247,11 +270,11 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
</>
)}
{/* Content Wrapper */}
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
{/* Section component — manages its own background, height, and inner content width */}
<div className="relative z-10 w-full">
<SectionComponent
id={section.id}
section={section} // Pass full section object for components that need raw data
section={section}
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface CTABannerSectionProps {
id: string;
@@ -22,26 +23,34 @@ export function CTABannerSection({
elementStyles,
styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
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-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
const es = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
es.fontSize,
es.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
'font-sans': es.fontFamily === 'secondary',
'font-serif': es.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
color: es.color,
textAlign: es.textAlign,
backgroundColor: es.backgroundColor,
borderColor: es.borderColor,
borderWidth: es.borderWidth,
borderRadius: es.borderRadius,
}
};
};
@@ -49,31 +58,15 @@ export function CTABannerSection({
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text');
return (
<section
id={id}
className={cn(
'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-20',
{
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
// Shared inner content — same markup used in boxed and non-boxed
const innerContent = (
<>
{title && (
<h2
className={cn(
"wn-cta__title mb-6",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
@@ -87,10 +80,11 @@ export function CTABannerSection({
<p className={cn(
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
{
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
styles?.contentWidth !== 'boxed' && {
'text-white/90': colorScheme === 'primary',
'text-gray-600': colorScheme === 'muted',
},
styles?.contentWidth === 'boxed' && 'text-gray-600',
textStyle.classNames
)}
style={textStyle.style}
@@ -104,14 +98,18 @@ export function CTABannerSection({
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && {
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
},
!btnStyle.style?.color && {
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
},
}),
btnStyle.classNames
)}
style={btnStyle.style}
@@ -119,7 +117,54 @@ export function CTABannerSection({
{button_text}
</a>
)}
</>
);
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<section
id={id}
className={cn(
'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
heightClasses,
{
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)}
style={getBackgroundStyle()}
>
{styles?.contentWidth === 'boxed' ? (
<div className="container mx-auto px-4 max-w-5xl">
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center">
{innerContent}
</div>
</div>
) : (
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{innerContent}
</div>
)}
</section>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface ContactFormSectionProps {
id: string;
@@ -23,6 +24,15 @@ export function ContactFormSection({
elementStyles,
styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
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-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family)
@@ -87,6 +97,19 @@ export function ContactFormSection({
} finally {
setSubmitting(false);
}
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
@@ -95,17 +118,18 @@ export function ContactFormSection({
className={cn(
'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-20',
heightClasses,
{
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)}
style={getBackgroundStyle()}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
<div className={cn(
'max-w-xl mx-auto',
@@ -116,7 +140,7 @@ export function ContactFormSection({
{title && (
<h2 className={cn(
"wn-contact__title text-center mb-12",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}

View File

@@ -1,20 +1,21 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles';
interface ContentSectionProps {
id?: string;
section: {
id: string;
layoutVariant?: string;
colorScheme?: string;
props?: {
content?: { value: string };
cta_text?: { value: string };
cta_url?: { value: string };
};
elementStyles?: Record<string, any>;
styles?: Record<string, any>;
props?: any;
};
content?: string;
cta_text?: string;
cta_url?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
@@ -25,7 +26,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> = {
@@ -164,11 +164,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
return styles.join('\n');
};
export function ContentSection({ section }: ContentSectionProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, outerPadding = false }: ContentSectionProps & { outerPadding?: boolean }) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
// Default to 'default' width if not specified
const layout = section.layoutVariant || 'default';
const widthClass = section.styles?.contentWidth === 'full' ? WIDTH_CLASSES.full : (WIDTH_CLASSES[layout] || WIDTH_CLASSES.default);
const heightPreset = section.styles?.heightPreset || 'default';
@@ -182,7 +181,7 @@ export function ContentSection({ section }: ContentSectionProps) {
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || '';
const content = propContent || section.props?.content?.value || section.props?.content || '';
// Helper to get text styles
const getTextStyles = (elementName: string) => {
@@ -209,15 +208,16 @@ export function ContentSection({ section }: ContentSectionProps) {
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const containerWidth = section.styles?.contentWidth || 'contained';
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
const containerWidth = section.styles?.contentWidth ?? 'contained';
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(section.styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
@@ -234,9 +234,8 @@ export function ContentSection({ section }: ContentSectionProps) {
id={section.id}
className={cn(
'wn-content',
'px-4 md:px-8',
heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg,
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text
)}
style={getBackgroundStyle()}

View File

@@ -1,10 +1,16 @@
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
import { getSectionBackground } from '@/lib/sectionStyles';
interface FeatureItem {
title?: string;
description?: string;
icon?: string;
// Post-card fields (from related_posts dynamic source)
url?: string;
featured_image?: string;
excerpt?: string;
date?: string;
}
interface FeatureGridSectionProps {
@@ -26,15 +32,26 @@ export function FeatureGridSection({
elementStyles,
styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
// Use items or features (priority to items if both exist, but usually only one comes from props)
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-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const listItems = items.length > 0 ? items : features;
const gridCols = {
'grid-2': 'md:grid-cols-2',
'grid-3': 'md:grid-cols-3',
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
}[layout] || 'md:grid-cols-3';
// Helper to get text styles (including font family)
// Detect if these are post-cards (from related_posts) — they have a url field
const isPostCards = listItems.some(item => !!item.url);
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
@@ -60,6 +77,21 @@ export function FeatureGridSection({
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<section
id={id}
@@ -67,23 +99,25 @@ export function FeatureGridSection({
'wn-section wn-feature-grid',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-24',
heightClasses,
{
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
}
)}
style={getBackgroundStyle()}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}>
{heading && (
<h2
className={cn(
"wn-features__heading text-center mb-12",
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
"wn-features__heading text-center mb-10",
!elementStyles?.heading?.fontSize && "text-2xl md:text-3xl lg:text-4xl",
!elementStyles?.heading?.fontWeight && "font-bold",
headingStyle.classNames
)}
@@ -93,8 +127,66 @@ export function FeatureGridSection({
</h2>
)}
<div className={cn('grid gap-8', gridCols)}>
{listItems.map((item, index) => (
<div className={cn('grid gap-6', gridCols)}>
{listItems.map((item, index) => {
// ── Post Card (from related_posts) ──────────────────────────
if (isPostCards) {
return (
<a
key={index}
href={item.url || '#'}
className={cn(
'wn-post-card group block rounded-xl overflow-hidden transition-all duration-200',
'bg-white shadow-md hover:shadow-xl hover:-translate-y-1',
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
{/* Thumbnail */}
{item.featured_image ? (
<div className="aspect-[16/9] overflow-hidden bg-gray-100">
<img
src={item.featured_image}
alt={item.title || ''}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
) : (
<div className="aspect-[16/9] bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center">
<svg className="w-10 h-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
{/* Card Body */}
<div className="p-5">
{item.date && (
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
)}
{item.title && (
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2">
{item.title}
</h3>
)}
{(item.excerpt || item.description) && (
<p className="text-sm text-gray-500 line-clamp-3 mb-4">
{item.excerpt || item.description}
</p>
)}
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary">
Read more
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</a>
);
}
// ── Feature Card (icon + title + desc) ─────────────────────
return (
<div
key={index}
className={cn(
@@ -117,7 +209,6 @@ export function FeatureGridSection({
</div>
);
})()}
{item.title && (
<h3
className={cn(
@@ -129,9 +220,9 @@ export function FeatureGridSection({
{item.title}
</h3>
)}
{item.description && (
<p className={cn(
<p
className={cn(
'wn-feature-grid__item-desc',
!featureItemStyle.style?.color && {
'text-gray-600': colorScheme !== 'primary',
@@ -144,8 +235,14 @@ export function FeatureGridSection({
</p>
)}
</div>
))}
);
})}
</div>
{/* Empty state for related posts */}
{isPostCards && listItems.length === 0 && (
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
)}
</div>
</section>
);

View File

@@ -25,6 +25,15 @@ export function HeroSection({
elementStyles,
styles,
}: HeroSectionProps & { styles?: Record<string, any> }) {
const heightMap: Record<string, string> = {
'default': 'py-16 md:py-28',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-36',
'fullscreen': 'min-h-screen flex flex-col justify-center',
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
@@ -67,9 +76,6 @@ export function HeroSection({
const getBackgroundStyle = (): React.CSSProperties | undefined => {
// If user set custom bg via Design tab, use that
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'gradient') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
@@ -79,7 +85,7 @@ export function HeroSection({
return undefined;
};
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
const isDynamicScheme = ['primary', 'secondary'].includes(colorScheme) && !hasCustomBackground;
return (
<section
@@ -88,12 +94,15 @@ export function HeroSection({
'wn-section wn-hero',
`wn-hero--${layout}`,
'relative overflow-hidden',
heightClasses,
)}
style={sectionBg.style}
>
<div className={cn(
'mx-auto px-4 z-10 relative flex w-full',
styles?.contentWidth === 'full' ? 'w-full' : 'container max-w-7xl',
styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container max-w-7xl',
{
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered,

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles';
interface ImageTextSectionProps {
id: string;
@@ -66,25 +67,40 @@ export function ImageTextSection({
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
const sectionBg = getSectionBackground(styles);
// Helper to get background style for dynamic schemes
const getBackgroundStyle = (): React.CSSProperties | undefined => {
if (hasCustomBackground) return sectionBg.style;
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<section
id={id}
className={cn(
'wn-section wn-image-text',
`wn-scheme--${colorScheme}`,
heightClasses,
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
{
'bg-muted': colorScheme === 'muted',
'bg-primary/5': colorScheme === 'primary',
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
}
)}
style={getBackgroundStyle()}
>
<SharedContentLayout
title={title}
text={text}
image={image}
imagePosition={isImageRight ? 'right' : 'left'}
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
containerWidth={styles?.contentWidth === 'full' ? 'full' : styles?.contentWidth === 'boxed' ? 'boxed' : 'contained'}
titleStyle={titleStyle.style}
titleClassName={titleStyle.classNames}
textStyle={textStyle.style}

View File

@@ -29,6 +29,20 @@ export default function Product() {
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
const { isEnabled: isModuleEnabled } = useModules();
// Apply white background to <main> in flat mode so the full viewport width is white
useEffect(() => {
const main = document.querySelector('main');
if (!main) return;
if (layout.layout_style === 'flat') {
(main as HTMLElement).style.backgroundColor = '#ffffff';
} else {
(main as HTMLElement).style.backgroundColor = '';
}
return () => {
(main as HTMLElement).style.backgroundColor = '';
};
}, [layout.layout_style]);
// Fetch product details by slug
const { data: product, isLoading, error } = useQuery<ProductType | null>({
queryKey: ['product', slug],
@@ -94,10 +108,16 @@ export default function Product() {
// Find matching variation when attributes change
useEffect(() => {
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
const variation = (product.variations as any[]).find(v => {
if (!v.attributes) return false;
let bestMatch: any = null;
let highestScore = -1;
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
(product.variations as any[]).forEach(v => {
if (!v.attributes) return;
let isMatch = true;
let score = 0;
const attributesMatch = Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
const normalizedSelectedValue = attrValue.toLowerCase().trim();
const attrNameLower = attrName.toLowerCase();
@@ -108,17 +128,11 @@ export default function Product() {
// Try to find a matching key in the variation attributes
let variationValue: string | undefined = undefined;
// Check for common WooCommerce attribute key formats
// 1. Check strict slug format (attribute_7-days-...)
if (`attribute_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrSlug}`];
}
// 2. Check pa_ format (attribute_pa_color)
else if (`attribute_pa_${attrSlug}` in v.attributes) {
} else if (`attribute_pa_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
}
// 3. Fallback to name-based checks (legacy)
else if (`attribute_${attrNameLower}` in v.attributes) {
} else if (`attribute_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrNameLower}`];
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
@@ -126,23 +140,34 @@ export default function Product() {
variationValue = v.attributes[attrNameLower];
}
// If key is undefined/missing in variation, it means "Any" -> Match
// If key is undefined/missing in variation, it means "Any" -> Match with score 0
if (variationValue === undefined || variationValue === null) {
return true;
}
// If empty string, it also means "Any" -> Match
// If empty string, it also means "Any" -> Match with score 0
const normalizedVarValue = String(variationValue).toLowerCase().trim();
if (normalizedVarValue === '') {
return true;
}
// Otherwise, values must match
return normalizedVarValue === normalizedSelectedValue;
});
// Exact match gets a higher score
if (normalizedVarValue === normalizedSelectedValue) {
score += 1;
return true;
}
// Value mismatch
return false;
});
setSelectedVariation(variation || null);
if (attributesMatch && score > highestScore) {
highestScore = score;
bestMatch = v;
}
});
setSelectedVariation(bestMatch || null);
} else if (product?.type !== 'variable') {
setSelectedVariation(null);
}
@@ -317,7 +342,10 @@ export default function Product() {
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
}}
/>
<div className="max-w-6xl mx-auto py-8">
{/* Flat: entire Container is bg-white. Card: per-section white cards on gray. */}
<div className="max-w-6xl mx-auto">
{/* Top section: flat = no card wrapper, card = white card */}
<div className={layout.layout_style === 'card' ? 'bg-white rounded-2xl shadow-sm border border-gray-100 p-6 lg:p-8 xl:p-10 mb-8' : 'mb-8'}>
{/* Breadcrumb */}
{elements.breadcrumbs && (
<nav className="mb-6 text-sm">
@@ -329,7 +357,7 @@ export default function Product() {
</nav>
)}
<div className={`grid gap-6 lg:gap-12 ${layout.image_position === 'right' ? 'lg:grid-cols-[42%_58%]' : 'lg:grid-cols-[58%_42%]'}`}>
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
{/* Product Images */}
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
{/* Main Image - ENHANCED */}
@@ -660,14 +688,18 @@ export default function Product() {
)}
</div>
</div>
</div>
{/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */}
<div className="mt-12 space-y-6">
<div className="space-y-6">
{/* Description Section */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className={layout.layout_style === 'card'
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
: 'border-t border-gray-200 overflow-hidden'
}>
<button
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
>
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
<svg
@@ -694,10 +726,13 @@ export default function Product() {
</div>
{/* Specifications Section - SCANNABLE TABLE */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className={layout.layout_style === 'card'
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
: 'border-t border-gray-200 overflow-hidden'
}>
<button
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
>
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
<svg

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { NewsletterForm } from '@/components/NewsletterForm';
export default function Subscribe() {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center py-20 px-4 bg-gray-50/50">
<Helmet>
<title>Subscribe | WooNooW</title>
</Helmet>
<div className="max-w-md w-full bg-white p-8 md:p-10 rounded-2xl shadow-sm border border-gray-100 text-center space-y-6">
<div className="w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 tracking-tight">
Subscribe to our Newsletter
</h1>
<p className="text-gray-600 leading-relaxed">
Get the latest updates, articles, and exclusive offers straight to your inbox. No spam, ever.
</p>
<div className="pt-4 mt-8 text-left">
<NewsletterForm
gdprRequired={true}
consentText="I agree to receive marketing emails and understand I can unsubscribe at any time."
/>
</div>
<p className="text-xs text-gray-400 mt-6 pt-6 border-t border-gray-100">
By subscribing, you agree to our Terms of Service and Privacy Policy.
</p>
</div>
</div>
);
}

View File

@@ -25,5 +25,5 @@ module.exports = {
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
}
},
plugins: [require("tailwindcss-animate")]
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
};

View File

@@ -381,6 +381,7 @@ class AppearanceController
'image_position' => sanitize_text_field($data['layout']['image_position'] ?? 'left'),
'gallery_style' => sanitize_text_field($data['layout']['gallery_style'] ?? 'thumbnails'),
'sticky_add_to_cart' => (bool) ($data['layout']['sticky_add_to_cart'] ?? false),
'layout_style' => sanitize_text_field($data['layout']['layout_style'] ?? 'flat'),
],
'elements' => [
'breadcrumbs' => (bool) ($data['elements']['breadcrumbs'] ?? true),
@@ -601,7 +602,11 @@ class AppearanceController
'show_icon' => true,
],
],
'product' => [],
'product' => [
'layout' => [
'layout_style' => 'flat',
],
],
'cart' => [],
'checkout' => [],
'thankyou' => [],

View File

@@ -75,7 +75,7 @@ class Assets
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
]);
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
@@ -201,7 +201,7 @@ class Assets
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
]);
// WordPress REST API settings (for media upload compatibility)

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Admin;
/**
@@ -9,12 +10,14 @@ namespace WooNooW\Admin;
*
* @package WooNooW\Admin
*/
class StandaloneAdmin {
class StandaloneAdmin
{
/**
* Initialize standalone admin handler
*/
public static function init() {
public static function init()
{
// Catch /admin requests very early (before WordPress routing)
add_action('parse_request', [__CLASS__, 'handle_admin_request'], 1);
}
@@ -22,7 +25,8 @@ class StandaloneAdmin {
/**
* Handle /admin requests
*/
public static function handle_admin_request() {
public static function handle_admin_request()
{
// Check if this is an /admin request
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
@@ -42,7 +46,8 @@ class StandaloneAdmin {
/**
* Render standalone admin interface
*/
private static function render_standalone_admin() {
private static function render_standalone_admin()
{
// Enqueue WordPress media library (needed for image uploads)
wp_enqueue_media();
@@ -88,6 +93,7 @@ class StandaloneAdmin {
?>
<!DOCTYPE html>
<html lang="<?php echo esc_attr(get_locale()); ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -118,6 +124,7 @@ class StandaloneAdmin {
<!-- WooNooW Assets -->
<link rel="stylesheet" href="<?php echo esc_url($css_url); ?>">
</head>
<body class="woonoow-standalone">
<div id="woonoow-admin-app"></div>
@@ -134,7 +141,8 @@ class StandaloneAdmin {
siteUrl: <?php echo wp_json_encode(home_url()); ?>,
siteName: <?php echo wp_json_encode(get_bloginfo('name')); ?>,
storeUrl: <?php echo wp_json_encode(self::get_spa_url()); ?>,
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
customerSpaEnabled: <?php echo get_option('woonoow_customer_spa_enabled', false) ? 'true' : 'false'; ?>,
onboardingCompleted: <?php echo (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1) ? 'true' : 'false'; ?>
};
// Also set WNW_API for API compatibility
@@ -169,6 +177,7 @@ class StandaloneAdmin {
<script type="module" src="<?php echo esc_url($js_url); ?>"></script>
</body>
</html>
<?php
}
@@ -178,7 +187,8 @@ class StandaloneAdmin {
*
* @return array Store settings (currency, decimals, separators, etc.)
*/
private static function get_store_settings(): array {
private static function get_store_settings(): array
{
// Get WooCommerce settings with fallbacks
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';

View File

@@ -122,7 +122,7 @@ class OnboardingController extends WP_REST_Controller
}
// 4. Mark as Complete
update_option('woonoow_onboarding_completed', true);
update_option('woonoow_onboarding_completed', 'yes');
return rest_ensure_response([
'success' => true,

View File

@@ -58,7 +58,7 @@ class PagesController
'permission_callback' => '__return_true',
]);
// Get/Save CPT templates
// Get/Save/Delete CPT templates
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
[
'methods' => 'GET',
@@ -70,6 +70,11 @@ class PagesController
'callback' => [__CLASS__, 'save_template'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
],
[
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_template'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
],
]);
// Get post with template applied (for SPA rendering)
@@ -337,6 +342,34 @@ class PagesController
], 200);
}
/**
* Delete CPT template (abort SPA handling for this post type)
*/
public static function delete_template(WP_REST_Request $request)
{
$cpt = $request->get_param('cpt');
// Validate CPT exists
if (!post_type_exists($cpt) && $cpt !== 'post') {
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
}
$option_key = "wn_template_{$cpt}";
$exists = get_option($option_key, null);
if ($exists === null) {
return new WP_Error('not_found', 'No template found for this post type', ['status' => 404]);
}
delete_option($option_key);
return new WP_REST_Response([
'success' => true,
'cpt' => $cpt,
'message' => 'Template deleted. WordPress will now handle this post type natively.',
], 200);
}
/**
* Get content with template applied (for SPA rendering)
*/
@@ -378,7 +411,37 @@ class PagesController
if ($template && !empty($template['sections'])) {
foreach ($template['sections'] as $section) {
$resolved_section = $section;
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
$props = $section['props'] ?? [];
foreach ($props as $key => $prop) {
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
$props[$key] = [
'type' => 'static',
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
];
}
}
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
// Resolve dynamicBackground in styles
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
$styles = $resolved_section['styles'] ?? [];
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
$dyn_source = $styles['dynamicBackground'];
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
$featured_url = $post_data['featured_image'] ?? '';
if (!empty($featured_url)) {
$styles['backgroundImage'] = $featured_url;
$styles['backgroundType'] = 'image';
}
}
// Remove the internal marker from the rendered output
unset($styles['dynamicBackground']);
$resolved_section['styles'] = $styles;
}
$rendered_sections[] = $resolved_section;
}
}

View File

@@ -46,6 +46,18 @@ class ProductsController
return trim($sanitized);
}
/**
* Sanitize rich text (allows HTML tags)
*/
private static function sanitize_rich_text($value)
{
if (!isset($value) || $value === '') {
return '';
}
$sanitized = wp_kses_post($value);
return trim($sanitized);
}
/**
* Sanitize numeric value
*/
@@ -335,8 +347,12 @@ class ProductsController
$product->set_slug(self::sanitize_slug($data['slug']));
}
$product->set_status(sanitize_key($data['status'] ?? 'publish'));
$product->set_description(self::sanitize_textarea($data['description'] ?? ''));
if (isset($data['description'])) {
$product->set_description(self::sanitize_rich_text($data['description'] ?? ''));
}
if (isset($data['short_description'])) {
$product->set_short_description(self::sanitize_textarea($data['short_description'] ?? ''));
}
if (!empty($data['sku'])) {
$product->set_sku(self::sanitize_text($data['sku']));
@@ -489,7 +505,7 @@ class ProductsController
if (isset($data['name'])) $product->set_name(self::sanitize_text($data['name']));
if (isset($data['slug'])) $product->set_slug(self::sanitize_slug($data['slug']));
if (isset($data['status'])) $product->set_status(sanitize_key($data['status']));
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
if (isset($data['description'])) $product->set_description(self::sanitize_rich_text($data['description']));
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
@@ -942,10 +958,17 @@ class ProductsController
$value = $term ? $term->name : $value;
}
} else {
// Custom attribute - stored as lowercase in meta
$meta_key = 'attribute_' . strtolower($attr_name);
// Custom attribute - stored as sanitize_title in meta
$sanitized_name = sanitize_title($attr_name);
$meta_key = 'attribute_' . $sanitized_name;
$value = get_post_meta($variation_id, $meta_key, true);
// Fallback to legacy lowercase if not found
if ($value === '') {
$meta_key_legacy = 'attribute_' . strtolower($attr_name);
$value = get_post_meta($variation_id, $meta_key_legacy, true);
}
// Capitalize the attribute name for display to match admin SPA
$clean_name = ucfirst($attr_name);
}
@@ -1029,8 +1052,27 @@ class ProductsController
foreach ($parent_attributes as $attr_name => $parent_attr) {
if (!$parent_attr->get_variation()) continue;
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
$wc_attributes[strtolower($attr_name)] = strtolower($value);
$is_match = false;
if (strpos($attr_name, 'pa_') === 0) {
$label = wc_attribute_label($attr_name);
if (strcasecmp($display_name, $label) === 0 || strcasecmp($display_name, $attr_name) === 0) {
$is_match = true;
}
} else {
// Custom attribute: Check exact name, or sanitized version
if (
strcasecmp($display_name, $attr_name) === 0 ||
strcasecmp($display_name, $parent_attr->get_name()) === 0 ||
sanitize_title($display_name) === sanitize_title($attr_name)
) {
$is_match = true;
}
}
if ($is_match) {
// WooCommerce expects the exact attribute slug as the key
$wc_attributes[$attr_name] = $value;
break;
}
}
@@ -1095,7 +1137,7 @@ class ProductsController
global $wpdb;
foreach ($wc_attributes as $attr_name => $attr_value) {
$meta_key = 'attribute_' . $attr_name;
$meta_key = 'attribute_' . sanitize_title($attr_name);
$wpdb->delete(
$wpdb->postmeta,

View File

@@ -1,4 +1,5 @@
<?php
/**
* Email Manager
*
@@ -9,7 +10,8 @@
namespace WooNooW\Core\Notifications;
class EmailManager {
class EmailManager
{
/**
* Instance
@@ -19,7 +21,8 @@ class EmailManager {
/**
* Get instance
*/
public static function instance() {
public static function instance()
{
if (null === self::$instance) {
self::$instance = new self();
}
@@ -29,14 +32,16 @@ class EmailManager {
/**
* Constructor
*/
private function __construct() {
private function __construct()
{
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
private function init_hooks()
{
// Disable WooCommerce emails to prevent duplicates
add_action('woocommerce_email', [$this, 'disable_wc_emails'], 1);
@@ -74,7 +79,8 @@ class EmailManager {
*
* @return bool
*/
public static function is_enabled() {
public static function is_enabled()
{
// Check global notification system mode
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
return $system_mode === 'woonoow';
@@ -85,7 +91,8 @@ class EmailManager {
*
* @param WC_Emails $email_class
*/
public function disable_wc_emails($email_class) {
public function disable_wc_emails($email_class)
{
// Only disable WC emails if WooNooW system is enabled
if (!self::is_enabled()) {
return; // Keep WC emails if WooNooW system disabled
@@ -117,7 +124,8 @@ class EmailManager {
* @param int $order_id
* @param WC_Order $order
*/
public function send_order_processing_email($order_id, $order = null) {
public function send_order_processing_email($order_id, $order = null)
{
if (defined('WP_DEBUG') && WP_DEBUG) {
}
@@ -151,7 +159,8 @@ class EmailManager {
* @param int $order_id
* @param WC_Order $order
*/
public function send_order_completed_email($order_id, $order = null) {
public function send_order_completed_email($order_id, $order = null)
{
if (!$order) {
$order = wc_get_order($order_id);
}
@@ -175,7 +184,8 @@ class EmailManager {
* @param int $order_id
* @param WC_Order $order
*/
public function send_order_on_hold_email($order_id, $order = null) {
public function send_order_on_hold_email($order_id, $order = null)
{
if (!$order) {
$order = wc_get_order($order_id);
}
@@ -199,7 +209,8 @@ class EmailManager {
* @param int $order_id
* @param WC_Order $order
*/
public function send_order_cancelled_email($order_id, $order = null) {
public function send_order_cancelled_email($order_id, $order = null)
{
if (!$order) {
$order = wc_get_order($order_id);
}
@@ -220,7 +231,8 @@ class EmailManager {
* @param int $order_id
* @param WC_Order $order
*/
public function send_order_refunded_email($order_id, $order = null) {
public function send_order_refunded_email($order_id, $order = null)
{
if (!$order) {
$order = wc_get_order($order_id);
}
@@ -243,7 +255,8 @@ class EmailManager {
*
* @param int $order_id
*/
public function send_new_order_admin_email($order_id) {
public function send_new_order_admin_email($order_id)
{
$order = wc_get_order($order_id);
if (!$order) {
@@ -264,7 +277,8 @@ class EmailManager {
*
* @param array $args
*/
public function send_customer_note_email($args) {
public function send_customer_note_email($args)
{
$order = wc_get_order($args['order_id']);
if (!$order) {
@@ -276,8 +290,8 @@ class EmailManager {
return;
}
// Send email with note data
$this->send_email('customer_note', 'customer', $order, ['note' => $args['customer_note']]);
// Send email with note data — key must match {customer_note} variable in template
$this->send_email('customer_note', 'customer', $order, ['customer_note' => $args['customer_note']]);
}
/**
@@ -287,7 +301,8 @@ class EmailManager {
* @param array $new_customer_data
* @param bool $password_generated
*/
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false) {
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false)
{
// Check if event is enabled
if (!$this->is_event_enabled('new_customer', 'email', 'customer')) {
return;
@@ -312,7 +327,8 @@ class EmailManager {
* @param WP_User $user_data User object
* @return string Empty string to prevent WordPress sending default email
*/
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
public function handle_password_reset_email($message, $key, $user_login, $user_data)
{
// Check if WooNooW notification system is enabled
if (!self::is_enabled()) {
return $message; // Use WordPress default
@@ -371,7 +387,8 @@ class EmailManager {
* @param string $reset_link Full reset link URL
* @param WC_Customer|null $customer WooCommerce customer object if available
*/
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
private function send_password_reset_email($user, $key, $reset_link, $customer = null)
{
// Get email renderer
$renderer = EmailRenderer::instance();
@@ -417,14 +434,18 @@ class EmailManager {
*
* @param WC_Product $product
*/
public function send_low_stock_email($product) {
public function send_low_stock_email($product)
{
// Check if event is enabled
if (!$this->is_event_enabled('low_stock', 'email', 'staff')) {
return;
}
// Send email
$this->send_email('low_stock', 'staff', $product);
// Pass low_stock_threshold so template can display it
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
$this->send_email('low_stock', 'staff', $product, [
'low_stock_threshold' => $low_stock_threshold,
]);
}
/**
@@ -432,7 +453,8 @@ class EmailManager {
*
* @param WC_Product $product
*/
public function send_out_of_stock_email($product) {
public function send_out_of_stock_email($product)
{
// Check if event is enabled
if (!$this->is_event_enabled('out_of_stock', 'email', 'staff')) {
return;
@@ -447,7 +469,8 @@ class EmailManager {
*
* @param WC_Product $product
*/
public function check_stock_levels($product) {
public function check_stock_levels($product)
{
$stock = $product->get_stock_quantity();
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
@@ -466,7 +489,8 @@ class EmailManager {
* @param string $recipient_type
* @return bool
*/
private function is_event_enabled($event_id, $channel_id, $recipient_type) {
private function is_event_enabled($event_id, $channel_id, $recipient_type)
{
$settings = get_option('woonoow_notification_settings', []);
// Check if event exists and channel is configured
@@ -493,7 +517,8 @@ class EmailManager {
* @param mixed $data
* @param array $extra_data
*/
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
private function send_email($event_id, $recipient_type, $data, $extra_data = [])
{
if (defined('WP_DEBUG') && WP_DEBUG) {
}

View File

@@ -227,6 +227,7 @@ class EmailRenderer
'payment_method' => $data->get_payment_method_title(),
'payment_status' => $data->get_status(),
'payment_date' => $payment_date,
'payment_error_reason' => $data->get_meta('_payment_error_reason') ?: 'Payment declined',
'transaction_id' => $data->get_transaction_id() ?: 'N/A',
'shipping_method' => $data->get_shipping_method(),
'estimated_delivery' => $estimated_delivery,
@@ -239,9 +240,12 @@ class EmailRenderer
'billing_address' => $data->get_formatted_billing_address(),
'shipping_address' => $data->get_formatted_shipping_address(),
// URLs
'review_url' => $data->get_view_order_url(), // Can be customized later
'review_url' => $data->get_view_order_url(),
'return_url' => $data->get_view_order_url(), // Customers click to initiate return
'contact_url' => home_url('/contact'),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'account_url' => get_permalink(wc_get_page_id('myaccount')), // Alias for my_account_url
'payment_retry_url' => $data->get_checkout_payment_url(),
// Tracking (if available from meta)
'tracking_number' => $data->get_meta('_tracking_number') ?: 'N/A',
@@ -249,6 +253,7 @@ class EmailRenderer
'shipping_carrier' => $data->get_meta('_shipping_carrier') ?: 'Standard Shipping',
]);
// Order items table
$items_html = '<table class="order-details" style="width: 100%; border-collapse: collapse;">';
$items_html .= '<thead><tr>';
@@ -277,9 +282,10 @@ class EmailRenderer
$items_html .= '</tbody></table>';
// Both naming conventions for compatibility
// All naming conventions for compatibility
$variables['order_items'] = $items_html;
$variables['order_items_table'] = $items_html;
$variables['order_items_list'] = $items_html; // Alias used in some templates
}
// Product variables

View File

@@ -19,6 +19,12 @@ class Assets
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
// Hide admin bar if configured
$settings = get_option('woonoow_appearance_settings', []);
if (!empty($settings['general']['hide_admin_bar'])) {
add_filter('show_admin_bar', '__return_false');
}
}
/**
@@ -115,13 +121,15 @@ class Assets
return;
}
// Check if we're in full mode and not on a page with shortcode
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
// Get appearance settings for unified spa_mode check
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
if ($mode === 'full') {
if ($spa_mode === 'full') {
// Only inject if the mount point doesn't already exist (from shortcode)
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
$current_path = parse_url($request_uri, PHP_URL_PATH);
echo '<div id="woonoow-customer-app" data-page="shop" data-initial-route="' . esc_attr($current_path) . '"><div class="woonoow-loading"><p>Loading...</p></div></div>';
}
}
@@ -227,6 +235,14 @@ class Assets
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// When injecting into a CPT or structural page, the URL does not start with the SPA base path.
// E.g., /desain-mockup... instead of /store/desain-mockup...
// For these pages, we must force the base path to empty so BrowserRouter starts from the root.
if (is_singular() && (!isset($spa_page) || get_queried_object_id() !== $spa_page->ID)) {
// If we're on a singular page that isn't the SPA Entry Page, it's a structural page or CPT
$base_path = '';
}
// Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -275,20 +291,27 @@ class Assets
}
/**
* Check if we should load customer-spa assets
* Check if we should load assets on this page
*/
private static function should_load_assets()
public static function should_load_assets()
{
global $post;
// Don't load on admin pages
if (is_admin()) {
return false;
}
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
// Force load if constant is defined (e.g. for preview)
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
return true;
}
// Check if we're on a frontpage SPA route (by URL detection)
if (self::is_frontpage_spa_route()) {
return true;
// Get SPA mode from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Check if SPA is completely disabled
if ($mode === 'disabled') {
return false;
}
// First check: Is this a designated SPA page?
@@ -296,39 +319,29 @@ class Assets
return true;
}
// Get SPA mode from appearance settings (the correct source)
$appearance_settings = get_option('woonoow_appearance_settings', []);
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// If disabled, only load for pages with shortcodes
if ($mode === 'disabled') {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
if (function_exists('is_shop') && is_shop()) {
$shop_page_id = get_option('woocommerce_shop_page_id');
if ($shop_page_id) {
$shop_page = get_post($shop_page_id);
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
// Check if we're on a frontpage SPA route
if (self::is_frontpage_spa_route()) {
return true;
}
// For structural pages (is_singular('page'))
if (is_singular('page')) {
$page_id = get_queried_object_id();
$structure = get_post_meta($page_id, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
return true;
}
}
// Check for shortcodes on regular pages
if ($post) {
if (has_shortcode($post->post_content, 'woonoow_shop')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_cart')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_account')) {
// For CPTs with WooNooW templates
if (is_singular() && !is_singular('page')) {
$post_type = get_post_type();
if (!in_array($post_type, ['product', 'shop_order', 'shop_coupon'])) {
$wn_template = get_option("wn_template_{$post_type}", null);
if (!empty($wn_template) && !empty($wn_template['sections'])) {
return true;
}
}
return false;
}
// Full SPA mode - load on all WooCommerce pages
@@ -353,6 +366,7 @@ class Assets
// Checkout-Only mode - load only on specific pages
if ($mode === 'checkout_only') {
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
@@ -370,6 +384,7 @@ class Assets
return false;
}
global $post;
// Check if current page has WooNooW shortcodes
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
return true;

View File

@@ -283,8 +283,8 @@ class ShopController
// Add detailed info if requested
if ($detailed) {
$data['description'] = $product->get_description();
$data['short_description'] = $product->get_short_description();
$data['description'] = wpautop($product->get_description());
$data['short_description'] = wpautop($product->get_short_description());
$data['sku'] = $product->get_sku();
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']);

View File

@@ -171,6 +171,11 @@ class TemplateOverride
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
add_rewrite_rule(
'^subscribe/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=subscribe',
'top'
);
// /order-pay/* → SPA page
add_rewrite_rule(
@@ -352,7 +357,6 @@ class TemplateOverride
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
return;
}
// Check if page has WooNooW structure
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
@@ -364,11 +368,6 @@ class TemplateOverride
}
}
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
* and serve the SPA template directly (bypasses WooCommerce templates)
*/
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
@@ -417,8 +416,8 @@ class TemplateOverride
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/register', // Register page
'/reset-password', // Password reset
'/subscribe', // Subscribe page
'/order-pay', // Order pay page
];
@@ -535,6 +534,32 @@ class TemplateOverride
}
}
// Check if it's a structural page with WooNooW sections
if (is_singular('page')) {
$page_id = get_queried_object_id();
$structure = get_post_meta($page_id, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
}
// Check if it's a CPT singular with a WooNooW template
if (is_singular() && !is_singular('page')) {
$post_type = get_post_type();
if ($post_type) {
$cpt_template = get_option("wn_template_{$post_type}", null);
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
}
}
// For spa_mode = 'full', override WooCommerce pages
if ($spa_mode === 'full') {
// Override all WooCommerce pages
@@ -569,23 +594,30 @@ class TemplateOverride
return;
}
// Determine page type
$page_type = 'shop';
// Determine page type and route
$data_attrs = 'data-page="shop"';
// Pass current request URI as initial route so router doesn't fallback to /shop
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
$current_path = parse_url($request_uri, PHP_URL_PATH);
$data_attrs .= ' data-initial-route="' . esc_attr($current_path) . '"';
if (is_product()) {
$page_type = 'product';
global $post;
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
$data_attrs .= ' data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
} elseif (is_cart()) {
$page_type = 'cart';
$data_attrs = 'data-page="cart"';
$data_attrs .= ' data-page="cart"';
} elseif (is_checkout()) {
$page_type = 'checkout';
$data_attrs = 'data-page="checkout"';
$data_attrs .= ' data-page="checkout"';
} elseif (is_account_page()) {
$page_type = 'account';
$data_attrs = 'data-page="account"';
$data_attrs .= ' data-page="account"';
} elseif (is_singular('page')) {
$data_attrs .= ' data-page="page"';
} elseif (is_singular() && !is_singular('page')) {
// CPT single item with a WooNooW template
global $post;
$post_type = get_post_type();
$data_attrs .= ' data-page="cpt" data-cpt-type="' . esc_attr($post_type) . '" data-cpt-slug="' . esc_attr($post->post_name) . '"';
}
// Output SPA mount point
@@ -631,6 +663,26 @@ class TemplateOverride
return true;
}
// For structural pages (is_singular('page'))
if (is_singular('page')) {
$page_id = get_queried_object_id();
$structure = get_post_meta($page_id, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) {
return true;
}
}
// For CPT singular items with a WooNooW template
if (is_singular() && !is_singular('page')) {
$post_type = get_post_type();
if ($post_type) {
$cpt_template = get_option("wn_template_{$post_type}", null);
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
return true;
}
}
}
return false;
}

View File

@@ -39,6 +39,13 @@ class TemplateRegistry
'description' => 'Simple contact page with a form and address details.',
'icon' => 'mail',
'sections' => self::get_contact_structure()
],
[
'id' => 'single-post',
'label' => 'Single Post / CPT',
'description' => 'A dynamic layout for blog posts or custom post types with a hero, featured image, and body content.',
'icon' => 'layout',
'sections' => self::get_single_post_structure()
]
]);
}
@@ -166,4 +173,73 @@ class TemplateRegistry
]
];
}
private static function get_single_post_structure()
{
return [
// ── Section 1: Article Hero ─────────────────────────────────────
[
'id' => self::generate_id(),
'type' => 'hero',
'layoutVariant' => 'centered',
'colorScheme' => 'default',
'props' => [
'title' => ['type' => 'dynamic', 'source' => 'post_title'],
'subtitle' => ['type' => 'dynamic', 'source' => 'post_author'],
'image' => ['type' => 'static', 'value' => ''],
'cta_text' => ['type' => 'static', 'value' => ''],
'cta_url' => ['type' => 'static', 'value' => ''],
],
// dynamicBackground tells the API to resolve styles.backgroundImage
// from 'post_featured_image' at render time (falls back to '' if no featured image)
'styles' => [
'contentWidth' => 'contained',
'heightPreset' => 'medium',
'dynamicBackground' => 'post_featured_image',
'backgroundOverlay' => 50,
],
],
// ── Section 2: Article Body ─────────────────────────────────────
[
'id' => self::generate_id(),
'type' => 'content',
'layoutVariant' => 'narrow',
'colorScheme' => 'default',
'props' => [
'content' => ['type' => 'dynamic', 'source' => 'post_content'],
'cta_text' => ['type' => 'static', 'value' => ''],
'cta_url' => ['type' => 'static', 'value' => ''],
],
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
],
// ── Section 3: Related Posts ────────────────────────────────────
[
'id' => self::generate_id(),
'type' => 'feature-grid',
'layoutVariant' => 'grid-3',
'colorScheme' => 'muted',
'props' => [
'heading' => ['type' => 'static', 'value' => 'Related Articles'],
'features' => ['type' => 'dynamic', 'source' => 'related_posts'],
],
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
],
// ── Section 4: CTA Banner ───────────────────────────────────────
[
'id' => self::generate_id(),
'type' => 'cta-banner',
'colorScheme' => 'gradient',
'props' => [
'title' => ['type' => 'static', 'value' => 'Enjoyed this article?'],
'text' => ['type' => 'static', 'value' => 'Subscribe to our newsletter and never miss an update.'],
'button_text' => ['type' => 'static', 'value' => 'Subscribe Now'],
'button_url' => ['type' => 'static', 'value' => '/subscribe'],
],
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'medium'],
],
];
}
}

View File

@@ -1,31 +1,54 @@
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php wp_title('|', true, 'right'); ?><?php bloginfo('name'); ?></title>
<?php wp_head(); ?>
</head>
<body <?php body_class('woonoow-spa-page'); ?>>
<?php
// Determine initial route based on SPA mode
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
// Get actual request path for accurate routing
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
$current_path = parse_url($request_uri, PHP_URL_PATH);
// Set initial page based on mode
if ($spa_mode === 'checkout_only') {
// Checkout Only mode starts at cart
$page_type = 'cart';
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
} else {
// Full SPA mode starts at shop
$page_type = 'shop';
// Evaluate WordPress page type to pass to React App
if (is_product()) {
global $post;
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
} elseif (is_cart()) {
$data_attrs = 'data-page="cart"';
} elseif (is_checkout()) {
$data_attrs = 'data-page="checkout"';
} elseif (is_account_page()) {
$data_attrs = 'data-page="account"';
} elseif (is_singular('page')) {
$data_attrs = 'data-page="page"';
} elseif (is_singular() && !is_singular('page')) {
global $post;
$post_type = get_post_type();
$data_attrs = 'data-page="cpt" data-cpt-type="' . esc_attr($post_type) . '" data-cpt-slug="' . esc_attr($post->post_name) . '"';
} else {
$data_attrs = 'data-page="shop"';
}
// If this is the front page, route to /
if (is_front_page()) {
$data_attrs = 'data-page="shop" data-initial-route="/"';
$data_attrs .= ' data-initial-route="/"';
} else {
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
$data_attrs .= ' data-initial-route="' . esc_attr($current_path) . '"';
}
}
?>
@@ -38,4 +61,5 @@
<?php wp_footer(); ?>
</body>
</html>

5
test_onboarding_val.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
require_once dirname(dirname(dirname(__DIR__))) . '/wp-load.php';
$val = get_option('woonoow_onboarding_completed', "NOT_FOUND");
echo "Type: " . gettype($val) . "\n";
echo "Value: " . var_export($val, true) . "\n";