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

@@ -2,7 +2,7 @@ export const api = {
root: () => (window.WNW_API?.root?.replace(/\/$/, '') || ''),
nonce: () => (window.WNW_API?.nonce || ''),
async wpFetch(path: string, options: RequestInit = {}) {
async wpFetch<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const url = /^https?:\/\//.test(path) ? path : api.root() + path;
const headers = new Headers(options.headers || {});
if (!headers.has('X-WP-Nonce') && api.nonce()) headers.set('X-WP-Nonce', api.nonce());
@@ -33,13 +33,13 @@ export const api = {
}
try {
return await res.json();
return await res.json() as T;
} catch {
return await res.text();
return await res.text() as unknown as T;
}
},
async get(path: string, params?: Record<string, any>) {
async get<T = any>(path: string, params?: Record<string, any>): Promise<T> {
const usp = new URLSearchParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
@@ -48,71 +48,38 @@ export const api = {
}
}
const qs = usp.toString();
return api.wpFetch(path + (qs ? `?${qs}` : ''));
return api.wpFetch<T>(path + (qs ? `?${qs}` : ''));
},
async post(path: string, body?: any) {
return api.wpFetch(path, {
async post<T = any>(path: string, body?: any): Promise<T> {
return api.wpFetch<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
});
},
async put(path: string, body?: any) {
return api.wpFetch(path, {
async put<T = any>(path: string, body?: any): Promise<T> {
return api.wpFetch<T>(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
});
},
async del(path: string) {
return api.wpFetch(path, { method: 'DELETE' });
async del<T = any>(path: string): Promise<T> {
return api.wpFetch<T>(path, { method: 'DELETE' });
},
};
export type CreateOrderPayload = {
items: { product_id: number; qty: number }[];
billing?: Record<string, any>;
shipping?: Record<string, any>;
status?: string;
payment_method?: string;
};
export const OrdersApi = {
list: (params?: Record<string, any>) => api.get('/orders', params),
get: (id: number) => api.get(`/orders/${id}`),
create: (payload: CreateOrderPayload) => api.post('/orders', payload),
update: (id: number, payload: any) => api.wpFetch(`/orders/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
payments: async () => api.get('/payments'),
shippings: async () => api.get('/shippings'),
countries: () => api.get('/countries'),
};
export const ProductsApi = {
search: (search: string, limit = 10) => api.get('/products/search', { search, limit }),
list: (params?: { page?: number; per_page?: number }) => api.get('/products', { params }),
categories: () => api.get('/products/categories'),
};
export const CustomersApi = {
search: (search: string) => api.get('/customers/search', { search }),
searchByEmail: (email: string) => api.get('/customers/search', { email }),
};
export async function getMenus() {
// Prefer REST; fall back to localized snapshot
try {
const res = await fetch(`${(window as any).WNW_API}/menus`, { credentials: 'include' });
const res = await fetch(`${window.WNW_API}/menus`, { credentials: 'include' });
if (!res.ok) throw new Error('menus fetch failed');
return (await res.json()).items || [];
} catch {
return ((window as any).WNW_WC_MENUS?.items) || [];
return (window.WNW_WC_MENUS?.items) || [];
}
}