feat: Page Editor v1.0 - canonical schema, SSR parity, and migration

Major improvements to WooNooW Page Editor system:

Schema & Architecture:
- Canonical section schema with unified sectionSchema.ts
- Normalized feature-grid to use items (not features)
- Standardized default values across all section types
- Schema versioning with automatic migration on read

Backend (PHP):
- Enhanced PlaceholderRenderer with typed output contracts
- Added fallback behavior for empty/invalid dynamic sources
- Added caching support for post data resolution
- New SchemaMigration class for backward compatibility
- New Features class for feature flags
- Enhanced PageSSR with full style support
- Removed controller-level special-casing for related_posts

Frontend (Admin SPA):
- Updated CanvasRenderer with schema-aware transformation
- Enhanced InspectorPanel with canonical schema metadata
- Added new section renderers

Frontend (Customer SPA):
- New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage
- Updated FeatureGridSection for items prop contract

Testing:
- Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest
- Add TypeScript tests: schema-integration, feature-grid-regression
- Add parity tests for React vs SSR content matching
- Add CI script: check-schema-drift.mjs
- Add VERIFICATION_CHECKLIST.md

Documentation:
- RELEASE_NOTES-v1.0.md with full release notes
- docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md
- docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
Dwindi Ramadhana
2026-05-30 13:02:08 +07:00
parent e70aa1f554
commit 396ca25be4
118 changed files with 10162 additions and 3726 deletions

View File

@@ -13,10 +13,21 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { api } from '@/lib/api';
interface SubscriptionOrder {
id: number;
@@ -103,29 +114,13 @@ const formatPrice = (amount: string | number) => {
};
async function fetchSubscription(id: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscription');
return res.json();
const res = await api.get(`/subscriptions/${id}`);
return res;
}
async function subscriptionAction(id: number, action: string, reason?: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.WNW_API.nonce,
},
body: JSON.stringify({ reason }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `Failed to ${action} subscription`);
}
return res.json();
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
return res;
}
export default function SubscriptionDetail() {
@@ -133,6 +128,7 @@ export default function SubscriptionDetail() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const [showCancelDialog, setShowCancelDialog] = React.useState(false);
const { data: subscription, isLoading, error } = useQuery<Subscription>({
queryKey: ['subscription', id],
@@ -154,19 +150,26 @@ export default function SubscriptionDetail() {
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
setShowCancelDialog(false);
},
onError: (error: Error) => {
toast.error(error.message);
setShowCancelDialog(false);
},
});
const handleAction = (action: string) => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
if (action === 'cancel') {
setShowCancelDialog(true);
return;
}
actionMutation.mutate({ action });
};
const confirmCancel = () => {
actionMutation.mutate({ action: 'cancel' });
};
if (isLoading) {
return (
<div className="space-y-6">
@@ -418,6 +421,35 @@ export default function SubscriptionDetail() {
</Table>
</CardContent>
</Card>
{/* Cancel Confirmation Dialog (replaces native confirm()) */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Cancel Subscription')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to cancel this subscription?')}
<br />
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => setShowCancelDialog(false)}
disabled={actionMutation.isPending}
>
{__('Keep Subscription')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmCancel}
disabled={actionMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{actionMutation.isPending ? __('Cancelling...') : __('Cancel Subscription')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}