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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -31,6 +32,7 @@ export default function AppearanceGeneral() {
const [customBody, setCustomBody] = useState(''); const [customBody, setCustomBody] = useState('');
const [fontScale, setFontScale] = useState([1.0]); const [fontScale, setFontScale] = useState([1.0]);
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed'); const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
const [hideAdminBar, setHideAdminBar] = useState(true);
const fontPairs = { const fontPairs = {
modern: { name: 'Modern & Clean', fonts: 'Inter' }, modern: { name: 'Modern & Clean', fonts: 'Inter' },
@@ -70,6 +72,9 @@ export default function AppearanceGeneral() {
if (general.container_width) { if (general.container_width) {
setContainerWidth(general.container_width); setContainerWidth(general.container_width);
} }
if (general.hide_admin_bar !== undefined) {
setHideAdminBar(!!general.hide_admin_bar);
}
if (general.colors) { if (general.colors) {
setColors({ setColors({
primary: general.colors.primary || '#1a1a1a', primary: general.colors.primary || '#1a1a1a',
@@ -116,6 +121,7 @@ export default function AppearanceGeneral() {
scale: fontScale[0], scale: fontScale[0],
}, },
containerWidth, containerWidth,
hideAdminBar,
colors, colors,
}); });
@@ -176,6 +182,28 @@ export default function AppearanceGeneral() {
</RadioGroup> </RadioGroup>
</SettingsCard> </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 */} {/* SPA Page */}
<SettingsCard <SettingsCard
title="SPA Page" title="SPA Page"

View File

@@ -81,7 +81,10 @@ export function CanvasSection({
> >
{/* Section content with Styles */} {/* Section content with Styles */}
<div <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={{ style={{
...(section.styles?.backgroundType === 'gradient' ...(section.styles?.backgroundType === 'gradient'
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` } ? { 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 */} {/* Content Wrapper */}
<div className={cn( {section.styles?.contentWidth === 'boxed' ? (
"relative z-10", <div className="relative z-10 container mx-auto px-4 max-w-5xl">
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full' <div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
)}> {children}
{children} </div>
</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> </div>
{/* Floating Toolbar (Standard Interaction) */} {/* Floating Toolbar (Standard Interaction) */}

View File

@@ -5,7 +5,6 @@ import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,8 +13,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { FileText, Layout, Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
interface PageItem { interface PageItem {
id?: number; id?: number;
@@ -23,83 +23,119 @@ interface PageItem {
cpt?: string; cpt?: string;
slug?: string; slug?: string;
title: string; title: string;
has_template?: boolean;
} }
interface CreatePageModalProps { interface CreatePageModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
cptList?: PageItem[];
onCreated: (page: PageItem) => void; onCreated: (page: PageItem) => void;
} }
export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) { export function CreatePageModal({ open, onOpenChange, cptList = [], onCreated }: CreatePageModalProps) {
const [pageType, setPageType] = useState<'page' | 'template'>('page'); const [mode, setMode] = useState<'page' | 'template'>('page');
// Structural page state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [slug, setSlug] = useState(''); const [slug, setSlug] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank'); const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
// CPT template state
const [selectedCpt, setSelectedCpt] = useState<string>('');
const [selectedCptPreset, setSelectedCptPreset] = useState<string>('single-post');
// Prevent double submission // Prevent double submission
const isSubmittingRef = useRef(false); const isSubmittingRef = useRef(false);
// Get site URL from WordPress config
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin; const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
// Fetch templates // Fetch template presets
const { data: templates = [] } = useQuery({ const { data: templates = [] } = useQuery({
queryKey: ['templates-presets'], queryKey: ['templates-presets'],
queryFn: async () => { queryFn: async () => {
const res = await api.get('/templates/presets'); 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 // CPTs that don't have a template yet
const createMutation = useMutation({ const availableCpts = cptList.filter(p => p.type === 'template' && !p.has_template);
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;
// 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 { try {
// api.post returns JSON directly (not wrapped in { data: ... })
const response = await api.post('/pages', { const response = await api.post('/pages', {
title: data.title, title: data.title,
slug: data.slug, 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 { } finally {
// Reset after a delay to prevent race conditions setTimeout(() => { isSubmittingRef.current = false; }, 500);
setTimeout(() => {
isSubmittingRef.current = false;
}, 500);
} }
}, },
onSuccess: (data) => { onSuccess: (result) => {
if (data?.page) { if (result?.data?.page) {
toast.success(__('Page created successfully')); toast.success(__('Page created successfully'));
onCreated({ onCreated({
id: data.page.id, id: result.data.page.id,
type: 'page', type: result.type,
slug: data.page.slug, slug: result.data.page.slug,
title: data.page.title, title: result.data.page.title,
}); });
onOpenChange(false); onOpenChange(false);
setTitle('');
setSlug('');
setSelectedTemplateId('blank');
} }
}, },
onError: (error: any) => { 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') { const message = error?.response?.data?.message || error?.message || __('Failed to create page');
return; 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 || onSuccess: (result) => {
error?.message || toast.success(__('Template created successfully'));
__('Failed to create page'); // 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); toast.error(message);
}, },
}); });
@@ -107,35 +143,48 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
// Auto-generate slug from title // Auto-generate slug from title
const handleTitleChange = (value: string) => { const handleTitleChange = (value: string) => {
setTitle(value); 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, ''); const autoSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
if (!slug || slug === autoSlug) { if (!slug || slug === autoSlug) {
setSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')); 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 // Reset form when modal closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setMode('page');
setTitle(''); setTitle('');
setSlug(''); setSlug('');
setPageType('page');
setSelectedTemplateId('blank'); setSelectedTemplateId('blank');
setSelectedCpt('');
setSelectedCptPreset('single-post');
isSubmittingRef.current = false; isSubmittingRef.current = false;
} }
}, [open]); }, [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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -147,42 +196,15 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6 px-6 py-4"> <div className="px-6 py-4">
{/* Page Type Selection */} <Tabs value={mode} onValueChange={(v) => setMode(v as 'page' | 'template')}>
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4"> <TabsList className="w-full grid grid-cols-2 mb-6">
<div <TabsTrigger value="page">{__('Structural Page')}</TabsTrigger>
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'}`} <TabsTrigger value="template">{__('CPT Template')}</TabsTrigger>
onClick={() => setPageType('page')} </TabsList>
>
<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="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative"> {/* ── Structural Page Tab ── */}
<RadioGroupItem value="template" id="template" className="mt-1" disabled /> <TabsContent value="page" className="space-y-6 mt-0">
<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">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label> <Label htmlFor="title">{__('Page Title')}</Label>
@@ -191,7 +213,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
value={title} value={title}
onChange={(e) => handleTitleChange(e.target.value)} onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')} placeholder={__('e.g., About Us')}
disabled={createMutation.isPending} disabled={isPending}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -201,7 +223,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
value={slug} value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')} placeholder={__('e.g., about-us')}
disabled={createMutation.isPending} disabled={isPending}
/> />
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span> <span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
@@ -210,9 +232,9 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
</div> </div>
<div className="space-y-3"> <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"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{templates.map((tpl) => ( {pagePresets.map((tpl: any) => (
<div <div
key={tpl.id} key={tpl.id}
className={` className={`
@@ -221,40 +243,80 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
`} `}
onClick={() => setSelectedTemplateId(tpl.id)} onClick={() => setSelectedTemplateId(tpl.id)}
> >
<div className="mb-2 font-medium text-sm flex items-center gap-2"> <div className="mb-2 font-medium text-sm">{tpl.label}</div>
{tpl.label} <p className="text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
</div> </div>
))} ))}
{templates.length === 0 && (
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
{__('Loading templates...')}
</div>
)}
</div> </div>
</div> </div>
</div> </TabsContent>
)}
{/* ── CPT Template Tab ── */}
<TabsContent value="template" className="space-y-6 mt-0">
{availableCpts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm space-y-2">
<p className="font-medium">{__('All post types already have a template.')}</p>
<p className="text-xs">{__('Abort an existing template first to create a new one.')}</p>
</div>
) : (
<>
<div className="space-y-2">
<Label>{__('Select Post Type')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{availableCpts.map((cpt) => (
<div
key={cpt.cpt}
className={`
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
${selectedCpt === cpt.cpt ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
`}
onClick={() => setSelectedCpt(cpt.cpt || '')}
>
<div className="font-medium text-sm">{cpt.title}</div>
{cpt.cpt && (
<p className="text-xs text-muted-foreground mt-1 font-mono">/{cpt.cpt}/</p>
)}
</div>
))}
</div>
</div>
<div className="space-y-3">
<Label>{__('Starting Layout')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{cptPresets.map((tpl: any) => (
<div
key={tpl.id}
className={`
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
${selectedCptPreset === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
`}
onClick={() => setSelectedCptPreset(tpl.id)}
>
<div className="font-medium text-sm">{tpl.label}</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{tpl.description}</p>
</div>
))}
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={createMutation.isPending}> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
{__('Cancel')} {__('Cancel')}
</Button> </Button>
<Button <Button onClick={handleSubmit} disabled={isDisabled}>
onClick={handleSubmit} {isPending ? (
disabled={isDisabled}
>
{createMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{__('Creating...')} {__('Creating...')}
</> </>
) : ( ) : (
__('Create Page') mode === 'page' ? __('Create Page') : __('Create Template')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -54,6 +54,10 @@ export function InspectorField({
} }
}; };
const handleSelectChange = (val: string) => {
onChange({ type: 'dynamic', source: val });
};
const handleTypeToggle = (dynamic: boolean) => { const handleTypeToggle = (dynamic: boolean) => {
if (dynamic) { if (dynamic) {
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' }); onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
@@ -85,18 +89,20 @@ export function InspectorField({
</div> </div>
{isDynamic && supportsDynamic ? ( {isDynamic && supportsDynamic ? (
<Select value={currentValue} onValueChange={handleValueChange}> <div className="space-y-2">
<SelectTrigger className="w-full"> <Select value={currentValue} onValueChange={handleSelectChange}>
<SelectValue placeholder="Select data source" /> <SelectTrigger className="w-full">
</SelectTrigger> <SelectValue placeholder="Select data source" />
<SelectContent> </SelectTrigger>
{availableSources.map((source) => ( <SelectContent>
<SelectItem key={source.value} value={source.value}> {availableSources.map((source) => (
{source.label} <SelectItem key={source.value} value={source.value}>
</SelectItem> {source.label}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
</div>
) : fieldType === 'rte' ? ( ) : fieldType === 'rte' ? (
<RichTextEditor <RichTextEditor
content={currentValue} content={currentValue}

View File

@@ -60,6 +60,7 @@ interface InspectorPanelProps {
onSetAsSpaLanding?: () => void; onSetAsSpaLanding?: () => void;
onUnsetSpaLanding?: () => void; onUnsetSpaLanding?: () => void;
onDeletePage?: () => void; onDeletePage?: () => void;
onDeleteTemplate?: () => void;
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void; onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
} }
@@ -127,7 +128,6 @@ const COLOR_SCHEMES = [
{ value: 'primary', label: 'Primary' }, { value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' }, { value: 'secondary', label: 'Secondary' },
{ value: 'muted', label: 'Muted' }, { value: 'muted', label: 'Muted' },
{ value: 'gradient', label: 'Gradient' },
]; ];
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = { const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
@@ -183,6 +183,7 @@ export function InspectorPanel({
onSetAsSpaLanding, onSetAsSpaLanding,
onUnsetSpaLanding, onUnsetSpaLanding,
onDeletePage, onDeletePage,
onDeleteTemplate,
onContainerWidthChange, onContainerWidthChange,
}: InspectorPanelProps) { }: InspectorPanelProps) {
if (isCollapsed) { if (isCollapsed) {
@@ -306,6 +307,25 @@ export function InspectorPanel({
</Button> </Button>
</div> </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>
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed"> <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.')} {__('Select any section on the canvas to edit its content and design.')}
@@ -433,21 +453,32 @@ export function InspectorPanel({
</div> </div>
{/* Feature Grid Repeater */} {/* Feature Grid Repeater */}
{selectedSection.type === 'feature-grid' && ( {selectedSection.type === 'feature-grid' && (() => {
<div className="pt-4 border-t"> const featuresProp = selectedSection.props.features;
<InspectorRepeater const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
label={__('Features')} const items = Array.isArray(featuresProp?.value) ? featuresProp.value : [];
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []} return (
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })} <div className="pt-4 border-t">
fields={[ <InspectorRepeater
{ name: 'title', label: 'Title', type: 'text' }, label={__('Features')}
{ name: 'description', label: 'Description', type: 'textarea' }, items={items}
{ name: 'icon', label: 'Icon', type: 'icon' }, onChange={(newItems) => onSectionPropChange('features', { type: 'static', value: newItems })}
]} fields={[
itemLabelKey="title" { name: 'title', label: 'Title', type: 'text' },
/> { name: 'description', label: 'Description', type: 'textarea' },
</div> { name: 'icon', label: 'Icon', type: 'icon' },
)} ]}
itemLabelKey="title"
isDynamic={isDynamicFeatures}
dynamicLabel={
isDynamicFeatures
? `⚡ Auto-populated from "${featuresProp.source}" at runtime`
: undefined
}
/>
</div>
);
})()}
</TabsContent> </TabsContent>
{/* Design Tab */} {/* Design Tab */}
@@ -571,48 +602,90 @@ export function InspectorPanel({
)} )}
{/* Image Background */} {/* Image Background */}
{selectedSection.styles?.backgroundType === 'image' && ( {selectedSection.styles?.backgroundType === 'image' && (() => {
<> const isDynamicBg = selectedSection.styles?.dynamicBackground === 'post_featured_image';
<div className="space-y-2"> return (
<Label className="text-xs">{__('Background Image')}</Label> <>
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}> {/* Source toggle: Upload vs Featured Image */}
{selectedSection.styles?.backgroundImage ? ( <div className="space-y-2">
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50"> <Label className="text-xs">{__('Background Image')}</Label>
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" /> <div className="flex gap-1 p-0.5 bg-gray-100 rounded-md">
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <button
<span className="text-white text-xs font-medium">{__('Change')}</span> onClick={() => onSectionStylesChange({ dynamicBackground: undefined })}
</div> className={cn(
<button 'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }} !isDynamicBg
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" ? 'bg-white shadow-sm font-medium text-gray-900'
> : 'text-gray-500 hover:text-gray-700'
<Trash2 className="w-3 h-3" /> )}
</button> >
</div> Upload Image
) : ( </button>
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal"> <button
<Palette className="w-6 h-6" /> onClick={() => onSectionStylesChange({ dynamicBackground: 'post_featured_image', backgroundImage: '' })}
{__('Select Image')} className={cn(
</Button> 'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
)} isDynamicBg
</MediaUploader> ? 'bg-white shadow-sm font-medium text-gray-900'
</div> : 'text-gray-500 hover:text-gray-700'
)}
<div className="space-y-1 pt-2"> >
<div className="flex items-center justify-between"> Featured Image
<Label className="text-xs">{__('Overlay Opacity')}</Label> </button>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span> </div>
</div> </div>
<Slider
value={[selectedSection.styles?.backgroundOverlay ?? 0]} {/* Static upload */}
min={0} {!isDynamicBg && (
max={100} <div className="space-y-2">
step={5} <MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })} {selectedSection.styles?.backgroundImage ? (
/> <div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
</div> <img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
</> <div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
)} <span className="text-white text-xs font-medium">{__('Change')}</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
) : (
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
<Palette className="w-6 h-6" />
{__('Select Image')}
</Button>
)}
</MediaUploader>
</div>
)}
{/* Dynamic source info */}
{isDynamicBg && (
<div className="flex items-start gap-2 text-xs bg-blue-50 border border-blue-200 rounded-md p-2.5 text-blue-700">
<span className="mt-0.5"></span>
<span>At runtime, the background will use this post's featured image. Falls back to no background if no featured image is set.</span>
</div>
)}
<div className="space-y-1 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{__('Overlay Opacity')}</Label>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
</div>
<Slider
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
min={0}
max={100}
step={5}
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
/>
</div>
</>
);
})()}
{/* Spacing Controls */} {/* Spacing Controls */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4"> <div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
@@ -643,7 +716,7 @@ export function InspectorPanel({
<RadioGroup <RadioGroup
value={selectedSection.styles?.contentWidth || 'full'} value={selectedSection.styles?.contentWidth || 'full'}
onValueChange={(val: any) => onSectionStylesChange({ contentWidth: val })} onValueChange={(val: any) => onSectionStylesChange({ contentWidth: val })}
className="flex gap-4" className="flex flex-wrap gap-4"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="full" id="width-full" /> <RadioGroupItem value="full" id="width-full" />
@@ -653,6 +726,10 @@ export function InspectorPanel({
<RadioGroupItem value="contained" id="width-contained" /> <RadioGroupItem value="contained" id="width-contained" />
<Label htmlFor="width-contained" className="text-sm font-normal cursor-pointer">{__('Contained')}</Label> <Label htmlFor="width-contained" className="text-sm font-normal cursor-pointer">{__('Contained')}</Label>
</div> </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> </RadioGroup>
</div> </div>

View File

@@ -49,6 +49,8 @@ interface InspectorRepeaterProps {
fields: RepeaterFieldDef[]; fields: RepeaterFieldDef[];
onChange: (items: any[]) => void; onChange: (items: any[]) => void;
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title') 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 // 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 // Generate simple stable IDs for sorting if items don't have them
const itemIds = items.map((_, i) => `item-${i}`); 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="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label> <Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}> {!isDynamic && (
<Plus className="w-3 h-3 mr-1" /> <Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
Add Item <Plus className="w-3 h-3 mr-1" />
</Button> Add Item
</Button>
)}
</div> </div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
@@ -224,8 +228,15 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab
</Accordion> </Accordion>
{items.length === 0 && ( {items.length === 0 && (
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50"> <div className={cn(
No items yet. Click "Add Item" to start. "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>
)} )}
</div> </div>

View File

@@ -14,7 +14,7 @@ interface PageSidebarProps {
export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) { export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) {
const structuralPages = pages.filter(p => p.type === 'page'); 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) { if (isLoading) {
return ( return (
@@ -69,24 +69,28 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
{__('Templates')} {__('Templates')}
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
{templates.map((template) => ( {templates.length === 0 ? (
<button <p className="text-sm text-gray-400 italic">{__('No templates yet')}</p>
key={`template-${template.cpt}`} ) : (
onClick={() => onSelectPage(template)} templates.map((template) => (
className={cn( <button
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors', key={`template-${template.cpt}`}
'hover:bg-gray-100', onClick={() => onSelectPage(template)}
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template' className={cn(
? 'bg-primary/10 text-primary font-medium' 'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
: 'text-gray-700' 'hover:bg-gray-100',
)} selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
> ? 'bg-primary/10 text-primary font-medium'
<span className="block">{template.title}</span> : 'text-gray-700'
{template.permalink_base && ( )}
<span className="text-xs text-gray-400">{template.permalink_base}*</span> >
)} <span className="block">{template.title}</span>
</button> {template.permalink_base && (
))} <span className="text-xs text-gray-400">{template.permalink_base}*</span>
)}
</button>
))
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ interface Section {
colorScheme?: string; colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>; props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>; elementStyles?: Record<string, any>;
styles?: any;
} }
interface CTABannerRendererProps { 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' }, 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' }, 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' }, 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) { 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 title = section.props?.title?.value || 'Ready to get started?';
const text = section.props?.text?.value || 'Join thousands of happy customers today.'; const text = section.props?.text?.value || 'Join thousands of happy customers today.';
const buttonText = section.props?.button_text?.value || 'Get Started'; const buttonText = section.props?.button_text?.value || 'Get Started';
const buttonUrl = section.props?.button_url?.value || '#'; 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) // Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => { const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {}; const styles = section.elementStyles?.[elementName] || {};
@@ -56,8 +66,10 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
const textStyle = getTextStyles('text'); const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text'); const btnStyle = getTextStyles('button_text');
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return ( 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"> <div className="max-w-4xl mx-auto text-center space-y-6">
<h2 <h2
className={cn( className={cn(

View File

@@ -9,6 +9,7 @@ interface Section {
colorScheme?: string; colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>; props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>; elementStyles?: Record<string, any>;
styles?: any;
} }
interface ContactFormRendererProps { 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' }, 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' }, 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' }, 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) { 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'; const title = section.props?.title?.value || 'Contact Us';
@@ -69,10 +69,22 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
return undefined; 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 ( return (
<div <div
className={cn('py-12 px-4 md:py-20 md:px-8', !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)}
style={getBackgroundStyle()} style={hasCustomBackground ? {} : getBackgroundStyle()}
> >
<div className="max-w-xl mx-auto"> <div className="max-w-xl mx-auto">
<h2 <h2

View File

@@ -15,7 +15,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
primary: { bg: 'wn-primary-bg', text: 'text-white' }, primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' }, secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' }, muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
}; };
const WIDTH_CLASSES: Record<string, string> = { const WIDTH_CLASSES: Record<string, string> = {
@@ -152,7 +151,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
}; };
export function ContentRenderer({ section, className }: ContentRendererProps) { 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 layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default; const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
@@ -211,17 +210,20 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
return undefined; return undefined;
}; };
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
return ( return (
<div <div
id={`section-${section.id}`} id={`section-${section.id}`}
className={cn( className={cn(
'relative w-full overflow-hidden',
'px-4 md:px-8', 'px-4 md:px-8',
heightClasses, heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg, !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text, scheme.text,
className className
)} )}
style={getBackgroundStyle()} style={hasCustomBackground ? {} : getBackgroundStyle()}
> >
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} /> <style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />

View File

@@ -1,5 +1,6 @@
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Image, Calendar, User } from 'lucide-react';
interface Section { interface Section {
id: string; id: string;
@@ -8,6 +9,7 @@ interface Section {
colorScheme?: string; colorScheme?: string;
props: Record<string, any>; props: Record<string, any>;
elementStyles?: Record<string, any>; elementStyles?: Record<string, any>;
styles?: any;
} }
interface FeatureGridRendererProps { 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' }, primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
secondary: { bg: 'wn-secondary-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' }, 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> = { 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', '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 = [ const DEFAULT_FEATURES = [
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' }, { title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' }, { title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' }, { 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) { 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 layout = section.layoutVariant || 'grid-3';
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3']; const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
const heading = section.props?.heading?.value || 'Our Features'; 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) // Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => { const getTextStyles = (elementName: string) => {
@@ -81,10 +119,22 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
return undefined; 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 ( return (
<div <div
className={cn('py-12 px-4 md:py-20 md:px-8', !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)}
style={getBackgroundStyle()} style={hasCustomBackground ? {} : getBackgroundStyle()}
> >
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{heading && ( {heading && (
@@ -99,47 +149,56 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
</h2> </h2>
)} )}
<div className={cn('grid gap-8', gridClass)}> {/* Dynamic (related posts) — show post-card skeleton placeholders */}
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => { {isDynamic ? (
// Resolve icon from name, fallback to Star <div className={cn('grid gap-8', gridClass)}>
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star; {Array.from({ length: placeholderCount }).map((_, i) => (
<PostCardPlaceholder key={i} index={i} cardBg={scheme.cardBg} />
return ( ))}
<div </div>
key={index} ) : (
className={cn( /* Static items — regular icon feature cards */
'p-6 rounded-xl text-center', <div className={cn('grid gap-8', gridClass)}>
!featureItemStyle.style?.backgroundColor && scheme.cardBg, {(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
featureItemStyle.classNames const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
)} return (
style={featureItemStyle.style} <div
> key={index}
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center"> className={cn(
<IconComponent className="w-7 h-7 text-blue-600" /> 'p-6 rounded-xl text-center',
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
<IconComponent className="w-7 h-7 text-blue-600" />
</div>
<h3
className={cn(
"mb-2",
!featureItemStyle.style?.color && "text-lg font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.title || `Feature ${index + 1}`}
</h3>
<p
className={cn(
"text-sm",
!featureItemStyle.style?.color && "opacity-80"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.description || 'Feature description goes here'}
</p>
</div> </div>
<h3 );
className={cn( })}
"mb-2", </div>
!featureItemStyle.style?.color && "text-lg font-semibold" )}
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.title || `Feature ${index + 1}`}
</h3>
<p
className={cn(
"text-sm",
!featureItemStyle.style?.color && "opacity-80"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.description || 'Feature description goes here'}
</p>
</div>
);
})}
</div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ interface Section {
colorScheme?: string; colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>; props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>; elementStyles?: Record<string, any>;
styles?: any;
} }
interface HeroRendererProps { interface HeroRendererProps {
@@ -20,13 +21,22 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
primary: { bg: 'wn-primary-bg', text: 'text-white' }, primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' }, secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' }, muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
}; };
export function HeroRenderer({ section, className }: HeroRendererProps) { 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 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 title = section.props?.title?.value || 'Hero Title';
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here'; const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
const image = section.props?.image?.value; const image = section.props?.image?.value;
@@ -66,12 +76,12 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
// Helper for image styles // Helper for image styles
const imageStyle = section.elementStyles?.['image'] || {}; 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') { if (layout === 'hero-left-image' || layout === 'hero-right-image') {
return ( return (
<div <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( <div className={cn(
'max-w-6xl mx-auto flex items-center gap-12', 'max-w-6xl mx-auto flex items-center gap-12',
@@ -146,7 +156,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
// Default centered layout // Default centered layout
return ( return (
<div <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"> <div className="max-w-4xl mx-auto space-y-6">
<h1 <h1

View File

@@ -14,11 +14,10 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
primary: { bg: 'wn-primary-bg', text: 'text-white' }, primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' }, secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' }, muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
}; };
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) { 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 layout = section.layoutVariant || 'image-left';
const isImageRight = layout === 'image-right'; const isImageRight = layout === 'image-right';
@@ -73,10 +72,22 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
return undefined; 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 ( return (
<div <div
className={cn('py-12 px-4 md:py-20 md:px-8', !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)}
style={getBackgroundStyle()} style={hasCustomBackground ? {} : getBackgroundStyle()}
> >
<div className={cn( <div className={cn(
'max-w-6xl mx-auto flex items-center gap-12', '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 // Set as SPA Landing mutation
const setSpaLandingMutation = useMutation({ const setSpaLandingMutation = useMutation({
mutationFn: async (id: number) => { 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 ( return (
<div className={ <div className={
cn( cn(
@@ -358,6 +382,7 @@ export default function AppearancePages() {
}} }}
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()} onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
onDeletePage={handleDeletePage} onDeletePage={handleDeletePage}
onDeleteTemplate={handleDeleteTemplate}
onContainerWidthChange={(width) => { onContainerWidthChange={(width) => {
if (currentPage) { if (currentPage) {
setCurrentPage({ ...currentPage, containerWidth: width }); setCurrentPage({ ...currentPage, containerWidth: width });
@@ -373,6 +398,7 @@ export default function AppearancePages() {
< CreatePageModal < CreatePageModal
open={showCreateModal} open={showCreateModal}
onOpenChange={setShowCreateModal} onOpenChange={setShowCreateModal}
cptList={pages.filter((p: PageItem) => p.type === 'template')}
onCreated={(newPage) => { onCreated={(newPage) => {
queryClient.invalidateQueries({ queryKey: ['pages'] }); queryClient.invalidateQueries({ queryKey: ['pages'] });
setCurrentPage(newPage); setCurrentPage(newPage);

View File

@@ -19,8 +19,9 @@ export interface SectionStyles {
backgroundOverlay?: number; // 0-100 opacity backgroundOverlay?: number; // 0-100 opacity
paddingTop?: string; paddingTop?: string;
paddingBottom?: string; paddingBottom?: string;
contentWidth?: 'full' | 'contained'; contentWidth?: 'full' | 'contained' | 'boxed';
heightPreset?: string; heightPreset?: string;
dynamicBackground?: string; // e.g. 'post_featured_image'
} }
export interface ElementStyle { export interface ElementStyle {

View File

@@ -13,6 +13,7 @@ export default function AppearanceProduct() {
const [imagePosition, setImagePosition] = useState('left'); const [imagePosition, setImagePosition] = useState('left');
const [galleryStyle, setGalleryStyle] = useState('thumbnails'); const [galleryStyle, setGalleryStyle] = useState('thumbnails');
const [stickyAddToCart, setStickyAddToCart] = useState(false); const [stickyAddToCart, setStickyAddToCart] = useState(false);
const [layoutStyle, setLayoutStyle] = useState('flat');
const [elements, setElements] = useState({ const [elements, setElements] = useState({
breadcrumbs: true, breadcrumbs: true,
@@ -40,6 +41,7 @@ export default function AppearanceProduct() {
if (product.layout.image_position) setImagePosition(product.layout.image_position); if (product.layout.image_position) setImagePosition(product.layout.image_position);
if (product.layout.gallery_style) setGalleryStyle(product.layout.gallery_style); 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.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) { if (product.elements) {
setElements({ setElements({
@@ -80,7 +82,8 @@ export default function AppearanceProduct() {
layout: { layout: {
image_position: imagePosition, image_position: imagePosition,
gallery_style: galleryStyle, gallery_style: galleryStyle,
sticky_add_to_cart: stickyAddToCart sticky_add_to_cart: stickyAddToCart,
layout_style: layoutStyle,
}, },
elements, elements,
related_products: { related_products: {
@@ -106,6 +109,23 @@ export default function AppearanceProduct() {
title="Layout" title="Layout"
description="Configure product page layout and gallery" 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"> <SettingsSection label="Image Position" htmlFor="image-position">
<Select value={imagePosition} onValueChange={setImagePosition}> <Select value={imagePosition} onValueChange={setImagePosition}>
<SelectTrigger id="image-position"> <SelectTrigger id="image-position">

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ interface SharedContentProps {
imagePosition?: 'left' | 'right' | 'top' | 'bottom'; imagePosition?: 'left' | 'right' | 'top' | 'bottom';
// Layout // Layout
containerWidth?: 'full' | 'contained'; containerWidth?: 'full' | 'contained' | 'boxed';
// Styles // Styles
className?: string; className?: string;
@@ -53,15 +53,19 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
const isImageTop = imagePosition === 'top'; const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom'; 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( const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8', '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( const gridClasses = cn(
'mx-auto', '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'; const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
@@ -74,82 +78,161 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
<div className={gridClasses}> {containerWidth === 'boxed' ? (
{/* Image Side */} <div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
{hasImage && ( <div className={gridClasses}>
<div className={cn( {/* Image Side */}
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg', {hasImage && (
imageWrapperOrder, <div className={cn(
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked 'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
)} style={imageStyle}> imageWrapperOrder,
<img (isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
src={image} )} style={imageStyle}>
alt={title || 'Section Image'} <img
className="absolute inset-0 w-full h-full object-cover" 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>
)} </div>
) : (
{/* Content Side */} <div className={gridClasses}>
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}> {/* Image Side */}
{title && ( {hasImage && (
<h2 <div className={cn(
className={cn( 'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
"tracking-tight text-current mb-6", imageWrapperOrder,
!titleClassName && "text-3xl font-bold sm:text-4xl", (isImageTop || isImageBottom) && 'mb-8'
titleClassName )} style={imageStyle}>
)} <img
style={titleStyle} src={image}
> alt={title || 'Section Image'}
{title} className="absolute inset-0 w-full h-full object-cover"
</h2> />
)}
{text && (
<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-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>
)} )}
{/* 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>
</div> )}
</div> </div>
); );
}; };

View File

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

View File

@@ -12,7 +12,7 @@ export interface SectionStyleResult {
*/ */
export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult { export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult {
if (!styles) { if (!styles) {
return { style: {}, hasOverlay: false, overlayStyle: undefined }; return { style: {}, hasOverlay: false, overlayOpacity: 0 };
} }
const bgType = styles.backgroundType || 'solid'; const bgType = styles.backgroundType || 'solid';
@@ -56,3 +56,30 @@ export function getSectionBackground(styles?: Record<string, any>): SectionStyle
return { style, hasOverlay, overlayOpacity, backgroundImage }; 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 { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { cn } from '@/lib/utils';
// Section Components // Section Components
import { HeroSection } from './sections/HeroSection'; import { HeroSection } from './sections/HeroSection';
@@ -121,14 +122,25 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
const navigate = useNavigate(); const navigate = useNavigate();
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
// Use prop slug if provided, otherwise use param slug // Get page type from DOM (injected by TemplateOverride.php)
const effectiveSlug = propSlug || paramSlug; 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 // 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 = dataPageType === 'page' || dataPageType === 'shop' || contentType === undefined;
const isStructuralPage = !pathBase || !!propSlug;
const contentType = pathBase === 'blog' ? 'post' : pathBase; const contentSlug = effectiveSlug;
const contentSlug = effectiveSlug || '';
// Fetch page/content data // Fetch page/content data
const { data: pageData, isLoading, error } = useQuery<PageData>({ 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 // Fetch structural page - api.get returns JSON directly
const response = await api.get<PageData>(`/pages/${contentSlug}`); const response = await api.get<PageData>(`/pages/${contentSlug}`);
return response; return response;
} else { } else if (contentType) {
// Fetch CPT content with template // Fetch CPT content with template
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`); const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
return response; return response;
} }
throw new Error("Unable to determine content type");
}, },
retry: false, retry: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes 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"> <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> <h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
<p className="text-gray-600 mb-8">Page not found</p> <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 <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90" 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 ( return (
<div <div
key={section.id} key={section.id}
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`} className="relative overflow-hidden"
style={{ style={{
backgroundColor: section.styles?.backgroundColor, // Only explicit custom padding overrides from the padding fields
paddingTop: section.styles?.paddingTop, paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom, paddingBottom: section.styles?.paddingBottom,
}} }}
> >
{/* Background Image & Overlay */} {/* Full-bleed background image & overlay */}
{section.styles?.backgroundImage && ( {section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
<> <>
<div <div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat" 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 */} {/* Section component — manages its own background, height, and inner content width */}
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}> <div className="relative z-10 w-full">
<SectionComponent <SectionComponent
id={section.id} id={section.id}
section={section} // Pass full section object for components that need raw data section={section}
layout={section.layoutVariant || 'default'} layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'} colorScheme={section.colorScheme || 'default'}
styles={section.styles} styles={section.styles}

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface CTABannerSectionProps { interface CTABannerSectionProps {
id: string; id: string;
@@ -22,26 +23,34 @@ export function CTABannerSection({
elementStyles, elementStyles,
styles, styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) { }: 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 getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {}; const es = elementStyles?.[elementName] || {};
return { return {
classNames: cn( classNames: cn(
styles.fontSize, es.fontSize,
styles.fontWeight, es.fontWeight,
{ {
'font-sans': styles.fontFamily === 'secondary', 'font-sans': es.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary', 'font-serif': es.fontFamily === 'primary',
} }
), ),
style: { style: {
color: styles.color, color: es.color,
textAlign: styles.textAlign, textAlign: es.textAlign,
backgroundColor: styles.backgroundColor, backgroundColor: es.backgroundColor,
borderColor: styles.borderColor, borderColor: es.borderColor,
borderWidth: styles.borderWidth, borderWidth: es.borderWidth,
borderRadius: styles.borderRadius, borderRadius: es.borderRadius,
} }
}; };
}; };
@@ -49,6 +58,83 @@ export function CTABannerSection({
const titleStyle = getTextStyles('title'); const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text'); const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text'); const btnStyle = getTextStyles('button_text');
// 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 lg:text-5xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
{text && (
<p className={cn(
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
styles?.contentWidth !== 'boxed' && {
'text-white/90': colorScheme === 'primary',
'text-gray-600': colorScheme === 'muted',
},
styles?.contentWidth === 'boxed' && 'text-gray-600',
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
)}
{button_text && button_url && (
<a
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 && (styles?.contentWidth === 'boxed'
? 'bg-primary'
: {
'bg-white': colorScheme === 'primary',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
}),
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
? 'text-primary-foreground'
: {
'text-primary': colorScheme === 'primary',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
}),
btnStyle.classNames
)}
style={btnStyle.style}
>
{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 ( return (
<section <section
id={id} id={id}
@@ -56,70 +142,29 @@ export function CTABannerSection({
'wn-section wn-cta-banner', 'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`, `wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
'py-12 md:py-20', heightClasses,
{ {
'bg-primary text-primary-foreground': colorScheme === 'primary', 'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
'bg-secondary text-secondary-foreground': colorScheme === 'secondary', 'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient', 'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted',
} }
)} )}
style={getBackgroundStyle()}
> >
<div className={cn( {styles?.contentWidth === 'boxed' ? (
"mx-auto px-4 text-center", <div className="container mx-auto px-4 max-w-5xl">
styles?.contentWidth === 'full' ? 'w-full' : 'container' <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}
{title && ( </div>
<h2 </div>
className={cn( ) : (
"wn-cta__title mb-6", <div className={cn(
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl", "mx-auto px-4 text-center",
!elementStyles?.title?.fontWeight && "font-bold", styles?.contentWidth === 'full' ? 'w-full' : 'container'
titleStyle.classNames )}>
)} {innerContent}
style={titleStyle.style} </div>
> )}
{title}
</h2>
)}
{text && (
<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',
'text-gray-600': colorScheme === 'muted',
},
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
)}
{button_text && button_url && (
<a
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',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
},
!btnStyle.style?.color && {
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
},
btnStyle.classNames
)}
style={btnStyle.style}
>
{button_text}
</a>
)}
</div>
</section> </section>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface ContactFormSectionProps { interface ContactFormSectionProps {
id: string; id: string;
@@ -23,6 +24,15 @@ export function ContactFormSection({
elementStyles, elementStyles,
styles, styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) { }: 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>>({}); const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family) // Helper to get text styles (including font family)
@@ -87,6 +97,19 @@ export function ContactFormSection({
} finally { } finally {
setSubmitting(false); 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 ( return (
@@ -95,17 +118,18 @@ export function ContactFormSection({
className={cn( className={cn(
'wn-section wn-contact-form', 'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
`wn-scheme--${colorScheme}`, heightClasses,
'py-12 md:py-20',
{ {
// 'bg-white': colorScheme === 'default', // Removed for global styling 'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted',
} }
)} )}
style={getBackgroundStyle()}
> >
<div className={cn( <div className={cn(
"mx-auto px-4", "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( <div className={cn(
'max-w-xl mx-auto', 'max-w-xl mx-auto',
@@ -116,7 +140,7 @@ export function ContactFormSection({
{title && ( {title && (
<h2 className={cn( <h2 className={cn(
"wn-contact__title text-center mb-12", "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", !elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames titleStyle.classNames
)} )}

View File

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

View File

@@ -1,10 +1,16 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { getSectionBackground } from '@/lib/sectionStyles';
interface FeatureItem { interface FeatureItem {
title?: string; title?: string;
description?: string; description?: string;
icon?: string; icon?: string;
// Post-card fields (from related_posts dynamic source)
url?: string;
featured_image?: string;
excerpt?: string;
date?: string;
} }
interface FeatureGridSectionProps { interface FeatureGridSectionProps {
@@ -26,15 +32,26 @@ export function FeatureGridSection({
elementStyles, elementStyles,
styles, styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) { }: 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 listItems = items.length > 0 ? items : features;
const gridCols = { const gridCols = {
'grid-2': 'md:grid-cols-2', 'grid-2': 'md:grid-cols-2',
'grid-3': 'md:grid-cols-3', 'grid-3': 'md:grid-cols-3',
'grid-4': 'md:grid-cols-2 lg:grid-cols-4', 'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
}[layout] || 'md:grid-cols-3'; }[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 getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {}; const styles = elementStyles?.[elementName] || {};
return { return {
@@ -60,6 +77,21 @@ export function FeatureGridSection({
const headingStyle = getTextStyles('heading'); const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item'); 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 ( return (
<section <section
id={id} id={id}
@@ -67,23 +99,25 @@ export function FeatureGridSection({
'wn-section wn-feature-grid', 'wn-section wn-feature-grid',
`wn-feature-grid--${layout}`, `wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
'py-12 md:py-24', heightClasses,
{ {
// 'bg-white': colorScheme === 'default', // Removed for global styling 'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'bg-muted': colorScheme === 'muted', 'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
'bg-primary text-primary-foreground': colorScheme === 'primary',
} }
)} )}
style={getBackgroundStyle()}
> >
<div className={cn( <div className={cn(
"mx-auto px-4", "mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container' styles?.contentWidth === 'full' ? 'w-full'
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
: 'container'
)}> )}>
{heading && ( {heading && (
<h2 <h2
className={cn( className={cn(
"wn-features__heading text-center mb-12", "wn-features__heading text-center mb-10",
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl", !elementStyles?.heading?.fontSize && "text-2xl md:text-3xl lg:text-4xl",
!elementStyles?.heading?.fontWeight && "font-bold", !elementStyles?.heading?.fontWeight && "font-bold",
headingStyle.classNames headingStyle.classNames
)} )}
@@ -93,59 +127,122 @@ export function FeatureGridSection({
</h2> </h2>
)} )}
<div className={cn('grid gap-8', gridCols)}> <div className={cn('grid gap-6', gridCols)}>
{listItems.map((item, index) => ( {listItems.map((item, index) => {
<div // ── Post Card (from related_posts) ──────────────────────────
key={index} if (isPostCards) {
className={cn( return (
'wn-feature-grid__item', <a
'p-6 rounded-xl', key={index}
!featureItemStyle.style?.backgroundColor && { href={item.url || '#'}
'bg-white shadow-lg': colorScheme !== 'primary',
'bg-white/10': colorScheme === 'primary',
},
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
{item.icon && (() => {
const IconComponent = (LucideIcons as any)[item.icon];
if (!IconComponent) return null;
return (
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
<IconComponent className="w-8 h-8" />
</div>
);
})()}
{item.title && (
<h3
className={cn( className={cn(
"wn-feature-grid__item-title mb-3", 'wn-post-card group block rounded-xl overflow-hidden transition-all duration-200',
!featureItemStyle.classNames && "text-xl font-semibold" 'bg-white shadow-md hover:shadow-xl hover:-translate-y-1',
featureItemStyle.classNames
)} )}
style={{ color: featureItemStyle.style?.color }} style={featureItemStyle.style}
> >
{item.title} {/* Thumbnail */}
</h3> {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>
)}
{item.description && ( {/* Card Body */}
<p className={cn( <div className="p-5">
'wn-feature-grid__item-desc', {item.date && (
!featureItemStyle.style?.color && { <p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
'text-gray-600': colorScheme !== 'primary', )}
'text-white/80': colorScheme === 'primary', {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(
'wn-feature-grid__item',
'p-6 rounded-xl',
!featureItemStyle.style?.backgroundColor && {
'bg-white shadow-lg': colorScheme !== 'primary',
'bg-white/10': colorScheme === 'primary',
},
featureItemStyle.classNames
)} )}
style={{ color: featureItemStyle.style?.color }} style={featureItemStyle.style}
> >
{item.description} {item.icon && (() => {
</p> const IconComponent = (LucideIcons as any)[item.icon];
)} if (!IconComponent) return null;
</div> return (
))} <div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
<IconComponent className="w-8 h-8" />
</div>
);
})()}
{item.title && (
<h3
className={cn(
"wn-feature-grid__item-title mb-3",
!featureItemStyle.classNames && "text-xl font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.title}
</h3>
)}
{item.description && (
<p
className={cn(
'wn-feature-grid__item-desc',
!featureItemStyle.style?.color && {
'text-gray-600': colorScheme !== 'primary',
'text-white/80': colorScheme === 'primary',
}
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.description}
</p>
)}
</div>
);
})}
</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> </div>
</section> </section>
); );

View File

@@ -25,6 +25,15 @@ export function HeroSection({
elementStyles, elementStyles,
styles, styles,
}: HeroSectionProps & { styles?: Record<string, any> }) { }: 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 isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right'; const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default'; const isCentered = layout === 'centered' || layout === 'default';
@@ -67,9 +76,6 @@ export function HeroSection({
const getBackgroundStyle = (): React.CSSProperties | undefined => { const getBackgroundStyle = (): React.CSSProperties | undefined => {
// If user set custom bg via Design tab, use that // If user set custom bg via Design tab, use that
if (hasCustomBackground) return sectionBg.style; 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') { if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' }; return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
} }
@@ -79,7 +85,7 @@ export function HeroSection({
return undefined; return undefined;
}; };
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground; const isDynamicScheme = ['primary', 'secondary'].includes(colorScheme) && !hasCustomBackground;
return ( return (
<section <section
@@ -88,12 +94,15 @@ export function HeroSection({
'wn-section wn-hero', 'wn-section wn-hero',
`wn-hero--${layout}`, `wn-hero--${layout}`,
'relative overflow-hidden', 'relative overflow-hidden',
heightClasses,
)} )}
style={sectionBg.style} style={sectionBg.style}
> >
<div className={cn( <div className={cn(
'mx-auto px-4 z-10 relative flex w-full', '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, 'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered, 'text-center': isCentered,

View File

@@ -1,5 +1,6 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout'; import { SharedContentLayout } from '@/components/SharedContentLayout';
import { getSectionBackground } from '@/lib/sectionStyles';
interface ImageTextSectionProps { interface ImageTextSectionProps {
id: string; id: string;
@@ -66,25 +67,40 @@ export function ImageTextSection({
}; };
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24'; 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 ( return (
<section <section
id={id} id={id}
className={cn( className={cn(
'wn-section wn-image-text', 'wn-section wn-image-text',
`wn-scheme--${colorScheme}`, `wn-scheme--${colorScheme}`,
heightClasses, !styles?.paddingTop && !styles?.paddingBottom && heightClasses,
{ {
'bg-muted': colorScheme === 'muted', 'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
'bg-primary/5': colorScheme === 'primary',
} }
)} )}
style={getBackgroundStyle()}
> >
<SharedContentLayout <SharedContentLayout
title={title} title={title}
text={text} text={text}
image={image} image={image}
imagePosition={isImageRight ? 'right' : 'left'} imagePosition={isImageRight ? 'right' : 'left'}
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'} containerWidth={styles?.contentWidth === 'full' ? 'full' : styles?.contentWidth === 'boxed' ? 'boxed' : 'contained'}
titleStyle={titleStyle.style} titleStyle={titleStyle.style}
titleClassName={titleStyle.classNames} titleClassName={titleStyle.classNames}
textStyle={textStyle.style} textStyle={textStyle.style}

View File

@@ -29,6 +29,20 @@ export default function Product() {
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist(); const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
const { isEnabled: isModuleEnabled } = useModules(); 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 // Fetch product details by slug
const { data: product, isLoading, error } = useQuery<ProductType | null>({ const { data: product, isLoading, error } = useQuery<ProductType | null>({
queryKey: ['product', slug], queryKey: ['product', slug],
@@ -94,10 +108,16 @@ export default function Product() {
// Find matching variation when attributes change // Find matching variation when attributes change
useEffect(() => { useEffect(() => {
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) { if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
const variation = (product.variations as any[]).find(v => { let bestMatch: any = null;
if (!v.attributes) return false; 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 normalizedSelectedValue = attrValue.toLowerCase().trim();
const attrNameLower = attrName.toLowerCase(); const attrNameLower = attrName.toLowerCase();
@@ -108,17 +128,11 @@ export default function Product() {
// Try to find a matching key in the variation attributes // Try to find a matching key in the variation attributes
let variationValue: string | undefined = undefined; 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) { if (`attribute_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrSlug}`]; variationValue = v.attributes[`attribute_${attrSlug}`];
} } else if (`attribute_pa_${attrSlug}` in v.attributes) {
// 2. Check pa_ format (attribute_pa_color)
else if (`attribute_pa_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrSlug}`]; variationValue = v.attributes[`attribute_pa_${attrSlug}`];
} } else if (`attribute_${attrNameLower}` in v.attributes) {
// 3. Fallback to name-based checks (legacy)
else if (`attribute_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrNameLower}`]; variationValue = v.attributes[`attribute_${attrNameLower}`];
} else if (`attribute_pa_${attrNameLower}` in v.attributes) { } else if (`attribute_pa_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrNameLower}`]; variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
@@ -126,23 +140,34 @@ export default function Product() {
variationValue = v.attributes[attrNameLower]; 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) { if (variationValue === undefined || variationValue === null) {
return true; 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(); const normalizedVarValue = String(variationValue).toLowerCase().trim();
if (normalizedVarValue === '') { if (normalizedVarValue === '') {
return true; return true;
} }
// Otherwise, values must match // Exact match gets a higher score
return normalizedVarValue === normalizedSelectedValue; if (normalizedVarValue === normalizedSelectedValue) {
score += 1;
return true;
}
// Value mismatch
return false;
}); });
if (attributesMatch && score > highestScore) {
highestScore = score;
bestMatch = v;
}
}); });
setSelectedVariation(variation || null); setSelectedVariation(bestMatch || null);
} else if (product?.type !== 'variable') { } else if (product?.type !== 'variable') {
setSelectedVariation(null); setSelectedVariation(null);
} }
@@ -317,357 +342,364 @@ export default function Product() {
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock', 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. */}
{/* Breadcrumb */} <div className="max-w-6xl mx-auto">
{elements.breadcrumbs && ( {/* Top section: flat = no card wrapper, card = white card */}
<nav className="mb-6 text-sm"> <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'}>
<Link to="/shop" className="text-gray-600 hover:text-gray-900"> {/* Breadcrumb */}
Shop {elements.breadcrumbs && (
</Link> <nav className="mb-6 text-sm">
<span className="mx-2 text-gray-400">/</span> <Link to="/shop" className="text-gray-600 hover:text-gray-900">
<span className="text-gray-900">{product.name}</span> Shop
</nav> </Link>
)} <span className="mx-2 text-gray-400">/</span>
<span className="text-gray-900">{product.name}</span>
</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 */} {/* Product Images */}
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}> <div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
{/* Main Image - ENHANCED */} {/* Main Image - ENHANCED */}
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6"> <div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
{selectedImage ? ( {selectedImage ? (
<img <img
src={selectedImage} src={selectedImage}
alt={product.name} alt={product.name}
className="w-full !h-full object-contain p-8" className="w-full !h-full object-contain p-8"
/> />
) : ( ) : (
<div className="!h-full flex items-center justify-center text-gray-400"> <div className="!h-full flex items-center justify-center text-gray-400">
<div className="text-center"> <div className="text-center">
<svg className="w-24 h-24 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-24 h-24 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" /> <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> </svg>
<p className="text-sm">No image available</p> <p className="text-sm">No image available</p>
</div>
</div>
)}
{/* Sale Badge on Image */}
{isOnSale && (
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
Sale
</div>
)}
</div>
{/* Dots Navigation - Show based on gallery_style */}
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
<div className="flex justify-center gap-2 mt-4">
<div className="flex gap-2">
{allImages.map((img, index) => (
<button
key={index}
onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
}`}
aria-label={`View image ${index + 1}`}
/>
))}
</div>
</div>
)}
{/* Thumbnail Slider - Show based on gallery_style */}
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
<div className="relative w-full overflow-hidden">
{/* Left Arrow */}
{allImages.length > 4 && (
<button
onClick={() => scrollThumbnails('left')}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
>
<ChevronLeft className="h-5 w-5" />
</button>
)}
{/* Scrollable Thumbnails */}
<div
ref={thumbnailsRef}
className="flex gap-3 overflow-x-auto scroll-smooth scrollbar-hide px-10"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{allImages.map((img, index) => (
<button
key={index}
onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<img
src={img}
alt={`${product.name} ${index + 1}`}
className="w-full !h-full object-cover"
/>
</button>
))}
</div>
{/* Right Arrow */}
{allImages.length > 4 && (
<button
onClick={() => scrollThumbnails('right')}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div>
)}
</div>
{/* Product Info */}
<div>
{/* Product Title - PRIMARY HIERARCHY - SERIF FONT */}
<h1 className="text-2xl md:text-3xl lg:text-4xl font-serif font-light mb-4 leading-tight text-gray-900">{product.name}</h1>
{/* Price - SECONDARY (per UI/UX Guide) */}
<div className="mb-6">
{isOnSale && regularPrice ? (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-3xl font-bold text-gray-900">
{formatPrice(currentPrice)}
</span>
<span className="text-xl text-gray-400 line-through ml-3">
{formatPrice(regularPrice)}
</span>
<span className="inline-block bg-red-50 text-red-600 px-3 py-1 rounded-md text-sm font-semibold ml-3">
Save {Math.round((1 - parseFloat(currentPrice) / parseFloat(regularPrice)) * 100)}%
</span>
</div>
) : (
<span className="text-3xl font-bold text-gray-900">{formatPrice(currentPrice)}</span>
)}
</div>
{/* Stock Status Badge */}
<div className="mb-6">
{stockStatus === 'instock' ? (
<div className="inline-flex items-center gap-2 text-green-700 text-sm font-medium">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>In Stock Ships Today</span>
</div>
) : (
<div className="inline-flex items-center gap-2 text-red-700 text-sm font-medium">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span>Out of Stock</span>
</div>
)}
</div>
{/* Short Description */}
{product.short_description && (
<div
className="prose prose-sm text-gray-600 leading-relaxed mb-6 border-l-4 border-gray-200 pl-4"
dangerouslySetInnerHTML={{ __html: product.short_description }}
/>
)}
{/* Variation Selector - PILLS (per UI/UX Guide) */}
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
<div className="mb-6 space-y-4">
{product.attributes.map((attr: any, index: number) => (
attr.variation && (
<div key={index}>
<label className="block font-medium mb-3 text-sm text-gray-700 uppercase tracking-wider">{attr.name}</label>
<div className="flex flex-wrap gap-2">
{attr.options && attr.options.map((option: string, optIndex: number) => {
const isSelected = selectedAttributes[attr.name] === option;
return (
<button
key={optIndex}
onClick={() => handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`}
>
{option}
</button>
);
})}
</div>
</div> </div>
)
))}
</div>
)}
{/* Quantity & Add to Cart */}
{stockStatus === 'instock' && (
<div className="space-y-4 mb-6">
{/* Quantity Selector */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
<div className="flex items-center border-2 border-gray-200 rounded-xl">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="p-2.5 hover:bg-gray-100 transition-colors rounded-l-md"
>
<Minus className="h-4 w-4" />
</button>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="w-14 text-center border-x-2 border-gray-200 focus:outline-none font-semibold"
/>
<button
onClick={() => setQuantity(quantity + 1)}
className="p-2.5 hover:bg-gray-100 transition-colors rounded-r-md"
>
<Plus className="h-4 w-4" />
</button>
</div> </div>
</div> )}
{/* Sale Badge on Image */}
{/* Action Buttons - PROMINENT */} {isOnSale && (
{/* Add to Cart Button */} <div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
<button Sale
onClick={handleAddToCart} </div>
className="w-full h-14 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold text-base hover:bg-gray-800 transition-all shadow-lg hover:shadow-xl"
>
<ShoppingCart className="h-5 w-5" />
Add to Cart
</button>
{isModuleEnabled('wishlist') && wishlistEnabled && (
<button
onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`}
>
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
}`} />
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
</button>
)} )}
</div> </div>
)}
{/* Trust Badges - REDESIGNED */} {/* Dots Navigation - Show based on gallery_style */}
<div className="grid grid-cols-3 gap-4 py-6 border-y border-gray-200"> {allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
{/* Free Shipping */} <div className="flex justify-center gap-2 mt-4">
<div className="flex flex-col items-center text-center"> <div className="flex gap-2">
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mb-2"> {allImages.map((img, index) => (
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /> key={index}
</svg> onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
}`}
aria-label={`View image ${index + 1}`}
/>
))}
</div>
</div> </div>
<p className="font-medium text-sm text-gray-900">Free Shipping</p> )}
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
</div>
{/* Returns */} {/* Thumbnail Slider - Show based on gallery_style */}
<div className="flex flex-col items-center text-center"> {allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2"> <div className="relative w-full overflow-hidden">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {/* Left Arrow */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> {allImages.length > 4 && (
</svg> <button
</div> onClick={() => scrollThumbnails('left')}
<p className="font-medium text-sm text-gray-900">Easy Returns</p> className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p> >
</div> <ChevronLeft className="h-5 w-5" />
</button>
)}
{/* Secure */} {/* Scrollable Thumbnails */}
<div className="flex flex-col items-center text-center"> <div
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2"> ref={thumbnailsRef}
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="flex gap-3 overflow-x-auto scroll-smooth scrollbar-hide px-10"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
</svg> >
{allImages.map((img, index) => (
<button
key={index}
onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<img
src={img}
alt={`${product.name} ${index + 1}`}
className="w-full !h-full object-cover"
/>
</button>
))}
</div>
{/* Right Arrow */}
{allImages.length > 4 && (
<button
onClick={() => scrollThumbnails('right')}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
</div> </div>
<p className="font-medium text-sm text-gray-900">Secure Payment</p> )}
<p className="text-xs text-gray-500 mt-1">SSL encrypted</p>
</div>
</div> </div>
{/* Product Meta */} {/* Product Info */}
{elements.product_meta && ( <div>
<div className="space-y-2 text-sm border-t pt-4 border-gray-200"> {/* Product Title - PRIMARY HIERARCHY - SERIF FONT */}
{product.sku && ( <h1 className="text-2xl md:text-3xl lg:text-4xl font-serif font-light mb-4 leading-tight text-gray-900">{product.name}</h1>
<div className="flex gap-2">
<span className="text-gray-600">SKU:</span> {/* Price - SECONDARY (per UI/UX Guide) */}
<span className="font-medium">{product.sku}</span> <div className="mb-6">
</div> {isOnSale && regularPrice ? (
)} <div className="flex items-center gap-3 flex-wrap">
{product.categories && product.categories.length > 0 && ( <span className="text-3xl font-bold text-gray-900">
<div className="flex gap-2"> {formatPrice(currentPrice)}
<span className="text-gray-600">Categories:</span> </span>
<span className="font-medium"> <span className="text-xl text-gray-400 line-through ml-3">
{product.categories.map((cat: any) => cat.name).join(', ')} {formatPrice(regularPrice)}
</span>
<span className="inline-block bg-red-50 text-red-600 px-3 py-1 rounded-md text-sm font-semibold ml-3">
Save {Math.round((1 - parseFloat(currentPrice) / parseFloat(regularPrice)) * 100)}%
</span> </span>
</div> </div>
) : (
<span className="text-3xl font-bold text-gray-900">{formatPrice(currentPrice)}</span>
)} )}
</div> </div>
)}
{/* Share Buttons */} {/* Stock Status Badge */}
{elements.share_buttons && ( <div className="mb-6">
<div className="flex items-center gap-3 pt-4 border-t border-gray-200"> {stockStatus === 'instock' ? (
<span className="text-sm text-gray-600 font-medium">Share:</span> <div className="inline-flex items-center gap-2 text-green-700 text-sm font-medium">
<div className="flex gap-2"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span>In Stock Ships Today</span>
</div>
) : (
<div className="inline-flex items-center gap-2 text-red-700 text-sm font-medium">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span>Out of Stock</span>
</div>
)}
</div>
{/* Short Description */}
{product.short_description && (
<div
className="prose prose-sm text-gray-600 leading-relaxed mb-6 border-l-4 border-gray-200 pl-4"
dangerouslySetInnerHTML={{ __html: product.short_description }}
/>
)}
{/* Variation Selector - PILLS (per UI/UX Guide) */}
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
<div className="mb-6 space-y-4">
{product.attributes.map((attr: any, index: number) => (
attr.variation && (
<div key={index}>
<label className="block font-medium mb-3 text-sm text-gray-700 uppercase tracking-wider">{attr.name}</label>
<div className="flex flex-wrap gap-2">
{attr.options && attr.options.map((option: string, optIndex: number) => {
const isSelected = selectedAttributes[attr.name] === option;
return (
<button
key={optIndex}
onClick={() => handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`}
>
{option}
</button>
);
})}
</div>
</div>
)
))}
</div>
)}
{/* Quantity & Add to Cart */}
{stockStatus === 'instock' && (
<div className="space-y-4 mb-6">
{/* Quantity Selector */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
<div className="flex items-center border-2 border-gray-200 rounded-xl">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="p-2.5 hover:bg-gray-100 transition-colors rounded-l-md"
>
<Minus className="h-4 w-4" />
</button>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="w-14 text-center border-x-2 border-gray-200 focus:outline-none font-semibold"
/>
<button
onClick={() => setQuantity(quantity + 1)}
className="p-2.5 hover:bg-gray-100 transition-colors rounded-r-md"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{/* Action Buttons - PROMINENT */}
{/* Add to Cart Button */}
<button <button
onClick={() => { onClick={handleAddToCart}
const url = encodeURIComponent(window.location.href); className="w-full h-14 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold text-base hover:bg-gray-800 transition-all shadow-lg hover:shadow-xl"
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
}}
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
title="Share on Facebook"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg> <ShoppingCart className="h-5 w-5" />
</button> Add to Cart
<button
onClick={() => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(product.name);
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
}}
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
title="Share on Twitter"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
</button>
<button
onClick={() => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(product.name);
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
}}
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
title="Share on WhatsApp"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
</button> </button>
{isModuleEnabled('wishlist') && wishlistEnabled && (
<button
onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`}
>
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
}`} />
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
</button>
)}
</div>
)}
{/* Trust Badges - REDESIGNED */}
<div className="grid grid-cols-3 gap-4 py-6 border-y border-gray-200">
{/* Free Shipping */}
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mb-2">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
</div>
{/* Returns */}
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
</div>
{/* Secure */}
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p className="font-medium text-sm text-gray-900">Secure Payment</p>
<p className="text-xs text-gray-500 mt-1">SSL encrypted</p>
</div> </div>
</div> </div>
)}
{/* Product Meta */}
{elements.product_meta && (
<div className="space-y-2 text-sm border-t pt-4 border-gray-200">
{product.sku && (
<div className="flex gap-2">
<span className="text-gray-600">SKU:</span>
<span className="font-medium">{product.sku}</span>
</div>
)}
{product.categories && product.categories.length > 0 && (
<div className="flex gap-2">
<span className="text-gray-600">Categories:</span>
<span className="font-medium">
{product.categories.map((cat: any) => cat.name).join(', ')}
</span>
</div>
)}
</div>
)}
{/* Share Buttons */}
{elements.share_buttons && (
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
<span className="text-sm text-gray-600 font-medium">Share:</span>
<div className="flex gap-2">
<button
onClick={() => {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
}}
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
title="Share on Facebook"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
</button>
<button
onClick={() => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(product.name);
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
}}
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
title="Share on Twitter"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
</button>
<button
onClick={() => {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(product.name);
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
}}
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
title="Share on WhatsApp"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
</button>
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
{/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */} {/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */}
<div className="mt-12 space-y-6"> <div className="space-y-6">
{/* Description Section */} {/* 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 <button
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')} 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> <h2 className="text-xl font-bold text-gray-900">Product Description</h2>
<svg <svg
@@ -694,10 +726,13 @@ export default function Product() {
</div> </div>
{/* Specifications Section - SCANNABLE TABLE */} {/* 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 <button
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')} 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> <h2 className="text-xl font-bold text-gray-900">Specifications</h2>
<svg <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" } 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'), 'image_position' => sanitize_text_field($data['layout']['image_position'] ?? 'left'),
'gallery_style' => sanitize_text_field($data['layout']['gallery_style'] ?? 'thumbnails'), 'gallery_style' => sanitize_text_field($data['layout']['gallery_style'] ?? 'thumbnails'),
'sticky_add_to_cart' => (bool) ($data['layout']['sticky_add_to_cart'] ?? false), 'sticky_add_to_cart' => (bool) ($data['layout']['sticky_add_to_cart'] ?? false),
'layout_style' => sanitize_text_field($data['layout']['layout_style'] ?? 'flat'),
], ],
'elements' => [ 'elements' => [
'breadcrumbs' => (bool) ($data['elements']['breadcrumbs'] ?? true), 'breadcrumbs' => (bool) ($data['elements']['breadcrumbs'] ?? true),
@@ -601,7 +602,11 @@ class AppearanceController
'show_icon' => true, 'show_icon' => true,
], ],
], ],
'product' => [], 'product' => [
'layout' => [
'layout_style' => 'flat',
],
],
'cart' => [], 'cart' => [],
'checkout' => [], 'checkout' => [],
'thankyou' => [], 'thankyou' => [],

View File

@@ -75,7 +75,7 @@ class Assets
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(), 'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false), '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'); 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__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(), 'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false), '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) // WordPress REST API settings (for media upload compatibility)

View File

@@ -1,4 +1,5 @@
<?php <?php
namespace WooNooW\Admin; namespace WooNooW\Admin;
/** /**
@@ -9,28 +10,31 @@ namespace WooNooW\Admin;
* *
* @package WooNooW\Admin * @package WooNooW\Admin
*/ */
class StandaloneAdmin { class StandaloneAdmin
{
/** /**
* Initialize standalone admin handler * Initialize standalone admin handler
*/ */
public static function init() { public static function init()
{
// Catch /admin requests very early (before WordPress routing) // Catch /admin requests very early (before WordPress routing)
add_action( 'parse_request', [ __CLASS__, 'handle_admin_request' ], 1 ); add_action('parse_request', [__CLASS__, 'handle_admin_request'], 1);
} }
/** /**
* Handle /admin requests * Handle /admin requests
*/ */
public static function handle_admin_request() { public static function handle_admin_request()
{
// Check if this is an /admin request // Check if this is an /admin request
$request_uri = $_SERVER['REQUEST_URI'] ?? ''; $request_uri = $_SERVER['REQUEST_URI'] ?? '';
// Remove query string // Remove query string
$path = strtok( $request_uri, '?' ); $path = strtok($request_uri, '?');
// Only handle exact /admin or /admin/ paths (not asset files) // Only handle exact /admin or /admin/ paths (not asset files)
if ( $path !== '/admin' && $path !== '/admin/' ) { if ($path !== '/admin' && $path !== '/admin/') {
return; return;
} }
@@ -42,33 +46,34 @@ class StandaloneAdmin {
/** /**
* Render standalone admin interface * Render standalone admin interface
*/ */
private static function render_standalone_admin() { private static function render_standalone_admin()
{
// Enqueue WordPress media library (needed for image uploads) // Enqueue WordPress media library (needed for image uploads)
wp_enqueue_media(); wp_enqueue_media();
// Check if user is logged in and has permissions // Check if user is logged in and has permissions
$is_logged_in = is_user_logged_in(); $is_logged_in = is_user_logged_in();
$has_permission = $is_logged_in && current_user_can( 'manage_woocommerce' ); $has_permission = $is_logged_in && current_user_can('manage_woocommerce');
$is_authenticated = $is_logged_in && $has_permission; $is_authenticated = $is_logged_in && $has_permission;
// Debug logging (only in WP_DEBUG mode) // Debug logging (only in WP_DEBUG mode)
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if (defined('WP_DEBUG') && WP_DEBUG) {
} }
// Get nonce for REST API // Get nonce for REST API
$nonce = wp_create_nonce( 'wp_rest' ); $nonce = wp_create_nonce('wp_rest');
$rest_url = untrailingslashit( rest_url( 'woonoow/v1' ) ); $rest_url = untrailingslashit(rest_url('woonoow/v1'));
$wp_admin_url = admin_url( 'admin.php?page=woonoow' ); $wp_admin_url = admin_url('admin.php?page=woonoow');
// Get current user data if authenticated // Get current user data if authenticated
$current_user = null; $current_user = null;
if ( $is_authenticated ) { if ($is_authenticated) {
$user = wp_get_current_user(); $user = wp_get_current_user();
$current_user = [ $current_user = [
'id' => $user->ID, 'id' => $user->ID,
'name' => $user->display_name, 'name' => $user->display_name,
'email' => $user->user_email, 'email' => $user->user_email,
'avatar' => get_avatar_url( $user->ID ), 'avatar' => get_avatar_url($user->ID),
]; ];
} }
@@ -76,101 +81,105 @@ class StandaloneAdmin {
$store_settings = self::get_store_settings(); $store_settings = self::get_store_settings();
// Get asset URLs // Get asset URLs
$plugin_url = plugins_url( '', dirname( dirname( __FILE__ ) ) ); $plugin_url = plugins_url('', dirname(dirname(__FILE__)));
$asset_url = $plugin_url . '/admin-spa/dist'; $asset_url = $plugin_url . '/admin-spa/dist';
// Cache busting // Cache busting
$version = defined( 'WP_DEBUG' ) && WP_DEBUG ? time() : '1.0.0'; $version = defined('WP_DEBUG') && WP_DEBUG ? time() : '1.0.0';
$css_url = $asset_url . '/app.css?ver=' . $version; $css_url = $asset_url . '/app.css?ver=' . $version;
$js_url = $asset_url . '/app.js?ver=' . $version; $js_url = $asset_url . '/app.js?ver=' . $version;
// Render HTML // Render HTML
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<?php echo esc_attr( get_locale() ); ?>"> <html lang="<?php echo esc_attr(get_locale()); ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?php echo esc_html( get_option( 'blogname', 'WooNooW' ) ); ?> Admin</title>
<?php <head>
// Favicon <meta charset="UTF-8">
$icon = get_option( 'woonoow_store_icon', '' ); <meta name="viewport" content="width=device-width, initial-scale=1.0">
if ( ! empty( $icon ) ) { <meta name="robots" content="noindex, nofollow">
?> <title><?php echo esc_html(get_option('blogname', 'WooNooW')); ?> Admin</title>
<link rel="icon" type="image/png" href="<?php echo esc_url( $icon ); ?>" />
<link rel="apple-touch-icon" href="<?php echo esc_url( $icon ); ?>" />
<?php
}
?>
<?php <?php
// Print WordPress media library styles (complete set for proper modal) // Favicon
wp_print_styles( 'media-views' ); $icon = get_option('woonoow_store_icon', '');
wp_print_styles( 'imgareaselect' ); if (! empty($icon)) {
wp_print_styles( 'buttons' ); ?>
wp_print_styles( 'dashicons' ); <link rel="icon" type="image/png" href="<?php echo esc_url($icon); ?>" />
wp_print_styles( 'wp-admin' ); <link rel="apple-touch-icon" href="<?php echo esc_url($icon); ?>" />
wp_print_styles( 'common' ); <?php
?> }
?>
<!-- WooNooW Assets --> <?php
<link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>"> // Print WordPress media library styles (complete set for proper modal)
</head> wp_print_styles('media-views');
<body class="woonoow-standalone"> wp_print_styles('imgareaselect');
<div id="woonoow-admin-app"></div> wp_print_styles('buttons');
wp_print_styles('dashicons');
wp_print_styles('wp-admin');
wp_print_styles('common');
?>
<script> <!-- WooNooW Assets -->
// Minimal config - no WordPress bloat <link rel="stylesheet" href="<?php echo esc_url($css_url); ?>">
window.WNW_CONFIG = { </head>
restUrl: <?php echo wp_json_encode( $rest_url ); ?>,
nonce: <?php echo wp_json_encode( $nonce ); ?>,
standaloneMode: true,
wpAdminUrl: <?php echo wp_json_encode( $wp_admin_url ); ?>,
isAuthenticated: <?php echo $is_authenticated ? 'true' : 'false'; ?>,
currentUser: <?php echo wp_json_encode( $current_user ); ?>,
locale: <?php echo wp_json_encode( get_locale() ); ?>,
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'; ?>
};
// Also set WNW_API for API compatibility <body class="woonoow-standalone">
window.WNW_API = { <div id="woonoow-admin-app"></div>
root: <?php echo wp_json_encode( $rest_url ); ?>,
nonce: <?php echo wp_json_encode( $nonce ); ?>,
isDev: <?php echo ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ? 'true' : 'false'; ?>
};
// WooCommerce store settings (currency, formatting, etc.) <script>
window.WNW_STORE = <?php echo wp_json_encode( $store_settings ); ?>; // Minimal config - no WordPress bloat
window.WNW_CONFIG = {
restUrl: <?php echo wp_json_encode($rest_url); ?>,
nonce: <?php echo wp_json_encode($nonce); ?>,
standaloneMode: true,
wpAdminUrl: <?php echo wp_json_encode($wp_admin_url); ?>,
isAuthenticated: <?php echo $is_authenticated ? 'true' : 'false'; ?>,
currentUser: <?php echo wp_json_encode($current_user); ?>,
locale: <?php echo wp_json_encode(get_locale()); ?>,
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'; ?>,
onboardingCompleted: <?php echo (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1) ? 'true' : 'false'; ?>
};
// Navigation tree (single source of truth from PHP) // Also set WNW_API for API compatibility
window.WNW_NAV_TREE = <?php echo wp_json_encode( \WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree() ); ?>; window.WNW_API = {
root: <?php echo wp_json_encode($rest_url); ?>,
nonce: <?php echo wp_json_encode($nonce); ?>,
isDev: <?php echo (defined('WP_DEBUG') && WP_DEBUG) ? 'true' : 'false'; ?>
};
// WordPress REST API settings (for media upload compatibility) // WooCommerce store settings (currency, formatting, etc.)
window.wpApiSettings = { window.WNW_STORE = <?php echo wp_json_encode($store_settings); ?>;
root: <?php echo wp_json_encode( untrailingslashit( rest_url() ) ); ?>,
nonce: <?php echo wp_json_encode( $nonce ); ?>,
versionString: 'wp/v2/'
};
</script>
<?php // Navigation tree (single source of truth from PHP)
// Print WordPress media library scripts (needed for wp.media) window.WNW_NAV_TREE = <?php echo wp_json_encode(\WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree()); ?>;
wp_print_scripts( 'media-editor' );
wp_print_scripts( 'media-audiovideo' );
// Print media templates (required for media modal to work) // WordPress REST API settings (for media upload compatibility)
wp_print_media_templates(); window.wpApiSettings = {
?> root: <?php echo wp_json_encode(untrailingslashit(rest_url())); ?>,
nonce: <?php echo wp_json_encode($nonce); ?>,
versionString: 'wp/v2/'
};
</script>
<script type="module" src="<?php echo esc_url( $js_url ); ?>"></script> <?php
</body> // Print WordPress media library scripts (needed for wp.media)
</html> wp_print_scripts('media-editor');
<?php wp_print_scripts('media-audiovideo');
// Print media templates (required for media modal to work)
wp_print_media_templates();
?>
<script type="module" src="<?php echo esc_url($js_url); ?>"></script>
</body>
</html>
<?php
} }
/** /**
@@ -178,14 +187,15 @@ class StandaloneAdmin {
* *
* @return array Store settings (currency, decimals, separators, etc.) * @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 // Get WooCommerce settings with fallbacks
$currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD'; $currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
$currency_sym = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol( $currency ) : '$'; $currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
$decimals = function_exists( 'wc_get_price_decimals' ) ? wc_get_price_decimals() : 2; $decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
$thousand_sep = function_exists( 'wc_get_price_thousand_separator' ) ? wc_get_price_thousand_separator() : ','; $thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
$decimal_sep = function_exists( 'wc_get_price_decimal_separator' ) ? wc_get_price_decimal_separator() : '.'; $decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
$currency_pos = get_option( 'woocommerce_currency_pos', 'left' ); $currency_pos = get_option('woocommerce_currency_pos', 'left');
return [ return [
'currency' => $currency, 'currency' => $currency,
@@ -200,17 +210,17 @@ class StandaloneAdmin {
/** Get the SPA page URL from appearance settings (dynamic slug) */ /** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string private static function get_spa_url(): string
{ {
$appearance_settings = get_option( 'woonoow_appearance_settings', [] ); $appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ( $spa_page_id ) { if ($spa_page_id) {
$spa_url = get_permalink( $spa_page_id ); $spa_url = get_permalink($spa_page_id);
if ( $spa_url ) { if ($spa_url) {
return trailingslashit( $spa_url ); return trailingslashit($spa_url);
} }
} }
// Fallback to /store/ if no SPA page configured // Fallback to /store/ if no SPA page configured
return home_url( '/store/' ); return home_url('/store/');
} }
} }

View File

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

View File

@@ -58,7 +58,7 @@ class PagesController
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Get/Save CPT templates // Get/Save/Delete CPT templates
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [ register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
[ [
'methods' => 'GET', 'methods' => 'GET',
@@ -70,6 +70,11 @@ class PagesController
'callback' => [__CLASS__, 'save_template'], 'callback' => [__CLASS__, 'save_template'],
'permission_callback' => [__CLASS__, 'check_admin_permission'], '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) // Get post with template applied (for SPA rendering)
@@ -337,6 +342,34 @@ class PagesController
], 200); ], 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) * Get content with template applied (for SPA rendering)
*/ */
@@ -378,7 +411,37 @@ class PagesController
if ($template && !empty($template['sections'])) { if ($template && !empty($template['sections'])) {
foreach ($template['sections'] as $section) { foreach ($template['sections'] as $section) {
$resolved_section = $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; $rendered_sections[] = $resolved_section;
} }
} }

View File

@@ -46,6 +46,18 @@ class ProductsController
return trim($sanitized); 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 * Sanitize numeric value
*/ */
@@ -335,8 +347,12 @@ class ProductsController
$product->set_slug(self::sanitize_slug($data['slug'])); $product->set_slug(self::sanitize_slug($data['slug']));
} }
$product->set_status(sanitize_key($data['status'] ?? 'publish')); $product->set_status(sanitize_key($data['status'] ?? 'publish'));
$product->set_description(self::sanitize_textarea($data['description'] ?? '')); if (isset($data['description'])) {
$product->set_short_description(self::sanitize_textarea($data['short_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'])) { if (!empty($data['sku'])) {
$product->set_sku(self::sanitize_text($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['name'])) $product->set_name(self::sanitize_text($data['name']));
if (isset($data['slug'])) $product->set_slug(self::sanitize_slug($data['slug'])); 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['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['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku'])); if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
@@ -942,10 +958,17 @@ class ProductsController
$value = $term ? $term->name : $value; $value = $term ? $term->name : $value;
} }
} else { } else {
// Custom attribute - stored as lowercase in meta // Custom attribute - stored as sanitize_title in meta
$meta_key = 'attribute_' . strtolower($attr_name); $sanitized_name = sanitize_title($attr_name);
$meta_key = 'attribute_' . $sanitized_name;
$value = get_post_meta($variation_id, $meta_key, true); $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 // Capitalize the attribute name for display to match admin SPA
$clean_name = ucfirst($attr_name); $clean_name = ucfirst($attr_name);
} }
@@ -1029,8 +1052,27 @@ class ProductsController
foreach ($parent_attributes as $attr_name => $parent_attr) { foreach ($parent_attributes as $attr_name => $parent_attr) {
if (!$parent_attr->get_variation()) continue; 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; break;
} }
} }
@@ -1095,7 +1137,7 @@ class ProductsController
global $wpdb; global $wpdb;
foreach ($wc_attributes as $attr_name => $attr_value) { foreach ($wc_attributes as $attr_name => $attr_value) {
$meta_key = 'attribute_' . $attr_name; $meta_key = 'attribute_' . sanitize_title($attr_name);
$wpdb->delete( $wpdb->delete(
$wpdb->postmeta, $wpdb->postmeta,

View File

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

View File

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

View File

@@ -19,6 +19,12 @@ class Assets
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100); add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3); 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); 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; return;
} }
// Check if we're in full mode and not on a page with shortcode // Get appearance settings for unified spa_mode check
$spa_settings = get_option('woonoow_customer_spa_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled'; $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) // 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 // 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'); $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) // Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true; $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) { if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
return true; return true;
} }
// Check if we're on a frontpage SPA route (by URL detection) // Get SPA mode from appearance settings
if (self::is_frontpage_spa_route()) { $appearance_settings = get_option('woonoow_appearance_settings', []);
return true; $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? // First check: Is this a designated SPA page?
@@ -296,39 +319,29 @@ class Assets
return true; return true;
} }
// Get SPA mode from appearance settings (the correct source) // Check if we're on a frontpage SPA route
$appearance_settings = get_option('woonoow_appearance_settings', []); if (self::is_frontpage_spa_route()) {
$mode = $appearance_settings['general']['spa_mode'] ?? 'full'; return true;
}
// If disabled, only load for pages with shortcodes // For structural pages (is_singular('page'))
if ($mode === 'disabled') { if (is_singular('page')) {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post) $page_id = get_queried_object_id();
if (function_exists('is_shop') && is_shop()) { $structure = get_post_meta($page_id, '_wn_page_structure', true);
$shop_page_id = get_option('woocommerce_shop_page_id'); if (!empty($structure) && !empty($structure['sections'])) {
if ($shop_page_id) { return true;
$shop_page = get_post($shop_page_id);
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
return true;
}
}
} }
}
// Check for shortcodes on regular pages // For CPTs with WooNooW templates
if ($post) { if (is_singular() && !is_singular('page')) {
if (has_shortcode($post->post_content, 'woonoow_shop')) { $post_type = get_post_type();
return true; if (!in_array($post_type, ['product', 'shop_order', 'shop_coupon'])) {
} $wn_template = get_option("wn_template_{$post_type}", null);
if (has_shortcode($post->post_content, 'woonoow_cart')) { if (!empty($wn_template) && !empty($wn_template['sections'])) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_account')) {
return true; return true;
} }
} }
return false;
} }
// Full SPA mode - load on all WooCommerce pages // Full SPA mode - load on all WooCommerce pages
@@ -353,6 +366,7 @@ class Assets
// Checkout-Only mode - load only on specific pages // Checkout-Only mode - load only on specific pages
if ($mode === 'checkout_only') { if ($mode === 'checkout_only') {
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : []; $checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) { if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
@@ -370,6 +384,7 @@ class Assets
return false; return false;
} }
global $post;
// Check if current page has WooNooW shortcodes // Check if current page has WooNooW shortcodes
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) { if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
return true; return true;

View File

@@ -283,8 +283,8 @@ class ShopController
// Add detailed info if requested // Add detailed info if requested
if ($detailed) { if ($detailed) {
$data['description'] = $product->get_description(); $data['description'] = wpautop($product->get_description());
$data['short_description'] = $product->get_short_description(); $data['short_description'] = wpautop($product->get_short_description());
$data['sku'] = $product->get_sku(); $data['sku'] = $product->get_sku();
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']); $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', 'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top' 'top'
); );
add_rewrite_rule(
'^subscribe/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=subscribe',
'top'
);
// /order-pay/* → SPA page // /order-pay/* → SPA page
add_rewrite_rule( add_rewrite_rule(
@@ -352,7 +357,6 @@ class TemplateOverride
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) { if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
return; return;
} }
// Check if page has WooNooW structure // Check if page has WooNooW structure
$structure = get_post_meta($post->ID, '_wn_page_structure', true); $structure = get_post_meta($post->ID, '_wn_page_structure', true);
if (!empty($structure) && !empty($structure['sections'])) { 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 * Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes * When SPA page is set as WordPress frontpage, intercept known routes
@@ -417,8 +416,8 @@ class TemplateOverride
'/my-account', // Account page '/my-account', // Account page
'/login', // Login page '/login', // Login page
'/register', // Register page '/register', // Register page
'/register', // Register page
'/reset-password', // Password reset '/reset-password', // Password reset
'/subscribe', // Subscribe page
'/order-pay', // Order pay 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 // For spa_mode = 'full', override WooCommerce pages
if ($spa_mode === 'full') { if ($spa_mode === 'full') {
// Override all WooCommerce pages // Override all WooCommerce pages
@@ -569,23 +594,30 @@ class TemplateOverride
return; return;
} }
// Determine page type // Determine page type and route
$page_type = 'shop';
$data_attrs = 'data-page="shop"'; $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()) { if (is_product()) {
$page_type = 'product';
global $post; 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()) { } elseif (is_cart()) {
$page_type = 'cart'; $data_attrs .= ' data-page="cart"';
$data_attrs = 'data-page="cart"';
} elseif (is_checkout()) { } elseif (is_checkout()) {
$page_type = 'checkout'; $data_attrs .= ' data-page="checkout"';
$data_attrs = 'data-page="checkout"';
} elseif (is_account_page()) { } 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 // Output SPA mount point
@@ -631,6 +663,26 @@ class TemplateOverride
return true; 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; return false;
} }

View File

@@ -39,6 +39,13 @@ class TemplateRegistry
'description' => 'Simple contact page with a form and address details.', 'description' => 'Simple contact page with a form and address details.',
'icon' => 'mail', 'icon' => 'mail',
'sections' => self::get_contact_structure() '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> <!DOCTYPE html>
<html <?php language_attributes(); ?>> <html <?php language_attributes(); ?>>
<head> <head>
<meta charset="<?php bloginfo('charset'); ?>"> <meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php wp_title('|', true, 'right'); ?><?php bloginfo('name'); ?></title> <title><?php wp_title('|', true, 'right'); ?><?php bloginfo('name'); ?></title>
<?php wp_head(); ?> <?php wp_head(); ?>
</head> </head>
<body <?php body_class('woonoow-spa-page'); ?>> <body <?php body_class('woonoow-spa-page'); ?>>
<?php <?php
// Determine initial route based on SPA mode // Determine initial route based on SPA mode
$appearance_settings = get_option('woonoow_appearance_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full'; $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 // Set initial page based on mode
if ($spa_mode === 'checkout_only') { if ($spa_mode === 'checkout_only') {
// Checkout Only mode starts at cart // Checkout Only mode starts at cart
$page_type = 'cart'; $page_type = 'cart';
$data_attrs = 'data-page="cart" data-initial-route="/cart"'; $data_attrs = 'data-page="cart" data-initial-route="/cart"';
} else { } else {
// Full SPA mode starts at shop // Evaluate WordPress page type to pass to React App
$page_type = 'shop'; 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 this is the front page, route to /
if (is_front_page()) { if (is_front_page()) {
$data_attrs = 'data-page="shop" data-initial-route="/"'; $data_attrs .= ' data-initial-route="/"';
} else { } 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(); ?> <?php wp_footer(); ?>
</body> </body>
</html> </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";