feat: product page layout toggle (flat/card), fix email shortcode rendering
- Add layout_style setting (flat default) to product appearance
- AppearanceController: sanitize & persist layout_style, add to default settings
- Admin SPA: Layout Style select in Appearance > Product
- Customer SPA: useEffect targets <main> bg-white in flat mode (full-width),
card mode uses per-section white floating cards on gray background
- Accordion sections styled per mode: flat=border-t dividers, card=white cards
- Fix email shortcode gaps (EmailRenderer, EmailManager)
- Add missing variables: return_url, contact_url, account_url (alias),
payment_error_reason, order_items_list (alias for order_items_table)
- Fix customer_note extra_data key mismatch (note → customer_note)
- Pass low_stock_threshold via extra_data in low_stock email send
This commit is contained in:
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -31,6 +32,7 @@ export default function AppearanceGeneral() {
|
||||
const [customBody, setCustomBody] = useState('');
|
||||
const [fontScale, setFontScale] = useState([1.0]);
|
||||
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
|
||||
const [hideAdminBar, setHideAdminBar] = useState(true);
|
||||
|
||||
const fontPairs = {
|
||||
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
||||
@@ -70,6 +72,9 @@ export default function AppearanceGeneral() {
|
||||
if (general.container_width) {
|
||||
setContainerWidth(general.container_width);
|
||||
}
|
||||
if (general.hide_admin_bar !== undefined) {
|
||||
setHideAdminBar(!!general.hide_admin_bar);
|
||||
}
|
||||
if (general.colors) {
|
||||
setColors({
|
||||
primary: general.colors.primary || '#1a1a1a',
|
||||
@@ -116,6 +121,7 @@ export default function AppearanceGeneral() {
|
||||
scale: fontScale[0],
|
||||
},
|
||||
containerWidth,
|
||||
hideAdminBar,
|
||||
colors,
|
||||
});
|
||||
|
||||
@@ -176,6 +182,28 @@ export default function AppearanceGeneral() {
|
||||
</RadioGroup>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Admin Bar */}
|
||||
<SettingsCard
|
||||
title="Admin Bar"
|
||||
description="Control visibility of the WordPress admin bar for logged-in users"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hide-admin-bar" className="font-medium cursor-pointer">
|
||||
Hide Admin Bar on frontend
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hides the WordPress admin bar for all users when visiting your store
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-admin-bar"
|
||||
checked={hideAdminBar}
|
||||
onCheckedChange={setHideAdminBar}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* SPA Page */}
|
||||
<SettingsCard
|
||||
title="SPA Page"
|
||||
|
||||
@@ -81,7 +81,10 @@ export function CanvasSection({
|
||||
>
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50")}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg",
|
||||
!section.styles?.backgroundColor && !section.styles?.backgroundType && "bg-white/50"
|
||||
)}
|
||||
style={{
|
||||
...(section.styles?.backgroundType === 'gradient'
|
||||
? { background: `linear-gradient(${section.styles?.gradientAngle ?? 135}deg, ${section.styles?.gradientFrom || '#9333ea'}, ${section.styles?.gradientTo || '#3b82f6'})` }
|
||||
@@ -118,13 +121,50 @@ export function CanvasSection({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dynamic background placeholder (Featured Image) */}
|
||||
{section.styles?.backgroundType === 'image'
|
||||
&& section.styles?.dynamicBackground === 'post_featured_image'
|
||||
&& !section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
backgroundColor: '#e2e8f0',
|
||||
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(148,163,184,0.15) 10px, rgba(148,163,184,0.15) 20px)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 z-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium">Featured Image</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Overlay preview */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
{section.styles?.contentWidth === 'boxed' ? (
|
||||
<div className="relative z-10 container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar (Standard Interaction) */}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
@@ -23,83 +23,119 @@ interface PageItem {
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
has_template?: boolean;
|
||||
}
|
||||
|
||||
interface CreatePageModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
cptList?: PageItem[];
|
||||
onCreated: (page: PageItem) => void;
|
||||
}
|
||||
|
||||
export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) {
|
||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||
export function CreatePageModal({ open, onOpenChange, cptList = [], onCreated }: CreatePageModalProps) {
|
||||
const [mode, setMode] = useState<'page' | 'template'>('page');
|
||||
|
||||
// Structural page state
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
|
||||
|
||||
// CPT template state
|
||||
const [selectedCpt, setSelectedCpt] = useState<string>('');
|
||||
const [selectedCptPreset, setSelectedCptPreset] = useState<string>('single-post');
|
||||
|
||||
// Prevent double submission
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
// Get site URL from WordPress config
|
||||
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
||||
|
||||
// Fetch templates
|
||||
// Fetch template presets
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['templates-presets'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/templates/presets');
|
||||
return res as { id: string; label: string; description: string; icon: string }[];
|
||||
return res as { id: string; label: string; description: string; icon: string; sections?: any }[];
|
||||
}
|
||||
});
|
||||
|
||||
// Create page mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
|
||||
// Guard against double submission
|
||||
if (isSubmittingRef.current) {
|
||||
throw new Error('Request already in progress');
|
||||
}
|
||||
isSubmittingRef.current = true;
|
||||
// CPTs that don't have a template yet
|
||||
const availableCpts = cptList.filter(p => p.type === 'template' && !p.has_template);
|
||||
|
||||
// Set default CPT when list loads
|
||||
useEffect(() => {
|
||||
if (availableCpts.length > 0 && !selectedCpt) {
|
||||
setSelectedCpt(availableCpts[0].cpt || '');
|
||||
}
|
||||
}, [availableCpts, selectedCpt]);
|
||||
|
||||
// Create structural page mutation
|
||||
const createPageMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; slug: string; templateId: string }) => {
|
||||
if (isSubmittingRef.current) throw new Error('Request already in progress');
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
// api.post returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.post('/pages', {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
templateId: data.templateId
|
||||
templateId: data.templateId,
|
||||
});
|
||||
return response; // Return response directly, not response.data
|
||||
return { type: 'page' as const, data: response };
|
||||
} finally {
|
||||
// Reset after a delay to prevent race conditions
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false;
|
||||
}, 500);
|
||||
setTimeout(() => { isSubmittingRef.current = false; }, 500);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.page) {
|
||||
onSuccess: (result) => {
|
||||
if (result?.data?.page) {
|
||||
toast.success(__('Page created successfully'));
|
||||
onCreated({
|
||||
id: data.page.id,
|
||||
type: 'page',
|
||||
slug: data.page.slug,
|
||||
title: data.page.title,
|
||||
id: result.data.page.id,
|
||||
type: result.type,
|
||||
slug: result.data.page.slug,
|
||||
title: result.data.page.title,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setSelectedTemplateId('blank');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Don't show error for duplicate prevention
|
||||
if (error?.message === 'Request already in progress') {
|
||||
return;
|
||||
if (error?.message === 'Request already in progress') return;
|
||||
const message = error?.response?.data?.message || error?.message || __('Failed to create page');
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
// Create CPT template mutation
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async (data: { cpt: string; presetId: string }) => {
|
||||
if (isSubmittingRef.current) throw new Error('Request already in progress');
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
// Get preset sections
|
||||
const presets = templates as any[];
|
||||
const preset = presets.find((t: any) => t.id === data.presetId);
|
||||
const sections = preset?.sections || [];
|
||||
|
||||
const response = await api.post(`/templates/${data.cpt}`, { sections });
|
||||
return { cpt: data.cpt, data: response };
|
||||
} finally {
|
||||
setTimeout(() => { isSubmittingRef.current = false; }, 500);
|
||||
}
|
||||
// Extract error message from the response
|
||||
const message = error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
__('Failed to create page');
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
toast.success(__('Template created successfully'));
|
||||
// Find the CPT item from the list to pass back
|
||||
const cptItem = cptList.find(p => p.cpt === result.cpt);
|
||||
onCreated({
|
||||
type: 'template',
|
||||
cpt: result.cpt,
|
||||
title: cptItem?.title || `${result.cpt} Template`,
|
||||
has_template: true,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'Request already in progress') return;
|
||||
const message = error?.response?.data?.message || error?.message || __('Failed to create template');
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
@@ -107,35 +143,48 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
// Auto-generate slug from title
|
||||
const handleTitleChange = (value: string) => {
|
||||
setTitle(value);
|
||||
// Auto-generate slug only if slug matches the previously auto-generated value
|
||||
const autoSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
if (!slug || slug === autoSlug) {
|
||||
setSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = () => {
|
||||
if (createMutation.isPending || isSubmittingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (pageType === 'page' && title && slug) {
|
||||
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode('page');
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setPageType('page');
|
||||
setSelectedTemplateId('blank');
|
||||
setSelectedCpt('');
|
||||
setSelectedCptPreset('single-post');
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const isDisabled = pageType === 'page' && (!title || !slug) || createMutation.isPending || isSubmittingRef.current;
|
||||
const handleSubmit = () => {
|
||||
if (isSubmittingRef.current) return;
|
||||
|
||||
if (mode === 'page') {
|
||||
if (title && slug) {
|
||||
createPageMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||
}
|
||||
} else {
|
||||
if (selectedCpt) {
|
||||
createTemplateMutation.mutate({ cpt: selectedCpt, presetId: selectedCptPreset });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createPageMutation.isPending || createTemplateMutation.isPending;
|
||||
const isPageDisabled = !title || !slug || isPending;
|
||||
const isTemplateDisabled = !selectedCpt || isPending;
|
||||
const isDisabled = mode === 'page' ? isPageDisabled : isTemplateDisabled;
|
||||
|
||||
// Page layout presets (exclude single-post — it's for CPT)
|
||||
const pagePresets = templates.filter((tpl: any) => tpl.id !== 'single-post');
|
||||
// CPT presets (include ALL — user can pick any layout)
|
||||
const cptPresets = templates as any[];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -147,42 +196,15 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 px-6 py-4">
|
||||
{/* Page Type Selection */}
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||
onClick={() => setPageType('page')}
|
||||
>
|
||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<FileText className="w-4 h-4" />
|
||||
{__('Structural Page')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Static content like About, Contact, Terms')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'page' | 'template')}>
|
||||
<TabsList className="w-full grid grid-cols-2 mb-6">
|
||||
<TabsTrigger value="page">{__('Structural Page')}</TabsTrigger>
|
||||
<TabsTrigger value="template">{__('CPT Template')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
<Layout className="w-4 h-4" />
|
||||
{__('CPT Template')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Templates are auto-created for each post type')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Page Details */}
|
||||
{pageType === 'page' && (
|
||||
<div className="space-y-6">
|
||||
{/* ── Structural Page Tab ── */}
|
||||
<TabsContent value="page" className="space-y-6 mt-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
@@ -191,7 +213,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -201,7 +223,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||
@@ -210,9 +232,9 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Choose a Template')}</Label>
|
||||
<Label>{__('Choose a Layout')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{templates.map((tpl) => (
|
||||
{pagePresets.map((tpl: any) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
@@ -221,40 +243,80 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
`}
|
||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||
{tpl.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
<div className="mb-2 font-medium text-sm">{tpl.label}</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||
{__('Loading templates...')}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── CPT Template Tab ── */}
|
||||
<TabsContent value="template" className="space-y-6 mt-0">
|
||||
{availableCpts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm space-y-2">
|
||||
<p className="font-medium">{__('All post types already have a template.')}</p>
|
||||
<p className="text-xs">{__('Abort an existing template first to create a new one.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Select Post Type')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{availableCpts.map((cpt) => (
|
||||
<div
|
||||
key={cpt.cpt}
|
||||
className={`
|
||||
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedCpt === cpt.cpt ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedCpt(cpt.cpt || '')}
|
||||
>
|
||||
<div className="font-medium text-sm">{cpt.title}</div>
|
||||
{cpt.cpt && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">/{cpt.cpt}/</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Starting Layout')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{cptPresets.map((tpl: any) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedCptPreset === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedCptPreset(tpl.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{tpl.label}</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{tpl.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={createMutation.isPending}>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Button onClick={handleSubmit} disabled={isDisabled}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{__('Creating...')}
|
||||
</>
|
||||
) : (
|
||||
__('Create Page')
|
||||
mode === 'page' ? __('Create Page') : __('Create Template')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -54,6 +54,10 @@ export function InspectorField({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (val: string) => {
|
||||
onChange({ type: 'dynamic', source: val });
|
||||
};
|
||||
|
||||
const handleTypeToggle = (dynamic: boolean) => {
|
||||
if (dynamic) {
|
||||
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
|
||||
@@ -85,7 +89,8 @@ export function InspectorField({
|
||||
</div>
|
||||
|
||||
{isDynamic && supportsDynamic ? (
|
||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
||||
<div className="space-y-2">
|
||||
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select data source" />
|
||||
</SelectTrigger>
|
||||
@@ -97,6 +102,7 @@ export function InspectorField({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : fieldType === 'rte' ? (
|
||||
<RichTextEditor
|
||||
content={currentValue}
|
||||
|
||||
@@ -60,6 +60,7 @@ interface InspectorPanelProps {
|
||||
onSetAsSpaLanding?: () => void;
|
||||
onUnsetSpaLanding?: () => void;
|
||||
onDeletePage?: () => void;
|
||||
onDeleteTemplate?: () => void;
|
||||
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
||||
}
|
||||
|
||||
@@ -127,7 +128,6 @@ const COLOR_SCHEMES = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
];
|
||||
|
||||
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
||||
@@ -183,6 +183,7 @@ export function InspectorPanel({
|
||||
onSetAsSpaLanding,
|
||||
onUnsetSpaLanding,
|
||||
onDeletePage,
|
||||
onDeleteTemplate,
|
||||
onContainerWidthChange,
|
||||
}: InspectorPanelProps) {
|
||||
if (isCollapsed) {
|
||||
@@ -306,6 +307,25 @@ export function InspectorPanel({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone - Templates */}
|
||||
{isTemplate && page && onDeleteTemplate && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{__('Deleting this template will disable SPA rendering for this post type. WordPress will handle it natively.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onDeleteTemplate}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Abort SPA Template')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
|
||||
{__('Select any section on the canvas to edit its content and design.')}
|
||||
@@ -433,21 +453,32 @@ export function InspectorPanel({
|
||||
</div>
|
||||
|
||||
{/* Feature Grid Repeater */}
|
||||
{selectedSection.type === 'feature-grid' && (
|
||||
{selectedSection.type === 'feature-grid' && (() => {
|
||||
const featuresProp = selectedSection.props.features;
|
||||
const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
|
||||
const items = Array.isArray(featuresProp?.value) ? featuresProp.value : [];
|
||||
return (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
|
||||
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
|
||||
items={items}
|
||||
onChange={(newItems) => onSectionPropChange('features', { type: 'static', value: newItems })}
|
||||
fields={[
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'icon', label: 'Icon', type: 'icon' },
|
||||
]}
|
||||
itemLabelKey="title"
|
||||
isDynamic={isDynamicFeatures}
|
||||
dynamicLabel={
|
||||
isDynamicFeatures
|
||||
? `⚡ Auto-populated from "${featuresProp.source}" at runtime`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
@@ -571,10 +602,42 @@ export function InspectorPanel({
|
||||
)}
|
||||
|
||||
{/* Image Background */}
|
||||
{selectedSection.styles?.backgroundType === 'image' && (
|
||||
{selectedSection.styles?.backgroundType === 'image' && (() => {
|
||||
const isDynamicBg = selectedSection.styles?.dynamicBackground === 'post_featured_image';
|
||||
return (
|
||||
<>
|
||||
{/* Source toggle: Upload vs Featured Image */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<div className="flex gap-1 p-0.5 bg-gray-100 rounded-md">
|
||||
<button
|
||||
onClick={() => onSectionStylesChange({ dynamicBackground: undefined })}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
!isDynamicBg
|
||||
? 'bg-white shadow-sm font-medium text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
Upload Image
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSectionStylesChange({ dynamicBackground: 'post_featured_image', backgroundImage: '' })}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
isDynamicBg
|
||||
? 'bg-white shadow-sm font-medium text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
Featured Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static upload */}
|
||||
{!isDynamicBg && (
|
||||
<div className="space-y-2">
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
@@ -597,6 +660,15 @@ export function InspectorPanel({
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic source info */}
|
||||
{isDynamicBg && (
|
||||
<div className="flex items-start gap-2 text-xs bg-blue-50 border border-blue-200 rounded-md p-2.5 text-blue-700">
|
||||
<span className="mt-0.5">⚡</span>
|
||||
<span>At runtime, the background will use this post's featured image. Falls back to no background if no featured image is set.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -612,7 +684,8 @@ export function InspectorPanel({
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Spacing Controls */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t mt-4">
|
||||
@@ -643,7 +716,7 @@ export function InspectorPanel({
|
||||
<RadioGroup
|
||||
value={selectedSection.styles?.contentWidth || 'full'}
|
||||
onValueChange={(val: any) => onSectionStylesChange({ contentWidth: val })}
|
||||
className="flex gap-4"
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="full" id="width-full" />
|
||||
@@ -653,6 +726,10 @@ export function InspectorPanel({
|
||||
<RadioGroupItem value="contained" id="width-contained" />
|
||||
<Label htmlFor="width-contained" className="text-sm font-normal cursor-pointer">{__('Contained')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="boxed" id="width-boxed" />
|
||||
<Label htmlFor="width-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed (Card)')}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ interface InspectorRepeaterProps {
|
||||
fields: RepeaterFieldDef[];
|
||||
onChange: (items: any[]) => void;
|
||||
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
|
||||
isDynamic?: boolean; // If true, items come from a dynamic source — hide Add Item
|
||||
dynamicLabel?: string; // Custom label for the dynamic placeholder
|
||||
}
|
||||
|
||||
// Sortable Item Component
|
||||
@@ -148,7 +150,7 @@ function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelet
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) {
|
||||
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title', isDynamic = false, dynamicLabel }: InspectorRepeaterProps) {
|
||||
// Generate simple stable IDs for sorting if items don't have them
|
||||
const itemIds = items.map((_, i) => `item-${i}`);
|
||||
|
||||
@@ -191,10 +193,12 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
||||
{!isDynamic && (
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
@@ -224,8 +228,15 @@ export function InspectorRepeater({ label, items = [], fields, onChange, itemLab
|
||||
</Accordion>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
|
||||
No items yet. Click "Add Item" to start.
|
||||
<div className={cn(
|
||||
"text-xs text-center py-4 border rounded-md",
|
||||
isDynamic
|
||||
? "text-blue-600 border-blue-200 bg-blue-50"
|
||||
: "text-gray-400 border-dashed bg-gray-50"
|
||||
)}>
|
||||
{isDynamic
|
||||
? (dynamicLabel || '⚡ Auto-populated from related posts at runtime')
|
||||
: 'No items yet. Click "Add Item" to start.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface PageSidebarProps {
|
||||
|
||||
export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) {
|
||||
const structuralPages = pages.filter(p => p.type === 'page');
|
||||
const templates = pages.filter(p => p.type === 'template');
|
||||
const templates = pages.filter(p => p.type === 'template' && p.has_template);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -69,7 +69,10 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
|
||||
{__('Templates')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{templates.map((template) => (
|
||||
{templates.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">{__('No templates yet')}</p>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<button
|
||||
key={`template-${template.cpt}`}
|
||||
onClick={() => onSelectPage(template)}
|
||||
@@ -86,7 +89,8 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
|
||||
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface CTABannerRendererProps {
|
||||
@@ -21,17 +22,26 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; b
|
||||
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
|
||||
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
|
||||
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
|
||||
};
|
||||
|
||||
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'] ?? COLOR_SCHEMES['primary'];
|
||||
|
||||
const title = section.props?.title?.value || 'Ready to get started?';
|
||||
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
|
||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||
const buttonUrl = section.props?.button_url?.value || '#';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
@@ -56,8 +66,10 @@ export function CTABannerRenderer({ section, className }: CTABannerRendererProps
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
|
||||
<div className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && scheme.bg, scheme.text, className)}>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2
|
||||
className={cn(
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface ContactFormRendererProps {
|
||||
@@ -21,11 +22,10 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string;
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
|
||||
};
|
||||
|
||||
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
|
||||
const title = section.props?.title?.value || 'Contact Us';
|
||||
|
||||
@@ -69,10 +69,22 @@ export function ContactFormRenderer({ section, className }: ContactFormRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<h2
|
||||
|
||||
@@ -15,7 +15,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
@@ -152,7 +151,7 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
};
|
||||
|
||||
export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
@@ -211,17 +210,20 @@ export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`section-${section.id}`}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text,
|
||||
className
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Image, Calendar, User } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -8,6 +9,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface FeatureGridRendererProps {
|
||||
@@ -20,7 +22,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
};
|
||||
|
||||
const GRID_CLASSES: Record<string, string> = {
|
||||
@@ -29,20 +30,57 @@ const GRID_CLASSES: Record<string, string> = {
|
||||
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// Default features for demo
|
||||
// Default features for static demo
|
||||
const DEFAULT_FEATURES = [
|
||||
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
|
||||
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
|
||||
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
|
||||
];
|
||||
|
||||
// Placeholder post-card skeleton shown when features are dynamic (related_posts)
|
||||
function PostCardPlaceholder({ index, cardBg }: { index: number; cardBg: string }) {
|
||||
const widths = ['w-3/4', 'w-2/3', 'w-4/5'];
|
||||
const titleWidth = widths[index % widths.length];
|
||||
return (
|
||||
<div className={cn('rounded-xl overflow-hidden', cardBg, 'shadow-sm border border-dashed border-gray-300')}>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="aspect-[16/9] bg-gray-200 flex items-center justify-center">
|
||||
<Image className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> Jan 1, 2025</span>
|
||||
<span className="flex items-center gap-1"><User className="w-3 h-3" /> Author</span>
|
||||
</div>
|
||||
{/* Title skeleton */}
|
||||
<div className={cn('h-4 bg-gray-300 rounded animate-pulse', titleWidth)} />
|
||||
{/* Excerpt skeleton */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-5/6" />
|
||||
</div>
|
||||
{/* "Read more" chip */}
|
||||
<div className="pt-1">
|
||||
<div className="inline-block h-3 w-16 bg-blue-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'grid-3';
|
||||
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
|
||||
|
||||
const heading = section.props?.heading?.value || 'Our Features';
|
||||
const features = section.props?.features?.value || DEFAULT_FEATURES;
|
||||
const featuresProp = section.props?.features;
|
||||
const isDynamic = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
|
||||
const features = isDynamic ? [] : (featuresProp?.value || DEFAULT_FEATURES);
|
||||
|
||||
// Determine how many placeholder post-cards to show (match grid columns)
|
||||
const placeholderCount = layout === 'grid-4' ? 4 : layout === 'grid-2' ? 2 : 3;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -81,10 +119,22 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{heading && (
|
||||
@@ -99,11 +149,18 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Dynamic (related posts) — show post-card skeleton placeholders */}
|
||||
{isDynamic ? (
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{Array.from({ length: placeholderCount }).map((_, i) => (
|
||||
<PostCardPlaceholder key={i} index={i} cardBg={scheme.cardBg} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Static items — regular icon feature cards */
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
|
||||
// Resolve icon from name, fallback to Star
|
||||
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
@@ -139,7 +196,9 @@ export function FeatureGridRenderer({ section, className }: FeatureGridRendererP
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Section {
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
interface HeroRendererProps {
|
||||
@@ -20,13 +21,22 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const title = section.props?.title?.value || 'Hero Title';
|
||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||
const image = section.props?.image?.value;
|
||||
@@ -66,12 +76,12 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
// Helper for image styles
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
@@ -146,7 +156,7 @@ export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
// Default centered layout
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
className={cn(heightClasses, 'px-4 md:px-8 text-center', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1
|
||||
|
||||
@@ -14,11 +14,10 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
const layout = section.layoutVariant || 'image-left';
|
||||
const isImageRight = layout === 'image-right';
|
||||
|
||||
@@ -73,10 +72,22 @@ export function ImageTextRenderer({ section, className }: ImageTextRendererProps
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-[50vh] flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = section.styles?.paddingTop || section.styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[section.styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
className={cn(heightClasses, 'px-4 md:px-8', !hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={hasCustomBackground ? {} : getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
|
||||
@@ -145,6 +145,22 @@ export default function AppearancePages() {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete template mutation (abort SPA for this CPT)
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (cpt: string) => {
|
||||
return api.del(`/templates/${cpt}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('CPT template deleted. WordPress will handle this post type natively.'));
|
||||
markAsSaved();
|
||||
setCurrentPage(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete template'));
|
||||
},
|
||||
});
|
||||
|
||||
// Set as SPA Landing mutation
|
||||
const setSpaLandingMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
@@ -212,6 +228,14 @@ export default function AppearancePages() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = () => {
|
||||
if (!currentPage || currentPage.type !== 'template' || !currentPage.cpt) return;
|
||||
|
||||
if (confirm(__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.'))) {
|
||||
deleteTemplateMutation.mutate(currentPage.cpt);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
@@ -358,6 +382,7 @@ export default function AppearancePages() {
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
onContainerWidthChange={(width) => {
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||
@@ -373,6 +398,7 @@ export default function AppearancePages() {
|
||||
< CreatePageModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
cptList={pages.filter((p: PageItem) => p.type === 'template')}
|
||||
onCreated={(newPage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
setCurrentPage(newPage);
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface SectionStyles {
|
||||
backgroundOverlay?: number; // 0-100 opacity
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
contentWidth?: 'full' | 'contained' | 'boxed';
|
||||
heightPreset?: string;
|
||||
dynamicBackground?: string; // e.g. 'post_featured_image'
|
||||
}
|
||||
|
||||
export interface ElementStyle {
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function AppearanceProduct() {
|
||||
const [imagePosition, setImagePosition] = useState('left');
|
||||
const [galleryStyle, setGalleryStyle] = useState('thumbnails');
|
||||
const [stickyAddToCart, setStickyAddToCart] = useState(false);
|
||||
const [layoutStyle, setLayoutStyle] = useState('flat');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
breadcrumbs: true,
|
||||
@@ -40,6 +41,7 @@ export default function AppearanceProduct() {
|
||||
if (product.layout.image_position) setImagePosition(product.layout.image_position);
|
||||
if (product.layout.gallery_style) setGalleryStyle(product.layout.gallery_style);
|
||||
if (product.layout.sticky_add_to_cart !== undefined) setStickyAddToCart(product.layout.sticky_add_to_cart);
|
||||
if (product.layout.layout_style) setLayoutStyle(product.layout.layout_style);
|
||||
}
|
||||
if (product.elements) {
|
||||
setElements({
|
||||
@@ -80,7 +82,8 @@ export default function AppearanceProduct() {
|
||||
layout: {
|
||||
image_position: imagePosition,
|
||||
gallery_style: galleryStyle,
|
||||
sticky_add_to_cart: stickyAddToCart
|
||||
sticky_add_to_cart: stickyAddToCart,
|
||||
layout_style: layoutStyle,
|
||||
},
|
||||
elements,
|
||||
related_products: {
|
||||
@@ -106,6 +109,23 @@ export default function AppearanceProduct() {
|
||||
title="Layout"
|
||||
description="Configure product page layout and gallery"
|
||||
>
|
||||
<SettingsSection label="Layout Style" htmlFor="layout-style">
|
||||
<Select value={layoutStyle} onValueChange={setLayoutStyle}>
|
||||
<SelectTrigger id="layout-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flat">Flat — content floats on page background</SelectItem>
|
||||
<SelectItem value="card">Card — content inside a white elevated card</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{layoutStyle === 'flat'
|
||||
? 'Clean, minimal look. Product sections blend with the page background.'
|
||||
: 'Each product section is wrapped in a white card, elevated from the background.'}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Image Position" htmlFor="image-position">
|
||||
<Select value={imagePosition} onValueChange={setImagePosition}>
|
||||
<SelectTrigger id="image-position">
|
||||
|
||||
100
customer-spa/package-lock.json
generated
100
customer-spa/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -62,7 +63,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -1057,7 +1057,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -1079,7 +1078,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1089,14 +1087,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1107,7 +1103,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -1121,7 +1116,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -1131,7 +1125,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -2660,6 +2653,31 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.10",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz",
|
||||
@@ -3099,14 +3117,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -3120,7 +3136,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -3365,7 +3380,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3388,7 +3402,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -3495,7 +3508,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3543,7 +3555,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -3568,7 +3579,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -3638,7 +3648,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -3686,7 +3695,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -3827,14 +3835,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
@@ -4433,7 +4439,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -4450,7 +4455,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -4477,7 +4481,6 @@
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -4500,7 +4503,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -4581,7 +4583,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -4596,7 +4597,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4723,7 +4723,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -4867,7 +4866,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -5012,7 +5010,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -5055,7 +5052,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -5106,7 +5102,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5152,7 +5147,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -5191,7 +5185,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -5395,7 +5388,6 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -5511,7 +5503,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -5524,7 +5515,6 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -5595,7 +5585,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -5605,7 +5594,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -5642,7 +5630,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -5654,7 +5641,6 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5687,7 +5673,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5707,7 +5692,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5717,7 +5701,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5926,21 +5909,18 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -5953,7 +5933,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5963,7 +5942,6 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5983,7 +5961,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6012,7 +5989,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -6030,7 +6006,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -6051,7 +6026,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6077,7 +6051,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6120,7 +6093,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6146,7 +6118,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -6160,7 +6131,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
@@ -6199,7 +6169,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6405,7 +6374,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -6415,7 +6383,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -6500,7 +6467,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -6553,7 +6519,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6824,7 +6789,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6959,7 +6923,6 @@
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -6995,7 +6958,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -7018,7 +6980,6 @@
|
||||
"version": "3.4.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -7066,7 +7027,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -7087,7 +7047,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -7097,7 +7056,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -7110,7 +7068,6 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -7127,7 +7084,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -7145,7 +7101,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -7158,7 +7113,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -7184,7 +7138,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@@ -7421,7 +7374,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -20,6 +20,7 @@ import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import OrderPay from './pages/OrderPay';
|
||||
import Subscribe from './pages/Subscribe';
|
||||
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||
|
||||
// Create QueryClient instance
|
||||
@@ -116,6 +117,9 @@ function AppRoutes() {
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
|
||||
{/* Newsletter / Notifications */}
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
|
||||
{/* Login & Auth */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
|
||||
@@ -12,7 +12,7 @@ interface SharedContentProps {
|
||||
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
// Layout
|
||||
containerWidth?: 'full' | 'contained';
|
||||
containerWidth?: 'full' | 'contained' | 'boxed';
|
||||
|
||||
// Styles
|
||||
className?: string;
|
||||
@@ -53,15 +53,19 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
const isImageTop = imagePosition === 'top';
|
||||
const isImageBottom = imagePosition === 'bottom';
|
||||
|
||||
// Wrapper classes
|
||||
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-7xl' : ''
|
||||
containerWidth === 'contained' ? 'max-w-4xl'
|
||||
: containerWidth === 'boxed' ? 'max-w-5xl'
|
||||
: '' // full = no max-width cap
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto',
|
||||
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
|
||||
hasImage && (isImageLeft || isImageRight)
|
||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
|
||||
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
|
||||
);
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
@@ -74,6 +78,8 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{containerWidth === 'boxed' ? (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
@@ -96,7 +102,7 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
@@ -111,8 +117,9 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
|
||||
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
@@ -151,5 +158,81 @@ export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8'
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ export function useProductSettings() {
|
||||
image_position: 'left' as string,
|
||||
gallery_style: 'thumbnails' as string,
|
||||
sticky_add_to_cart: false,
|
||||
layout_style: 'flat' as string,
|
||||
},
|
||||
elements: {
|
||||
breadcrumbs: true,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SectionStyleResult {
|
||||
*/
|
||||
export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult {
|
||||
if (!styles) {
|
||||
return { style: {}, hasOverlay: false, overlayStyle: undefined };
|
||||
return { style: {}, hasOverlay: false, overlayOpacity: 0 };
|
||||
}
|
||||
|
||||
const bgType = styles.backgroundType || 'solid';
|
||||
@@ -56,3 +56,30 @@ export function getSectionBackground(styles?: Record<string, any>): SectionStyle
|
||||
|
||||
return { style, hasOverlay, overlayOpacity, backgroundImage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns inner container class names for the three content width modes:
|
||||
* - full: edge-to-edge, no max-width
|
||||
* - contained: centered max-w-6xl (matches Product page / SPA default)
|
||||
* - boxed: centered max-w-5xl, wrapped in a white rounded-2xl card (matches product accordion cards)
|
||||
*
|
||||
* For 'boxed', apply this to the inner container div; no extra wrapper needed.
|
||||
*/
|
||||
export function getContentWidthClasses(contentWidth?: string): string {
|
||||
switch (contentWidth) {
|
||||
case 'full':
|
||||
return 'w-full px-4 md:px-8';
|
||||
case 'boxed':
|
||||
return 'container mx-auto px-4 max-w-5xl';
|
||||
case 'contained':
|
||||
default:
|
||||
return 'container mx-auto px-4';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the section uses the boxed (card) layout.
|
||||
*/
|
||||
export function isBoxedLayout(contentWidth?: string): boolean {
|
||||
return contentWidth === 'boxed';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Section Components
|
||||
import { HeroSection } from './sections/HeroSection';
|
||||
@@ -121,14 +122,25 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
const navigate = useNavigate();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
// Use prop slug if provided, otherwise use param slug
|
||||
const effectiveSlug = propSlug || paramSlug;
|
||||
// Get page type from DOM (injected by TemplateOverride.php)
|
||||
const appEl = document.getElementById('woonoow-customer-app');
|
||||
const dataPageType = appEl?.getAttribute('data-page');
|
||||
const dataCptType = appEl?.getAttribute('data-cpt-type'); // e.g. 'post', 'portfolio'
|
||||
const dataCptSlug = appEl?.getAttribute('data-cpt-slug'); // e.g. 'my-post-slug'
|
||||
|
||||
// Determine content type:
|
||||
// Priority: pathBase from router > data-cpt-type from DOM > fallback
|
||||
const contentType = pathBase
|
||||
? (pathBase === 'blog' ? 'post' : pathBase)
|
||||
: (dataPageType === 'cpt' && dataCptType ? dataCptType : undefined);
|
||||
|
||||
// Effective slug: prefer router param, then DOM cpt-slug
|
||||
const effectiveSlug = propSlug || paramSlug || (dataPageType === 'cpt' ? dataCptSlug : undefined) || '';
|
||||
|
||||
// Determine if this is a page or CPT content
|
||||
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
|
||||
const isStructuralPage = !pathBase || !!propSlug;
|
||||
const contentType = pathBase === 'blog' ? 'post' : pathBase;
|
||||
const contentSlug = effectiveSlug || '';
|
||||
const isStructuralPage = dataPageType === 'page' || dataPageType === 'shop' || contentType === undefined;
|
||||
|
||||
const contentSlug = effectiveSlug;
|
||||
|
||||
// Fetch page/content data
|
||||
const { data: pageData, isLoading, error } = useQuery<PageData>({
|
||||
@@ -138,11 +150,12 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
// Fetch structural page - api.get returns JSON directly
|
||||
const response = await api.get<PageData>(`/pages/${contentSlug}`);
|
||||
return response;
|
||||
} else {
|
||||
} else if (contentType) {
|
||||
// Fetch CPT content with template
|
||||
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
|
||||
return response;
|
||||
}
|
||||
throw new Error("Unable to determine content type");
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
@@ -175,6 +188,16 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
|
||||
<p className="text-gray-600 mb-8">Page not found</p>
|
||||
<div className="bg-gray-100 p-4 rounded text-left mb-8 text-sm">
|
||||
<strong>DEBUG INFO:</strong><br />
|
||||
pathBase: {pathBase ?? 'undefined'}<br />
|
||||
propSlug: {propSlug ?? 'undefined'}<br />
|
||||
paramSlug: {paramSlug ?? 'undefined'}<br />
|
||||
effectiveSlug: {effectiveSlug ?? 'undefined'}<br />
|
||||
dataPageType: {dataPageType ?? 'undefined'}<br />
|
||||
contentType: {contentType ?? 'undefined'}<br />
|
||||
isStructuralPage: {isStructuralPage ? 'true' : 'false'}<br />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
@@ -226,15 +249,15 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
// Only explicit custom padding overrides from the padding fields
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
{/* Full-bleed background image & overlay */}
|
||||
{section.styles?.backgroundImage && (section.styles.backgroundType === 'image' || !section.styles.backgroundType) && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
@@ -247,11 +270,11 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
|
||||
{/* Section component — manages its own background, height, and inner content width */}
|
||||
<div className="relative z-10 w-full">
|
||||
<SectionComponent
|
||||
id={section.id}
|
||||
section={section} // Pass full section object for components that need raw data
|
||||
section={section}
|
||||
layout={section.layoutVariant || 'default'}
|
||||
colorScheme={section.colorScheme || 'default'}
|
||||
styles={section.styles}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
id: string;
|
||||
@@ -22,26 +23,34 @@ export function CTABannerSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
const es = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
es.fontSize,
|
||||
es.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
'font-sans': es.fontFamily === 'secondary',
|
||||
'font-serif': es.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderColor: styles.borderColor,
|
||||
borderWidth: styles.borderWidth,
|
||||
borderRadius: styles.borderRadius,
|
||||
color: es.color,
|
||||
textAlign: es.textAlign,
|
||||
backgroundColor: es.backgroundColor,
|
||||
borderColor: es.borderColor,
|
||||
borderWidth: es.borderWidth,
|
||||
borderRadius: es.borderRadius,
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -49,31 +58,15 @@ export function CTABannerSection({
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-cta-banner',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
{
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
|
||||
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
|
||||
// Shared inner content — same markup used in boxed and non-boxed
|
||||
const innerContent = (
|
||||
<>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-cta__title mb-6",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
@@ -87,10 +80,11 @@ export function CTABannerSection({
|
||||
<p className={cn(
|
||||
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
|
||||
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
|
||||
{
|
||||
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
styles?.contentWidth !== 'boxed' && {
|
||||
'text-white/90': colorScheme === 'primary',
|
||||
'text-gray-600': colorScheme === 'muted',
|
||||
},
|
||||
styles?.contentWidth === 'boxed' && 'text-gray-600',
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
@@ -104,14 +98,18 @@ export function CTABannerSection({
|
||||
href={button_url}
|
||||
className={cn(
|
||||
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && {
|
||||
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
!btnStyle.style?.backgroundColor && (styles?.contentWidth === 'boxed'
|
||||
? 'bg-primary'
|
||||
: {
|
||||
'bg-white': colorScheme === 'primary',
|
||||
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
!btnStyle.style?.color && {
|
||||
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
|
||||
}),
|
||||
!btnStyle.style?.color && (styles?.contentWidth === 'boxed'
|
||||
? 'text-primary-foreground'
|
||||
: {
|
||||
'text-primary': colorScheme === 'primary',
|
||||
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
|
||||
},
|
||||
}),
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
@@ -119,7 +117,54 @@ export function CTABannerSection({
|
||||
{button_text}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-cta-banner',
|
||||
`wn-cta-banner--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
{
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
'bg-secondary text-secondary-foreground': colorScheme === 'secondary' && !hasCustomBackground,
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
{styles?.contentWidth === 'boxed' ? (
|
||||
<div className="container mx-auto px-4 max-w-5xl">
|
||||
<div className="bg-white text-gray-900 rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10 text-center">
|
||||
{innerContent}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"mx-auto px-4 text-center",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
)}>
|
||||
{innerContent}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
id: string;
|
||||
@@ -23,6 +24,15 @@ export function ContactFormSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
@@ -87,6 +97,19 @@ export function ContactFormSection({
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}; const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,17 +118,18 @@ export function ContactFormSection({
|
||||
className={cn(
|
||||
'wn-section wn-contact-form',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-20',
|
||||
heightClasses,
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'max-w-xl mx-auto',
|
||||
@@ -116,7 +140,7 @@ export function ContactFormSection({
|
||||
{title && (
|
||||
<h2 className={cn(
|
||||
"wn-contact__title text-center mb-12",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
|
||||
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl lg:text-5xl",
|
||||
!elementStyles?.title?.fontWeight && "font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ContentSectionProps {
|
||||
id?: string;
|
||||
section: {
|
||||
id: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props?: {
|
||||
content?: { value: string };
|
||||
cta_text?: { value: string };
|
||||
cta_url?: { value: string };
|
||||
};
|
||||
elementStyles?: Record<string, any>;
|
||||
styles?: Record<string, any>;
|
||||
props?: any;
|
||||
};
|
||||
content?: string;
|
||||
cta_text?: string;
|
||||
cta_url?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
@@ -25,7 +26,6 @@ const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
@@ -164,11 +164,10 @@ const generateScopedStyles = (sectionId: string, elementStyles: Record<string, a
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentSection({ section }: ContentSectionProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
export function ContentSection({ section, content: propContent, cta_text: propCtaText, cta_url: propCtaUrl, outerPadding = false }: ContentSectionProps & { outerPadding?: boolean }) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'] ?? COLOR_SCHEMES['default'];
|
||||
// Default to 'default' width if not specified
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = section.styles?.contentWidth === 'full' ? WIDTH_CLASSES.full : (WIDTH_CLASSES[layout] || WIDTH_CLASSES.default);
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
@@ -182,7 +181,7 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || '';
|
||||
const content = propContent || section.props?.content?.value || section.props?.content || '';
|
||||
|
||||
// Helper to get text styles
|
||||
const getTextStyles = (elementName: string) => {
|
||||
@@ -209,15 +208,16 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
const textStyle = getTextStyles('text');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
const containerWidth = section.styles?.contentWidth || 'contained';
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
const containerWidth = section.styles?.contentWidth ?? 'contained';
|
||||
const cta_text = propCtaText || section.props?.cta_text?.value || section.props?.cta_text;
|
||||
const cta_url = propCtaUrl || section.props?.cta_url?.value || section.props?.cta_url;
|
||||
|
||||
const hasCustomBackground = !!section.styles?.backgroundColor || !!section.styles?.backgroundImage || section.styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(section.styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
@@ -234,9 +234,8 @@ export function ContentSection({ section }: ContentSectionProps) {
|
||||
id={section.id}
|
||||
className={cn(
|
||||
'wn-content',
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
!hasCustomBackground && !scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface FeatureItem {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
// Post-card fields (from related_posts dynamic source)
|
||||
url?: string;
|
||||
featured_image?: string;
|
||||
excerpt?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface FeatureGridSectionProps {
|
||||
@@ -26,15 +32,26 @@ export function FeatureGridSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
|
||||
// Use items or features (priority to items if both exist, but usually only one comes from props)
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||
const listItems = items.length > 0 ? items : features;
|
||||
|
||||
const gridCols = {
|
||||
'grid-2': 'md:grid-cols-2',
|
||||
'grid-3': 'md:grid-cols-3',
|
||||
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
|
||||
}[layout] || 'md:grid-cols-3';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
// Detect if these are post-cards (from related_posts) — they have a url field
|
||||
const isPostCards = listItems.some(item => !!item.url);
|
||||
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = elementStyles?.[elementName] || {};
|
||||
return {
|
||||
@@ -60,6 +77,21 @@ export function FeatureGridSection({
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
@@ -67,23 +99,25 @@ export function FeatureGridSection({
|
||||
'wn-section wn-feature-grid',
|
||||
`wn-feature-grid--${layout}`,
|
||||
`wn-scheme--${colorScheme}`,
|
||||
'py-12 md:py-24',
|
||||
heightClasses,
|
||||
{
|
||||
// 'bg-white': colorScheme === 'default', // Removed for global styling
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary text-primary-foreground': colorScheme === 'primary',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
'text-primary-foreground': colorScheme === 'primary' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
"mx-auto px-4",
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container'
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container'
|
||||
)}>
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
"wn-features__heading text-center mb-12",
|
||||
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
|
||||
"wn-features__heading text-center mb-10",
|
||||
!elementStyles?.heading?.fontSize && "text-2xl md:text-3xl lg:text-4xl",
|
||||
!elementStyles?.heading?.fontWeight && "font-bold",
|
||||
headingStyle.classNames
|
||||
)}
|
||||
@@ -93,8 +127,66 @@ export function FeatureGridSection({
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridCols)}>
|
||||
{listItems.map((item, index) => (
|
||||
<div className={cn('grid gap-6', gridCols)}>
|
||||
{listItems.map((item, index) => {
|
||||
// ── Post Card (from related_posts) ──────────────────────────
|
||||
if (isPostCards) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={item.url || '#'}
|
||||
className={cn(
|
||||
'wn-post-card group block rounded-xl overflow-hidden transition-all duration-200',
|
||||
'bg-white shadow-md hover:shadow-xl hover:-translate-y-1',
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{item.featured_image ? (
|
||||
<div className="aspect-[16/9] overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={item.featured_image}
|
||||
alt={item.title || ''}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[16/9] bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-5">
|
||||
{item.date && (
|
||||
<p className="text-xs text-gray-400 mb-2 uppercase tracking-wider">{item.date}</p>
|
||||
)}
|
||||
{item.title && (
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-snug mb-2 group-hover:text-primary transition-colors line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
{(item.excerpt || item.description) && (
|
||||
<p className="text-sm text-gray-500 line-clamp-3 mb-4">
|
||||
{item.excerpt || item.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-primary">
|
||||
Read more
|
||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature Card (icon + title + desc) ─────────────────────
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
@@ -117,7 +209,6 @@ export function FeatureGridSection({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{item.title && (
|
||||
<h3
|
||||
className={cn(
|
||||
@@ -129,9 +220,9 @@ export function FeatureGridSection({
|
||||
{item.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{item.description && (
|
||||
<p className={cn(
|
||||
<p
|
||||
className={cn(
|
||||
'wn-feature-grid__item-desc',
|
||||
!featureItemStyle.style?.color && {
|
||||
'text-gray-600': colorScheme !== 'primary',
|
||||
@@ -144,8 +235,14 @@ export function FeatureGridSection({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty state for related posts */}
|
||||
{isPostCards && listItems.length === 0 && (
|
||||
<p className="text-center text-gray-400 text-sm py-8">No related articles found.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,15 @@ export function HeroSection({
|
||||
elementStyles,
|
||||
styles,
|
||||
}: HeroSectionProps & { styles?: Record<string, any> }) {
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-16 md:py-28',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-36',
|
||||
'fullscreen': 'min-h-screen flex flex-col justify-center',
|
||||
};
|
||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-16 md:py-28');
|
||||
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
|
||||
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
|
||||
const isCentered = layout === 'centered' || layout === 'default';
|
||||
@@ -67,9 +76,6 @@ export function HeroSection({
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
// If user set custom bg via Design tab, use that
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'gradient') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
@@ -79,7 +85,7 @@ export function HeroSection({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
|
||||
const isDynamicScheme = ['primary', 'secondary'].includes(colorScheme) && !hasCustomBackground;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -88,12 +94,15 @@ export function HeroSection({
|
||||
'wn-section wn-hero',
|
||||
`wn-hero--${layout}`,
|
||||
'relative overflow-hidden',
|
||||
heightClasses,
|
||||
)}
|
||||
style={sectionBg.style}
|
||||
>
|
||||
<div className={cn(
|
||||
'mx-auto px-4 z-10 relative flex w-full',
|
||||
styles?.contentWidth === 'full' ? 'w-full' : 'container max-w-7xl',
|
||||
styles?.contentWidth === 'full' ? 'w-full'
|
||||
: styles?.contentWidth === 'boxed' ? 'max-w-5xl'
|
||||
: 'container max-w-7xl',
|
||||
{
|
||||
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
|
||||
'text-center': isCentered,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedContentLayout } from '@/components/SharedContentLayout';
|
||||
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||
|
||||
interface ImageTextSectionProps {
|
||||
id: string;
|
||||
@@ -66,25 +67,40 @@ export function ImageTextSection({
|
||||
};
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
|
||||
|
||||
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage || styles?.backgroundType === 'gradient';
|
||||
const sectionBg = getSectionBackground(styles);
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = (): React.CSSProperties | undefined => {
|
||||
if (hasCustomBackground) return sectionBg.style;
|
||||
if (colorScheme === 'primary') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (colorScheme === 'secondary') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'wn-section wn-image-text',
|
||||
`wn-scheme--${colorScheme}`,
|
||||
heightClasses,
|
||||
!styles?.paddingTop && !styles?.paddingBottom && heightClasses,
|
||||
{
|
||||
'bg-muted': colorScheme === 'muted',
|
||||
'bg-primary/5': colorScheme === 'primary',
|
||||
'bg-muted': colorScheme === 'muted' && !hasCustomBackground,
|
||||
}
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<SharedContentLayout
|
||||
title={title}
|
||||
text={text}
|
||||
image={image}
|
||||
imagePosition={isImageRight ? 'right' : 'left'}
|
||||
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
|
||||
containerWidth={styles?.contentWidth === 'full' ? 'full' : styles?.contentWidth === 'boxed' ? 'boxed' : 'contained'}
|
||||
titleStyle={titleStyle.style}
|
||||
titleClassName={titleStyle.classNames}
|
||||
textStyle={textStyle.style}
|
||||
|
||||
@@ -29,6 +29,20 @@ export default function Product() {
|
||||
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist, isLoggedIn } = useWishlist();
|
||||
const { isEnabled: isModuleEnabled } = useModules();
|
||||
|
||||
// Apply white background to <main> in flat mode so the full viewport width is white
|
||||
useEffect(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main) return;
|
||||
if (layout.layout_style === 'flat') {
|
||||
(main as HTMLElement).style.backgroundColor = '#ffffff';
|
||||
} else {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
}
|
||||
return () => {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
};
|
||||
}, [layout.layout_style]);
|
||||
|
||||
// Fetch product details by slug
|
||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||
queryKey: ['product', slug],
|
||||
@@ -94,10 +108,16 @@ export default function Product() {
|
||||
// Find matching variation when attributes change
|
||||
useEffect(() => {
|
||||
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
||||
const variation = (product.variations as any[]).find(v => {
|
||||
if (!v.attributes) return false;
|
||||
let bestMatch: any = null;
|
||||
let highestScore = -1;
|
||||
|
||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
(product.variations as any[]).forEach(v => {
|
||||
if (!v.attributes) return;
|
||||
|
||||
let isMatch = true;
|
||||
let score = 0;
|
||||
|
||||
const attributesMatch = Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
const normalizedSelectedValue = attrValue.toLowerCase().trim();
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
@@ -108,17 +128,11 @@ export default function Product() {
|
||||
// Try to find a matching key in the variation attributes
|
||||
let variationValue: string | undefined = undefined;
|
||||
|
||||
// Check for common WooCommerce attribute key formats
|
||||
// 1. Check strict slug format (attribute_7-days-...)
|
||||
if (`attribute_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrSlug}`];
|
||||
}
|
||||
// 2. Check pa_ format (attribute_pa_color)
|
||||
else if (`attribute_pa_${attrSlug}` in v.attributes) {
|
||||
} else if (`attribute_pa_${attrSlug}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
|
||||
}
|
||||
// 3. Fallback to name-based checks (legacy)
|
||||
else if (`attribute_${attrNameLower}` in v.attributes) {
|
||||
} else if (`attribute_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_${attrNameLower}`];
|
||||
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
|
||||
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
|
||||
@@ -126,23 +140,34 @@ export default function Product() {
|
||||
variationValue = v.attributes[attrNameLower];
|
||||
}
|
||||
|
||||
// If key is undefined/missing in variation, it means "Any" -> Match
|
||||
// If key is undefined/missing in variation, it means "Any" -> Match with score 0
|
||||
if (variationValue === undefined || variationValue === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If empty string, it also means "Any" -> Match
|
||||
// If empty string, it also means "Any" -> Match with score 0
|
||||
const normalizedVarValue = String(variationValue).toLowerCase().trim();
|
||||
if (normalizedVarValue === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise, values must match
|
||||
return normalizedVarValue === normalizedSelectedValue;
|
||||
});
|
||||
// Exact match gets a higher score
|
||||
if (normalizedVarValue === normalizedSelectedValue) {
|
||||
score += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Value mismatch
|
||||
return false;
|
||||
});
|
||||
|
||||
setSelectedVariation(variation || null);
|
||||
if (attributesMatch && score > highestScore) {
|
||||
highestScore = score;
|
||||
bestMatch = v;
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedVariation(bestMatch || null);
|
||||
} else if (product?.type !== 'variable') {
|
||||
setSelectedVariation(null);
|
||||
}
|
||||
@@ -317,7 +342,10 @@ export default function Product() {
|
||||
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
{/* Flat: entire Container is bg-white. Card: per-section white cards on gray. */}
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Top section: flat = no card wrapper, card = white card */}
|
||||
<div className={layout.layout_style === 'card' ? 'bg-white rounded-2xl shadow-sm border border-gray-100 p-6 lg:p-8 xl:p-10 mb-8' : 'mb-8'}>
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
<nav className="mb-6 text-sm">
|
||||
@@ -329,7 +357,7 @@ export default function Product() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 lg:gap-12 ${layout.image_position === 'right' ? 'lg:grid-cols-[42%_58%]' : 'lg:grid-cols-[58%_42%]'}`}>
|
||||
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
|
||||
{/* Product Images */}
|
||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||
{/* Main Image - ENHANCED */}
|
||||
@@ -660,14 +688,18 @@ export default function Product() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */}
|
||||
<div className="mt-12 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Description Section */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className={layout.layout_style === 'card'
|
||||
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
|
||||
: 'border-t border-gray-200 overflow-hidden'
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
||||
<svg
|
||||
@@ -694,10 +726,13 @@ export default function Product() {
|
||||
</div>
|
||||
|
||||
{/* Specifications Section - SCANNABLE TABLE */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className={layout.layout_style === 'card'
|
||||
? 'bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden'
|
||||
: 'border-t border-gray-200 overflow-hidden'
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
||||
<svg
|
||||
|
||||
39
customer-spa/src/pages/Subscribe/index.tsx
Normal file
39
customer-spa/src/pages/Subscribe/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -25,5 +25,5 @@ module.exports = {
|
||||
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
|
||||
};
|
||||
@@ -381,6 +381,7 @@ class AppearanceController
|
||||
'image_position' => sanitize_text_field($data['layout']['image_position'] ?? 'left'),
|
||||
'gallery_style' => sanitize_text_field($data['layout']['gallery_style'] ?? 'thumbnails'),
|
||||
'sticky_add_to_cart' => (bool) ($data['layout']['sticky_add_to_cart'] ?? false),
|
||||
'layout_style' => sanitize_text_field($data['layout']['layout_style'] ?? 'flat'),
|
||||
],
|
||||
'elements' => [
|
||||
'breadcrumbs' => (bool) ($data['elements']['breadcrumbs'] ?? true),
|
||||
@@ -601,7 +602,11 @@ class AppearanceController
|
||||
'show_icon' => true,
|
||||
],
|
||||
],
|
||||
'product' => [],
|
||||
'product' => [
|
||||
'layout' => [
|
||||
'layout_style' => 'flat',
|
||||
],
|
||||
],
|
||||
'cart' => [],
|
||||
'checkout' => [],
|
||||
'thankyou' => [],
|
||||
|
||||
@@ -75,7 +75,7 @@ class Assets
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
|
||||
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
||||
|
||||
@@ -201,7 +201,7 @@ class Assets
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
|
||||
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
|
||||
]);
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Admin;
|
||||
|
||||
/**
|
||||
@@ -9,28 +10,31 @@ namespace WooNooW\Admin;
|
||||
*
|
||||
* @package WooNooW\Admin
|
||||
*/
|
||||
class StandaloneAdmin {
|
||||
class StandaloneAdmin
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize standalone admin handler
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// Catch /admin requests very early (before WordPress routing)
|
||||
add_action( 'parse_request', [ __CLASS__, 'handle_admin_request' ], 1 );
|
||||
add_action('parse_request', [__CLASS__, 'handle_admin_request'], 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /admin requests
|
||||
*/
|
||||
public static function handle_admin_request() {
|
||||
public static function handle_admin_request()
|
||||
{
|
||||
// Check if this is an /admin request
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
|
||||
// Remove query string
|
||||
$path = strtok( $request_uri, '?' );
|
||||
$path = strtok($request_uri, '?');
|
||||
|
||||
// Only handle exact /admin or /admin/ paths (not asset files)
|
||||
if ( $path !== '/admin' && $path !== '/admin/' ) {
|
||||
if ($path !== '/admin' && $path !== '/admin/') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,33 +46,34 @@ class StandaloneAdmin {
|
||||
/**
|
||||
* Render standalone admin interface
|
||||
*/
|
||||
private static function render_standalone_admin() {
|
||||
private static function render_standalone_admin()
|
||||
{
|
||||
// Enqueue WordPress media library (needed for image uploads)
|
||||
wp_enqueue_media();
|
||||
|
||||
// Check if user is logged in and has permissions
|
||||
$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;
|
||||
|
||||
// Debug logging (only in WP_DEBUG mode)
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
// Get nonce for REST API
|
||||
$nonce = wp_create_nonce( 'wp_rest' );
|
||||
$rest_url = untrailingslashit( rest_url( 'woonoow/v1' ) );
|
||||
$wp_admin_url = admin_url( 'admin.php?page=woonoow' );
|
||||
$nonce = wp_create_nonce('wp_rest');
|
||||
$rest_url = untrailingslashit(rest_url('woonoow/v1'));
|
||||
$wp_admin_url = admin_url('admin.php?page=woonoow');
|
||||
|
||||
// Get current user data if authenticated
|
||||
$current_user = null;
|
||||
if ( $is_authenticated ) {
|
||||
if ($is_authenticated) {
|
||||
$user = wp_get_current_user();
|
||||
$current_user = [
|
||||
'id' => $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'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();
|
||||
|
||||
// Get asset URLs
|
||||
$plugin_url = plugins_url( '', dirname( dirname( __FILE__ ) ) );
|
||||
$plugin_url = plugins_url('', dirname(dirname(__FILE__)));
|
||||
$asset_url = $plugin_url . '/admin-spa/dist';
|
||||
|
||||
// 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;
|
||||
$js_url = $asset_url . '/app.js?ver=' . $version;
|
||||
|
||||
// Render HTML
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo esc_attr( get_locale() ); ?>">
|
||||
<head>
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo esc_attr(get_locale()); ?>">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?php echo esc_html( get_option( 'blogname', 'WooNooW' ) ); ?> Admin</title>
|
||||
<title><?php echo esc_html(get_option('blogname', 'WooNooW')); ?> Admin</title>
|
||||
|
||||
<?php
|
||||
// Favicon
|
||||
$icon = get_option( 'woonoow_store_icon', '' );
|
||||
if ( ! empty( $icon ) ) {
|
||||
$icon = get_option('woonoow_store_icon', '');
|
||||
if (! empty($icon)) {
|
||||
?>
|
||||
<link rel="icon" type="image/png" href="<?php echo esc_url( $icon ); ?>" />
|
||||
<link rel="apple-touch-icon" href="<?php echo esc_url( $icon ); ?>" />
|
||||
<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
|
||||
// Print WordPress media library styles (complete set for proper modal)
|
||||
wp_print_styles( 'media-views' );
|
||||
wp_print_styles( 'imgareaselect' );
|
||||
wp_print_styles( 'buttons' );
|
||||
wp_print_styles( 'dashicons' );
|
||||
wp_print_styles( 'wp-admin' );
|
||||
wp_print_styles( 'common' );
|
||||
wp_print_styles('media-views');
|
||||
wp_print_styles('imgareaselect');
|
||||
wp_print_styles('buttons');
|
||||
wp_print_styles('dashicons');
|
||||
wp_print_styles('wp-admin');
|
||||
wp_print_styles('common');
|
||||
?>
|
||||
|
||||
<!-- WooNooW Assets -->
|
||||
<link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
|
||||
</head>
|
||||
<body class="woonoow-standalone">
|
||||
<link rel="stylesheet" href="<?php echo esc_url($css_url); ?>">
|
||||
</head>
|
||||
|
||||
<body class="woonoow-standalone">
|
||||
<div id="woonoow-admin-app"></div>
|
||||
|
||||
<script>
|
||||
// Minimal config - no WordPress bloat
|
||||
window.WNW_CONFIG = {
|
||||
restUrl: <?php echo wp_json_encode( $rest_url ); ?>,
|
||||
nonce: <?php echo wp_json_encode( $nonce ); ?>,
|
||||
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 ); ?>,
|
||||
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'; ?>
|
||||
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'; ?>
|
||||
};
|
||||
|
||||
// Also set WNW_API for API compatibility
|
||||
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'; ?>
|
||||
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.)
|
||||
window.WNW_STORE = <?php echo wp_json_encode( $store_settings ); ?>;
|
||||
window.WNW_STORE = <?php echo wp_json_encode($store_settings); ?>;
|
||||
|
||||
// Navigation tree (single source of truth from PHP)
|
||||
window.WNW_NAV_TREE = <?php echo wp_json_encode( \WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree() ); ?>;
|
||||
window.WNW_NAV_TREE = <?php echo wp_json_encode(\WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree()); ?>;
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
window.wpApiSettings = {
|
||||
root: <?php echo wp_json_encode( untrailingslashit( rest_url() ) ); ?>,
|
||||
nonce: <?php echo wp_json_encode( $nonce ); ?>,
|
||||
root: <?php echo wp_json_encode(untrailingslashit(rest_url())); ?>,
|
||||
nonce: <?php echo wp_json_encode($nonce); ?>,
|
||||
versionString: 'wp/v2/'
|
||||
};
|
||||
</script>
|
||||
|
||||
<?php
|
||||
// Print WordPress media library scripts (needed for wp.media)
|
||||
wp_print_scripts( 'media-editor' );
|
||||
wp_print_scripts( 'media-audiovideo' );
|
||||
wp_print_scripts('media-editor');
|
||||
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
|
||||
<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.)
|
||||
*/
|
||||
private static function get_store_settings(): array {
|
||||
private static function get_store_settings(): array
|
||||
{
|
||||
// Get WooCommerce settings with fallbacks
|
||||
$currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol( $currency ) : '$';
|
||||
$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() : ',';
|
||||
$decimal_sep = function_exists( 'wc_get_price_decimal_separator' ) ? wc_get_price_decimal_separator() : '.';
|
||||
$currency_pos = get_option( 'woocommerce_currency_pos', 'left' );
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$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;
|
||||
$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() : '.';
|
||||
$currency_pos = get_option('woocommerce_currency_pos', 'left');
|
||||
|
||||
return [
|
||||
'currency' => $currency,
|
||||
@@ -200,17 +210,17 @@ class StandaloneAdmin {
|
||||
/** Get the SPA page URL from appearance settings (dynamic slug) */
|
||||
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;
|
||||
|
||||
if ( $spa_page_id ) {
|
||||
$spa_url = get_permalink( $spa_page_id );
|
||||
if ( $spa_url ) {
|
||||
return trailingslashit( $spa_url );
|
||||
if ($spa_page_id) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
if ($spa_url) {
|
||||
return trailingslashit($spa_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to /store/ if no SPA page configured
|
||||
return home_url( '/store/' );
|
||||
return home_url('/store/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class OnboardingController extends WP_REST_Controller
|
||||
}
|
||||
|
||||
// 4. Mark as Complete
|
||||
update_option('woonoow_onboarding_completed', true);
|
||||
update_option('woonoow_onboarding_completed', 'yes');
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
|
||||
@@ -58,7 +58,7 @@ class PagesController
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Get/Save CPT templates
|
||||
// Get/Save/Delete CPT templates
|
||||
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
@@ -70,6 +70,11 @@ class PagesController
|
||||
'callback' => [__CLASS__, 'save_template'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
[
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_template'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get post with template applied (for SPA rendering)
|
||||
@@ -337,6 +342,34 @@ class PagesController
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete CPT template (abort SPA handling for this post type)
|
||||
*/
|
||||
public static function delete_template(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
|
||||
// Validate CPT exists
|
||||
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||
}
|
||||
|
||||
$option_key = "wn_template_{$cpt}";
|
||||
$exists = get_option($option_key, null);
|
||||
|
||||
if ($exists === null) {
|
||||
return new WP_Error('not_found', 'No template found for this post type', ['status' => 404]);
|
||||
}
|
||||
|
||||
delete_option($option_key);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'cpt' => $cpt,
|
||||
'message' => 'Template deleted. WordPress will now handle this post type natively.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content with template applied (for SPA rendering)
|
||||
*/
|
||||
@@ -378,7 +411,37 @@ class PagesController
|
||||
if ($template && !empty($template['sections'])) {
|
||||
foreach ($template['sections'] as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
|
||||
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
|
||||
$props = $section['props'] ?? [];
|
||||
foreach ($props as $key => $prop) {
|
||||
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
|
||||
$props[$key] = [
|
||||
'type' => 'static',
|
||||
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
||||
|
||||
// Resolve dynamicBackground in styles
|
||||
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
|
||||
$styles = $resolved_section['styles'] ?? [];
|
||||
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
||||
$dyn_source = $styles['dynamicBackground'];
|
||||
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
||||
$featured_url = $post_data['featured_image'] ?? '';
|
||||
if (!empty($featured_url)) {
|
||||
$styles['backgroundImage'] = $featured_url;
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
}
|
||||
// Remove the internal marker from the rendered output
|
||||
unset($styles['dynamicBackground']);
|
||||
$resolved_section['styles'] = $styles;
|
||||
}
|
||||
|
||||
$rendered_sections[] = $resolved_section;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,18 @@ class ProductsController
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize rich text (allows HTML tags)
|
||||
*/
|
||||
private static function sanitize_rich_text($value)
|
||||
{
|
||||
if (!isset($value) || $value === '') {
|
||||
return '';
|
||||
}
|
||||
$sanitized = wp_kses_post($value);
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize numeric value
|
||||
*/
|
||||
@@ -335,8 +347,12 @@ class ProductsController
|
||||
$product->set_slug(self::sanitize_slug($data['slug']));
|
||||
}
|
||||
$product->set_status(sanitize_key($data['status'] ?? 'publish'));
|
||||
$product->set_description(self::sanitize_textarea($data['description'] ?? ''));
|
||||
if (isset($data['description'])) {
|
||||
$product->set_description(self::sanitize_rich_text($data['description'] ?? ''));
|
||||
}
|
||||
if (isset($data['short_description'])) {
|
||||
$product->set_short_description(self::sanitize_textarea($data['short_description'] ?? ''));
|
||||
}
|
||||
|
||||
if (!empty($data['sku'])) {
|
||||
$product->set_sku(self::sanitize_text($data['sku']));
|
||||
@@ -489,7 +505,7 @@ class ProductsController
|
||||
if (isset($data['name'])) $product->set_name(self::sanitize_text($data['name']));
|
||||
if (isset($data['slug'])) $product->set_slug(self::sanitize_slug($data['slug']));
|
||||
if (isset($data['status'])) $product->set_status(sanitize_key($data['status']));
|
||||
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
|
||||
if (isset($data['description'])) $product->set_description(self::sanitize_rich_text($data['description']));
|
||||
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
|
||||
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
|
||||
|
||||
@@ -942,10 +958,17 @@ class ProductsController
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - stored as lowercase in meta
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
// Custom attribute - stored as sanitize_title in meta
|
||||
$sanitized_name = sanitize_title($attr_name);
|
||||
$meta_key = 'attribute_' . $sanitized_name;
|
||||
$value = get_post_meta($variation_id, $meta_key, true);
|
||||
|
||||
// Fallback to legacy lowercase if not found
|
||||
if ($value === '') {
|
||||
$meta_key_legacy = 'attribute_' . strtolower($attr_name);
|
||||
$value = get_post_meta($variation_id, $meta_key_legacy, true);
|
||||
}
|
||||
|
||||
// Capitalize the attribute name for display to match admin SPA
|
||||
$clean_name = ucfirst($attr_name);
|
||||
}
|
||||
@@ -1029,8 +1052,27 @@ class ProductsController
|
||||
|
||||
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||
if (!$parent_attr->get_variation()) continue;
|
||||
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
|
||||
$wc_attributes[strtolower($attr_name)] = strtolower($value);
|
||||
|
||||
$is_match = false;
|
||||
if (strpos($attr_name, 'pa_') === 0) {
|
||||
$label = wc_attribute_label($attr_name);
|
||||
if (strcasecmp($display_name, $label) === 0 || strcasecmp($display_name, $attr_name) === 0) {
|
||||
$is_match = true;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute: Check exact name, or sanitized version
|
||||
if (
|
||||
strcasecmp($display_name, $attr_name) === 0 ||
|
||||
strcasecmp($display_name, $parent_attr->get_name()) === 0 ||
|
||||
sanitize_title($display_name) === sanitize_title($attr_name)
|
||||
) {
|
||||
$is_match = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($is_match) {
|
||||
// WooCommerce expects the exact attribute slug as the key
|
||||
$wc_attributes[$attr_name] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1095,7 +1137,7 @@ class ProductsController
|
||||
global $wpdb;
|
||||
|
||||
foreach ($wc_attributes as $attr_name => $attr_value) {
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
$meta_key = 'attribute_' . sanitize_title($attr_name);
|
||||
|
||||
$wpdb->delete(
|
||||
$wpdb->postmeta,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Email Manager
|
||||
*
|
||||
@@ -9,7 +10,8 @@
|
||||
|
||||
namespace WooNooW\Core\Notifications;
|
||||
|
||||
class EmailManager {
|
||||
class EmailManager
|
||||
{
|
||||
|
||||
/**
|
||||
* Instance
|
||||
@@ -19,7 +21,8 @@ class EmailManager {
|
||||
/**
|
||||
* Get instance
|
||||
*/
|
||||
public static function instance() {
|
||||
public static function instance()
|
||||
{
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
@@ -29,14 +32,16 @@ class EmailManager {
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private function __construct() {
|
||||
private function __construct()
|
||||
{
|
||||
$this->init_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hooks
|
||||
*/
|
||||
private function init_hooks() {
|
||||
private function init_hooks()
|
||||
{
|
||||
// Disable WooCommerce emails to prevent duplicates
|
||||
add_action('woocommerce_email', [$this, 'disable_wc_emails'], 1);
|
||||
|
||||
@@ -74,7 +79,8 @@ class EmailManager {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
public static function is_enabled()
|
||||
{
|
||||
// Check global notification system mode
|
||||
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
|
||||
return $system_mode === 'woonoow';
|
||||
@@ -85,7 +91,8 @@ class EmailManager {
|
||||
*
|
||||
* @param WC_Emails $email_class
|
||||
*/
|
||||
public function disable_wc_emails($email_class) {
|
||||
public function disable_wc_emails($email_class)
|
||||
{
|
||||
// Only disable WC emails if WooNooW system is enabled
|
||||
if (!self::is_enabled()) {
|
||||
return; // Keep WC emails if WooNooW system disabled
|
||||
@@ -117,7 +124,8 @@ class EmailManager {
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_processing_email($order_id, $order = null) {
|
||||
public function send_order_processing_email($order_id, $order = null)
|
||||
{
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
@@ -151,7 +159,8 @@ class EmailManager {
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_completed_email($order_id, $order = null) {
|
||||
public function send_order_completed_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
@@ -175,7 +184,8 @@ class EmailManager {
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_on_hold_email($order_id, $order = null) {
|
||||
public function send_order_on_hold_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
@@ -199,7 +209,8 @@ class EmailManager {
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_cancelled_email($order_id, $order = null) {
|
||||
public function send_order_cancelled_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
@@ -220,7 +231,8 @@ class EmailManager {
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_refunded_email($order_id, $order = null) {
|
||||
public function send_order_refunded_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
@@ -243,7 +255,8 @@ class EmailManager {
|
||||
*
|
||||
* @param int $order_id
|
||||
*/
|
||||
public function send_new_order_admin_email($order_id) {
|
||||
public function send_new_order_admin_email($order_id)
|
||||
{
|
||||
$order = wc_get_order($order_id);
|
||||
|
||||
if (!$order) {
|
||||
@@ -264,7 +277,8 @@ class EmailManager {
|
||||
*
|
||||
* @param array $args
|
||||
*/
|
||||
public function send_customer_note_email($args) {
|
||||
public function send_customer_note_email($args)
|
||||
{
|
||||
$order = wc_get_order($args['order_id']);
|
||||
|
||||
if (!$order) {
|
||||
@@ -276,8 +290,8 @@ class EmailManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email with note data
|
||||
$this->send_email('customer_note', 'customer', $order, ['note' => $args['customer_note']]);
|
||||
// Send email with note data — key must match {customer_note} variable in template
|
||||
$this->send_email('customer_note', 'customer', $order, ['customer_note' => $args['customer_note']]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,7 +301,8 @@ class EmailManager {
|
||||
* @param array $new_customer_data
|
||||
* @param bool $password_generated
|
||||
*/
|
||||
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false) {
|
||||
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('new_customer', 'email', 'customer')) {
|
||||
return;
|
||||
@@ -312,7 +327,8 @@ class EmailManager {
|
||||
* @param WP_User $user_data User object
|
||||
* @return string Empty string to prevent WordPress sending default email
|
||||
*/
|
||||
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
|
||||
public function handle_password_reset_email($message, $key, $user_login, $user_data)
|
||||
{
|
||||
// Check if WooNooW notification system is enabled
|
||||
if (!self::is_enabled()) {
|
||||
return $message; // Use WordPress default
|
||||
@@ -371,7 +387,8 @@ class EmailManager {
|
||||
* @param string $reset_link Full reset link URL
|
||||
* @param WC_Customer|null $customer WooCommerce customer object if available
|
||||
*/
|
||||
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
|
||||
private function send_password_reset_email($user, $key, $reset_link, $customer = null)
|
||||
{
|
||||
// Get email renderer
|
||||
$renderer = EmailRenderer::instance();
|
||||
|
||||
@@ -417,14 +434,18 @@ class EmailManager {
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function send_low_stock_email($product) {
|
||||
public function send_low_stock_email($product)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('low_stock', 'email', 'staff')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email
|
||||
$this->send_email('low_stock', 'staff', $product);
|
||||
// Pass low_stock_threshold so template can display it
|
||||
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
|
||||
$this->send_email('low_stock', 'staff', $product, [
|
||||
'low_stock_threshold' => $low_stock_threshold,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,7 +453,8 @@ class EmailManager {
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function send_out_of_stock_email($product) {
|
||||
public function send_out_of_stock_email($product)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('out_of_stock', 'email', 'staff')) {
|
||||
return;
|
||||
@@ -447,7 +469,8 @@ class EmailManager {
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function check_stock_levels($product) {
|
||||
public function check_stock_levels($product)
|
||||
{
|
||||
$stock = $product->get_stock_quantity();
|
||||
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
|
||||
|
||||
@@ -466,7 +489,8 @@ class EmailManager {
|
||||
* @param string $recipient_type
|
||||
* @return bool
|
||||
*/
|
||||
private function is_event_enabled($event_id, $channel_id, $recipient_type) {
|
||||
private function is_event_enabled($event_id, $channel_id, $recipient_type)
|
||||
{
|
||||
$settings = get_option('woonoow_notification_settings', []);
|
||||
|
||||
// Check if event exists and channel is configured
|
||||
@@ -493,7 +517,8 @@ class EmailManager {
|
||||
* @param mixed $data
|
||||
* @param array $extra_data
|
||||
*/
|
||||
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
|
||||
private function send_email($event_id, $recipient_type, $data, $extra_data = [])
|
||||
{
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ class EmailRenderer
|
||||
'payment_method' => $data->get_payment_method_title(),
|
||||
'payment_status' => $data->get_status(),
|
||||
'payment_date' => $payment_date,
|
||||
'payment_error_reason' => $data->get_meta('_payment_error_reason') ?: 'Payment declined',
|
||||
'transaction_id' => $data->get_transaction_id() ?: 'N/A',
|
||||
'shipping_method' => $data->get_shipping_method(),
|
||||
'estimated_delivery' => $estimated_delivery,
|
||||
@@ -239,9 +240,12 @@ class EmailRenderer
|
||||
'billing_address' => $data->get_formatted_billing_address(),
|
||||
'shipping_address' => $data->get_formatted_shipping_address(),
|
||||
// URLs
|
||||
'review_url' => $data->get_view_order_url(), // Can be customized later
|
||||
'review_url' => $data->get_view_order_url(),
|
||||
'return_url' => $data->get_view_order_url(), // Customers click to initiate return
|
||||
'contact_url' => home_url('/contact'),
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||
'account_url' => get_permalink(wc_get_page_id('myaccount')), // Alias for my_account_url
|
||||
'payment_retry_url' => $data->get_checkout_payment_url(),
|
||||
// Tracking (if available from meta)
|
||||
'tracking_number' => $data->get_meta('_tracking_number') ?: 'N/A',
|
||||
@@ -249,6 +253,7 @@ class EmailRenderer
|
||||
'shipping_carrier' => $data->get_meta('_shipping_carrier') ?: 'Standard Shipping',
|
||||
]);
|
||||
|
||||
|
||||
// Order items table
|
||||
$items_html = '<table class="order-details" style="width: 100%; border-collapse: collapse;">';
|
||||
$items_html .= '<thead><tr>';
|
||||
@@ -277,9 +282,10 @@ class EmailRenderer
|
||||
|
||||
$items_html .= '</tbody></table>';
|
||||
|
||||
// Both naming conventions for compatibility
|
||||
// All naming conventions for compatibility
|
||||
$variables['order_items'] = $items_html;
|
||||
$variables['order_items_table'] = $items_html;
|
||||
$variables['order_items_list'] = $items_html; // Alias used in some templates
|
||||
}
|
||||
|
||||
// Product variables
|
||||
|
||||
@@ -19,6 +19,12 @@ class Assets
|
||||
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
||||
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
||||
|
||||
// Hide admin bar if configured
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
if (!empty($settings['general']['hide_admin_bar'])) {
|
||||
add_filter('show_admin_bar', '__return_false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,13 +121,15 @@ class Assets
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in full mode and not on a page with shortcode
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
// Get appearance settings for unified spa_mode check
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
if ($mode === 'full') {
|
||||
if ($spa_mode === 'full') {
|
||||
// Only inject if the mount point doesn't already exist (from shortcode)
|
||||
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
$current_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
echo '<div id="woonoow-customer-app" data-page="shop" data-initial-route="' . esc_attr($current_path) . '"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +235,14 @@ class Assets
|
||||
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
|
||||
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||
|
||||
// When injecting into a CPT or structural page, the URL does not start with the SPA base path.
|
||||
// E.g., /desain-mockup... instead of /store/desain-mockup...
|
||||
// For these pages, we must force the base path to empty so BrowserRouter starts from the root.
|
||||
if (is_singular() && (!isset($spa_page) || get_queried_object_id() !== $spa_page->ID)) {
|
||||
// If we're on a singular page that isn't the SPA Entry Page, it's a structural page or CPT
|
||||
$base_path = '';
|
||||
}
|
||||
|
||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
@@ -275,20 +291,27 @@ class Assets
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should load customer-spa assets
|
||||
* Check if we should load assets on this page
|
||||
*/
|
||||
private static function should_load_assets()
|
||||
public static function should_load_assets()
|
||||
{
|
||||
global $post;
|
||||
// Don't load on admin pages
|
||||
if (is_admin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
|
||||
// Force load if constant is defined (e.g. for preview)
|
||||
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we're on a frontpage SPA route (by URL detection)
|
||||
if (self::is_frontpage_spa_route()) {
|
||||
return true;
|
||||
// Get SPA mode from appearance settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// Check if SPA is completely disabled
|
||||
if ($mode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
@@ -296,39 +319,29 @@ class Assets
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get SPA mode from appearance settings (the correct source)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// If disabled, only load for pages with shortcodes
|
||||
if ($mode === 'disabled') {
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
if (function_exists('is_shop') && is_shop()) {
|
||||
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||
// Check if we're on a frontpage SPA route
|
||||
if (self::is_frontpage_spa_route()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For structural pages (is_singular('page'))
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shortcodes on regular pages
|
||||
if ($post) {
|
||||
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_account')) {
|
||||
// For CPTs with WooNooW templates
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if (!in_array($post_type, ['product', 'shop_order', 'shop_coupon'])) {
|
||||
$wn_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($wn_template) && !empty($wn_template['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full SPA mode - load on all WooCommerce pages
|
||||
@@ -353,6 +366,7 @@ class Assets
|
||||
|
||||
// Checkout-Only mode - load only on specific pages
|
||||
if ($mode === 'checkout_only') {
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
|
||||
|
||||
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
|
||||
@@ -370,6 +384,7 @@ class Assets
|
||||
return false;
|
||||
}
|
||||
|
||||
global $post;
|
||||
// Check if current page has WooNooW shortcodes
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
|
||||
@@ -283,8 +283,8 @@ class ShopController
|
||||
|
||||
// Add detailed info if requested
|
||||
if ($detailed) {
|
||||
$data['description'] = $product->get_description();
|
||||
$data['short_description'] = $product->get_short_description();
|
||||
$data['description'] = wpautop($product->get_description());
|
||||
$data['short_description'] = wpautop($product->get_short_description());
|
||||
$data['sku'] = $product->get_sku();
|
||||
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']);
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ class TemplateOverride
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^subscribe/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=subscribe',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /order-pay/* → SPA page
|
||||
add_rewrite_rule(
|
||||
@@ -352,7 +357,6 @@ class TemplateOverride
|
||||
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page has WooNooW structure
|
||||
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
@@ -364,11 +368,6 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
* and serve the SPA template directly (bypasses WooCommerce templates)
|
||||
*/
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
@@ -417,8 +416,8 @@ class TemplateOverride
|
||||
'/my-account', // Account page
|
||||
'/login', // Login page
|
||||
'/register', // Register page
|
||||
'/register', // Register page
|
||||
'/reset-password', // Password reset
|
||||
'/subscribe', // Subscribe page
|
||||
'/order-pay', // Order pay page
|
||||
];
|
||||
|
||||
@@ -535,6 +534,32 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a structural page with WooNooW sections
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
return $spa_template;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a CPT singular with a WooNooW template
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if ($post_type) {
|
||||
$cpt_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
return $spa_template;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For spa_mode = 'full', override WooCommerce pages
|
||||
if ($spa_mode === 'full') {
|
||||
// Override all WooCommerce pages
|
||||
@@ -569,23 +594,30 @@ class TemplateOverride
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine page type
|
||||
$page_type = 'shop';
|
||||
// Determine page type and route
|
||||
$data_attrs = 'data-page="shop"';
|
||||
|
||||
// Pass current request URI as initial route so router doesn't fallback to /shop
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
$current_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
$data_attrs .= ' data-initial-route="' . esc_attr($current_path) . '"';
|
||||
|
||||
if (is_product()) {
|
||||
$page_type = 'product';
|
||||
global $post;
|
||||
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
$data_attrs .= ' data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
} elseif (is_cart()) {
|
||||
$page_type = 'cart';
|
||||
$data_attrs = 'data-page="cart"';
|
||||
$data_attrs .= ' data-page="cart"';
|
||||
} elseif (is_checkout()) {
|
||||
$page_type = 'checkout';
|
||||
$data_attrs = 'data-page="checkout"';
|
||||
$data_attrs .= ' data-page="checkout"';
|
||||
} elseif (is_account_page()) {
|
||||
$page_type = 'account';
|
||||
$data_attrs = 'data-page="account"';
|
||||
$data_attrs .= ' data-page="account"';
|
||||
} elseif (is_singular('page')) {
|
||||
$data_attrs .= ' data-page="page"';
|
||||
} elseif (is_singular() && !is_singular('page')) {
|
||||
// CPT single item with a WooNooW template
|
||||
global $post;
|
||||
$post_type = get_post_type();
|
||||
$data_attrs .= ' data-page="cpt" data-cpt-type="' . esc_attr($post_type) . '" data-cpt-slug="' . esc_attr($post->post_name) . '"';
|
||||
}
|
||||
|
||||
// Output SPA mount point
|
||||
@@ -631,6 +663,26 @@ class TemplateOverride
|
||||
return true;
|
||||
}
|
||||
|
||||
// For structural pages (is_singular('page'))
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For CPT singular items with a WooNooW template
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if ($post_type) {
|
||||
$cpt_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ class TemplateRegistry
|
||||
'description' => 'Simple contact page with a form and address details.',
|
||||
'icon' => 'mail',
|
||||
'sections' => self::get_contact_structure()
|
||||
],
|
||||
[
|
||||
'id' => 'single-post',
|
||||
'label' => 'Single Post / CPT',
|
||||
'description' => 'A dynamic layout for blog posts or custom post types with a hero, featured image, and body content.',
|
||||
'icon' => 'layout',
|
||||
'sections' => self::get_single_post_structure()
|
||||
]
|
||||
]);
|
||||
}
|
||||
@@ -166,4 +173,73 @@ class TemplateRegistry
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private static function get_single_post_structure()
|
||||
{
|
||||
return [
|
||||
// ── Section 1: Article Hero ─────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'hero',
|
||||
'layoutVariant' => 'centered',
|
||||
'colorScheme' => 'default',
|
||||
'props' => [
|
||||
'title' => ['type' => 'dynamic', 'source' => 'post_title'],
|
||||
'subtitle' => ['type' => 'dynamic', 'source' => 'post_author'],
|
||||
'image' => ['type' => 'static', 'value' => ''],
|
||||
'cta_text' => ['type' => 'static', 'value' => ''],
|
||||
'cta_url' => ['type' => 'static', 'value' => ''],
|
||||
],
|
||||
// dynamicBackground tells the API to resolve styles.backgroundImage
|
||||
// from 'post_featured_image' at render time (falls back to '' if no featured image)
|
||||
'styles' => [
|
||||
'contentWidth' => 'contained',
|
||||
'heightPreset' => 'medium',
|
||||
'dynamicBackground' => 'post_featured_image',
|
||||
'backgroundOverlay' => 50,
|
||||
],
|
||||
],
|
||||
|
||||
// ── Section 2: Article Body ─────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'content',
|
||||
'layoutVariant' => 'narrow',
|
||||
'colorScheme' => 'default',
|
||||
'props' => [
|
||||
'content' => ['type' => 'dynamic', 'source' => 'post_content'],
|
||||
'cta_text' => ['type' => 'static', 'value' => ''],
|
||||
'cta_url' => ['type' => 'static', 'value' => ''],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
|
||||
],
|
||||
|
||||
// ── Section 3: Related Posts ────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'feature-grid',
|
||||
'layoutVariant' => 'grid-3',
|
||||
'colorScheme' => 'muted',
|
||||
'props' => [
|
||||
'heading' => ['type' => 'static', 'value' => 'Related Articles'],
|
||||
'features' => ['type' => 'dynamic', 'source' => 'related_posts'],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
|
||||
],
|
||||
|
||||
// ── Section 4: CTA Banner ───────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'cta-banner',
|
||||
'colorScheme' => 'gradient',
|
||||
'props' => [
|
||||
'title' => ['type' => 'static', 'value' => 'Enjoyed this article?'],
|
||||
'text' => ['type' => 'static', 'value' => 'Subscribe to our newsletter and never miss an update.'],
|
||||
'button_text' => ['type' => 'static', 'value' => 'Subscribe Now'],
|
||||
'button_url' => ['type' => 'static', 'value' => '/subscribe'],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'medium'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
|
||||
<head>
|
||||
<meta charset="<?php bloginfo('charset'); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php wp_title('|', true, 'right'); ?><?php bloginfo('name'); ?></title>
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
|
||||
<body <?php body_class('woonoow-spa-page'); ?>>
|
||||
<?php
|
||||
// Determine initial route based on SPA mode
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
|
||||
|
||||
// Get actual request path for accurate routing
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
$current_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
|
||||
// Set initial page based on mode
|
||||
if ($spa_mode === 'checkout_only') {
|
||||
// Checkout Only mode starts at cart
|
||||
$page_type = 'cart';
|
||||
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
|
||||
} else {
|
||||
// Full SPA mode starts at shop
|
||||
$page_type = 'shop';
|
||||
// Evaluate WordPress page type to pass to React App
|
||||
if (is_product()) {
|
||||
global $post;
|
||||
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
} elseif (is_cart()) {
|
||||
$data_attrs = 'data-page="cart"';
|
||||
} elseif (is_checkout()) {
|
||||
$data_attrs = 'data-page="checkout"';
|
||||
} elseif (is_account_page()) {
|
||||
$data_attrs = 'data-page="account"';
|
||||
} elseif (is_singular('page')) {
|
||||
$data_attrs = 'data-page="page"';
|
||||
} elseif (is_singular() && !is_singular('page')) {
|
||||
global $post;
|
||||
$post_type = get_post_type();
|
||||
$data_attrs = 'data-page="cpt" data-cpt-type="' . esc_attr($post_type) . '" data-cpt-slug="' . esc_attr($post->post_name) . '"';
|
||||
} else {
|
||||
$data_attrs = 'data-page="shop"';
|
||||
}
|
||||
|
||||
// If this is the front page, route to /
|
||||
if (is_front_page()) {
|
||||
$data_attrs = 'data-page="shop" data-initial-route="/"';
|
||||
$data_attrs .= ' data-initial-route="/"';
|
||||
} else {
|
||||
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
||||
$data_attrs .= ' data-initial-route="' . esc_attr($current_path) . '"';
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -38,4 +61,5 @@
|
||||
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5
test_onboarding_val.php
Normal file
5
test_onboarding_val.php
Normal 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";
|
||||
Reference in New Issue
Block a user