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