feat: product page layout toggle (flat/card), fix email shortcode rendering

- Add layout_style setting (flat default) to product appearance
  - AppearanceController: sanitize & persist layout_style, add to default settings
  - Admin SPA: Layout Style select in Appearance > Product
  - Customer SPA: useEffect targets <main> bg-white in flat mode (full-width),
    card mode uses per-section white floating cards on gray background
  - Accordion sections styled per mode: flat=border-t dividers, card=white cards

- Fix email shortcode gaps (EmailRenderer, EmailManager)
  - Add missing variables: return_url, contact_url, account_url (alias),
    payment_error_reason, order_items_list (alias for order_items_table)
  - Fix customer_note extra_data key mismatch (note → customer_note)
  - Pass low_stock_threshold via extra_data in low_stock email send
This commit is contained in:
Dwindi Ramadhana
2026-03-04 01:14:56 +07:00
parent 7ff429502d
commit 90169b508d
46 changed files with 2337 additions and 1278 deletions

View File

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