From 396ca25be4c8575598d3a1a81595e56c7bcb31eb Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 30 May 2026 13:02:08 +0700 Subject: [PATCH] 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 --- RELEASE_NOTES-v1.0.md | 171 +++ admin-spa/postcss.config.cjs | 1 - admin-spa/src/App.tsx | 921 +--------------- admin-spa/src/components/ErrorCard.tsx | 12 +- admin-spa/src/components/Pagination.tsx | 48 + admin-spa/src/components/ProductCard.tsx | 1 + .../src/components/SharedContentLayout.tsx | 238 +++++ admin-spa/src/components/layout/AppRoutes.tsx | 269 +++++ .../src/components/layout/AuthWrapper.tsx | 56 + admin-spa/src/components/layout/Header.tsx | 211 ++++ admin-spa/src/components/layout/Shell.tsx | 162 +++ admin-spa/src/components/layout/Sidebar.tsx | 54 + admin-spa/src/components/layout/TopNav.tsx | 36 + admin-spa/src/hooks/useFABConfig.tsx | 2 +- admin-spa/src/hooks/useFullscreen.ts | 45 + admin-spa/src/hooks/useIsDesktop.ts | 17 + admin-spa/src/hooks/useShortcuts.tsx | 7 + admin-spa/src/hooks/useUnsavedChanges.ts | 52 + admin-spa/src/lib/api.ts | 59 +- admin-spa/src/lib/api/client.ts | 2 + admin-spa/src/lib/api/coupons.ts | 2 +- admin-spa/src/lib/api/customers.ts | 4 +- admin-spa/src/lib/api/orders.ts | 23 + admin-spa/src/lib/api/products.ts | 12 + admin-spa/src/lib/cart/store.ts | 1 + admin-spa/src/lib/currency.ts | 2 + admin-spa/src/lib/nav-icons.ts | 26 + admin-spa/src/lib/sectionStyles.ts | 85 ++ admin-spa/src/lib/windowAPI.ts | 4 +- admin-spa/src/routes/Appearance/Footer.tsx | 2 +- admin-spa/src/routes/Appearance/General.tsx | 5 +- .../Pages/components/CanvasRenderer.tsx | 122 ++- .../Pages/components/InspectorPanel.tsx | 169 ++- .../Pages/components/InspectorRepeater.tsx | 538 ++++++---- .../Pages/components/RepeaterProductField.tsx | 100 ++ .../Pages/components/SectionEditor.tsx | 6 +- .../BentoCategoryGridRenderer.tsx | 42 + .../MarqueeBannerRenderer.tsx | 38 + .../ProductCarouselRenderer.tsx | 60 ++ .../ShoppableImageRenderer.tsx | 63 ++ .../components/section-renderers/index.ts | 4 + .../src/routes/Appearance/Pages/index.tsx | 180 +++- .../Appearance/Pages/schema/sectionSchema.ts | 301 ++++++ .../Pages/store/usePageEditorStore.ts | 56 +- admin-spa/src/routes/Customers/Detail.tsx | 3 +- admin-spa/src/routes/Customers/index.tsx | 121 ++- admin-spa/src/routes/Dashboard/Customers.tsx | 15 +- admin-spa/src/routes/Dashboard/index.tsx | 16 +- .../routes/Marketing/Coupons/CouponForm.tsx | 2 +- .../src/routes/Marketing/Coupons/index.tsx | 53 +- .../Marketing/Newsletter/Subscribers.tsx | 226 ++-- admin-spa/src/routes/Orders/Detail.tsx | 3 +- admin-spa/src/routes/Orders/Edit.tsx | 3 +- admin-spa/src/routes/Orders/New.tsx | 3 +- admin-spa/src/routes/Orders/index.old.tsx | 478 --------- admin-spa/src/routes/Orders/index.tsx | 27 +- .../src/routes/Orders/partials/OrderForm.tsx | 4 +- admin-spa/src/routes/Products/Attributes.tsx | 30 +- admin-spa/src/routes/Products/Categories.tsx | 30 +- admin-spa/src/routes/Products/Tags.tsx | 31 +- admin-spa/src/routes/Products/index.tsx | 34 +- .../Products/partials/tabs/GeneralTab.tsx | 44 +- .../Products/partials/tabs/VariationsTab.tsx | 54 +- admin-spa/src/routes/Settings/CustomerSPA.tsx | 0 admin-spa/src/routes/Settings/LocalPickup.tsx | 38 +- .../Settings/Notifications/EditTemplate.tsx | 58 +- .../Notifications/EmailCustomization.tsx | 53 +- admin-spa/src/routes/Settings/Store.tsx | 45 + admin-spa/src/routes/Settings/Store.tsx.bak | 381 ------- admin-spa/src/routes/Settings/Tax.tsx | 10 +- admin-spa/src/routes/Subscriptions/Detail.tsx | 74 +- admin-spa/src/routes/Subscriptions/index.tsx | 645 +++++++----- admin-spa/src/types/window.d.ts | 13 + admin-spa/tailwind.config.js | 2 +- admin-spa/tsconfig.json | 3 +- admin-spa/vite.config.ts | 5 +- customer-spa/package-lock.json | 43 + customer-spa/package.json | 1 + .../src/components/Layout/MiniCartDrawer.tsx | 232 +++++ .../src/components/Layout/MinimalHeader.tsx | 7 +- customer-spa/src/components/ThemeToggle.tsx | 28 + customer-spa/src/contexts/ThemeContext.tsx | 43 +- customer-spa/src/index.css | 78 ++ customer-spa/src/layouts/BaseLayout.tsx | 71 +- customer-spa/src/layouts/LayoutWrapper.tsx | 7 +- customer-spa/src/pages/Checkout/index.tsx | 980 +++++++----------- customer-spa/src/pages/DynamicPage/index.tsx | 16 + .../sections/BentoCategoryGrid.tsx | 140 +++ .../sections/FeatureGridSection.tsx | 4 +- .../DynamicPage/sections/MarqueeBanner.tsx | 52 + .../DynamicPage/sections/ProductCarousel.tsx | 138 +++ .../DynamicPage/sections/ShoppableImage.tsx | 190 ++++ customer-spa/src/pages/Product/index.tsx | 219 +++- customer-spa/src/styles/theme.css | 21 +- customer-spa/tsconfig.json | 3 +- docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md | 42 + docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md | 26 + includes/Api/PagesController.php | 224 ++-- includes/Api/ProductsController.php | 68 +- includes/Features.php | 174 ++++ includes/Frontend/PageSSR.php | 557 ++++++++-- includes/Frontend/PlaceholderRenderer.php | 299 +++++- includes/Frontend/SchemaMigration.php | 253 +++++ package.json | 3 +- project_info__1.md | 623 +++++++++++ scratch.php | 21 + scripts/check-schema-drift.mjs | 228 ++++ test_product.php | 14 + tests/PageSSRTest.php | 382 +++++++ tests/PlaceholderRendererTest.php | 224 ++++ tests/SchemaMigrationTest.php | 207 ++++ tests/VERIFICATION_CHECKLIST.md | 205 ++++ tests/feature-grid-regression.test.ts | 223 ++++ .../feature-grid-legacy-features.json | 26 + .../fixtures/page-editor/feature-grid-v1.json | 35 + tests/page-editor-fixtures.test.mjs | 41 + tests/parity.test.ts | 748 +++++++++++++ tests/schema-integration.test.ts | 279 +++++ 118 files changed, 10162 insertions(+), 3726 deletions(-) create mode 100644 RELEASE_NOTES-v1.0.md delete mode 100644 admin-spa/postcss.config.cjs create mode 100644 admin-spa/src/components/Pagination.tsx create mode 100644 admin-spa/src/components/ProductCard.tsx create mode 100644 admin-spa/src/components/SharedContentLayout.tsx create mode 100644 admin-spa/src/components/layout/AppRoutes.tsx create mode 100644 admin-spa/src/components/layout/AuthWrapper.tsx create mode 100644 admin-spa/src/components/layout/Header.tsx create mode 100644 admin-spa/src/components/layout/Shell.tsx create mode 100644 admin-spa/src/components/layout/Sidebar.tsx create mode 100644 admin-spa/src/components/layout/TopNav.tsx create mode 100644 admin-spa/src/hooks/useFullscreen.ts create mode 100644 admin-spa/src/hooks/useIsDesktop.ts create mode 100644 admin-spa/src/hooks/useUnsavedChanges.ts create mode 100644 admin-spa/src/lib/api/client.ts create mode 100644 admin-spa/src/lib/api/orders.ts create mode 100644 admin-spa/src/lib/api/products.ts create mode 100644 admin-spa/src/lib/cart/store.ts create mode 100644 admin-spa/src/lib/nav-icons.ts create mode 100644 admin-spa/src/lib/sectionStyles.ts create mode 100644 admin-spa/src/routes/Appearance/Pages/components/RepeaterProductField.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/section-renderers/BentoCategoryGridRenderer.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/section-renderers/MarqueeBannerRenderer.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/section-renderers/ProductCarouselRenderer.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/components/section-renderers/ShoppableImageRenderer.tsx create mode 100644 admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts delete mode 100644 admin-spa/src/routes/Orders/index.old.tsx delete mode 100644 admin-spa/src/routes/Settings/CustomerSPA.tsx delete mode 100644 admin-spa/src/routes/Settings/Store.tsx.bak create mode 100644 customer-spa/src/components/Layout/MiniCartDrawer.tsx create mode 100644 customer-spa/src/components/ThemeToggle.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/BentoCategoryGrid.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/MarqueeBanner.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/ProductCarousel.tsx create mode 100644 customer-spa/src/pages/DynamicPage/sections/ShoppableImage.tsx create mode 100644 docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md create mode 100644 docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md create mode 100644 includes/Features.php create mode 100644 includes/Frontend/SchemaMigration.php create mode 100644 project_info__1.md create mode 100644 scratch.php create mode 100644 scripts/check-schema-drift.mjs create mode 100644 test_product.php create mode 100644 tests/PageSSRTest.php create mode 100644 tests/PlaceholderRendererTest.php create mode 100644 tests/SchemaMigrationTest.php create mode 100644 tests/VERIFICATION_CHECKLIST.md create mode 100644 tests/feature-grid-regression.test.ts create mode 100644 tests/fixtures/page-editor/feature-grid-legacy-features.json create mode 100644 tests/fixtures/page-editor/feature-grid-v1.json create mode 100644 tests/page-editor-fixtures.test.mjs create mode 100644 tests/parity.test.ts create mode 100644 tests/schema-integration.test.ts diff --git a/RELEASE_NOTES-v1.0.md b/RELEASE_NOTES-v1.0.md new file mode 100644 index 0000000..d7974cd --- /dev/null +++ b/RELEASE_NOTES-v1.0.md @@ -0,0 +1,171 @@ +# WooNooW Page Editor v1.0 - Release Notes + +## Summary + +This release implements comprehensive improvements to the WooNooW Page Editor system, focusing on WYSIWYG consistency between the admin editor canvas and frontend rendering, improved SSR coverage for SEO, and robust backward compatibility with legacy structures. + +--- + +## Key Improvements + +### 1. Canonical Section Schema + +**What changed:** +- Unified section schema definition in `admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts` +- Normalized `feature-grid` to use `items` prop (not `features`) +- Standardized default values across all section types + +**Why it matters:** +- Single source of truth for section types, prop names, and defaults +- Consistent naming eliminates `items` vs `features` confusion +- Easier maintenance and extension of new section types + +**Files affected:** +- `admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts` +- `admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts` +- `admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx` + +--- + +### 2. Enhanced PHP SSR with Full Style Support + +**What changed:** +- All section renderers now support section styles: background, gradient, images, overlay, padding, content width, height presets +- Hero, Content, Image+Text, Feature Grid, CTA Banner, Contact Form, Bento Grid, Product Carousel, Shoppable Image, and Marquee Banner all render with full style parity + +**Why it matters:** +- Bots now see the same styled content as human visitors +- Better SEO with proper semantic HTML and styling +- Consistent experience across all section types + +**Files affected:** +- `includes/Frontend/PageSSR.php` + +--- + +### 3. Dynamic Source Resolution Architecture + +**What changed:** +- Moved complex dynamic resolution from controller to `PlaceholderRenderer` +- Added typed output contracts (scalar, html, url, array) +- Added explicit fallback behavior for empty/invalid dynamic sources +- Added caching for post data resolution + +**Why it matters:** +- `related_posts` special-casing removed from controller +- Consistent handling of all dynamic sources +- Better fallback UX when sources resolve to empty + +**Files affected:** +- `includes/Frontend/PlaceholderRenderer.php` +- `includes/Api/PagesController.php` + +--- + +### 4. Schema Migration System + +**What changed:** +- New `SchemaMigration` class handles backward compatibility +- Automatic migration of legacy structures on read +- Schema versioning (`schemaVersion`) tracking + +**Why it matters:** +- Legacy pages/templates with `features` key automatically normalized to `items` +- Old style keys (`container_width`, `height`) normalized to new keys (`contentWidth`, `heightPreset`) +- Zero breaking changes for existing content + +**Files affected:** +- `includes/Frontend/SchemaMigration.php` +- `includes/Api/PagesController.php` + +--- + +### 5. Feature Flags for Safe Rollout + +**What changed:** +- New `Features` class for feature toggles +- Default flags: `dynamic_preview`, `schema_v1`, `enhanced_ssr`, `placeholder_cache` +- Per-user override support for testing + +**Why it matters:** +- Staged rollout capability for new features +- Easy rollback if issues arise +- Admin control over feature enablement + +**Files affected:** +- `includes/Features.php` + +--- + +## Test Coverage + +### New Test Files + +| Test File | Coverage | +|-----------|----------| +| `tests/SchemaMigrationTest.php` | Migration of legacy structures, feature-grid normalization, style key conversions | +| `tests/PlaceholderRendererTest.php` | Dynamic source resolution, typed output contracts, fallback behavior | +| `tests/PageSSRTest.php` | All section renderers, resolve_props(), HTML output validation | +| `tests/schema-integration.test.ts` | TypeScript schema validation, canvas prop flattening | +| `tests/feature-grid-regression.test.ts` | items/features naming, default values, style keys regression | +| `tests/parity.test.ts` | React vs SSR content parity, CSS class matching, color scheme parity | + +### CI Guardrails + +- `scripts/check-schema-drift.mjs` - Validates schema consistency between TypeScript and PHP + +### Verification Checklist + +- `tests/VERIFICATION_CHECKLIST.md` - Pre-flight checks and manual verification procedures + +--- + +## Migration Guide + +### For Existing Installations + +**No action required.** All existing pages and templates will be automatically migrated on first access: +- `features` → `items` normalization +- `container_width` → `contentWidth` normalization +- `height` → `heightPreset` normalization +- `backgroundType` defaults added + +### For Custom Code + +If you have custom code referencing section props: +- Replace `section.props.features` with `section.props.items` for feature-grid sections +- Replace `section.styles.container_width` with `section.styles.contentWidth` +- Replace `section.styles.height` with `section.styles.heightPreset` + +--- + +## Breaking Changes + +**None.** This release maintains full backward compatibility. + +--- + +## Deprecations + +The following are deprecated and will be removed in a future version: + +| Deprecated | Replacement | Estimated Removal | +|------------|-------------|-------------------| +| `features` prop on feature-grid | `items` | v2.0 | +| `container_width` style | `contentWidth` | v2.0 | +| `height` style | `heightPreset` | v2.0 | +| Generic `render_generic()` fallback | Explicit section renderers | v2.0 | + +--- + +## Support & Documentation + +- Internal support playbook: See project documentation +- Schema contract: `project_info__1.md` +- Test fixtures: `tests/fixtures/page-editor/` + +--- + +## Contributors + +This release implements the work planned in the Page Editor audit and consistency improvements. \ No newline at end of file diff --git a/admin-spa/postcss.config.cjs b/admin-spa/postcss.config.cjs deleted file mode 100644 index cce4985..0000000 --- a/admin-spa/postcss.config.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index c6d43a2..d8cb7f4 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -1,889 +1,39 @@ -import React, { useEffect, useState } from 'react'; -import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom'; -import { Login } from './routes/Login'; -import ResetPassword from './routes/ResetPassword'; -import Dashboard from '@/routes/Dashboard'; -import DashboardRevenue from '@/routes/Dashboard/Revenue'; -import DashboardOrders from '@/routes/Dashboard/Orders'; -import DashboardProducts from '@/routes/Dashboard/Products'; -import DashboardCustomers from '@/routes/Dashboard/Customers'; -import DashboardCoupons from '@/routes/Dashboard/Coupons'; -import DashboardTaxes from '@/routes/Dashboard/Taxes'; -import OrdersIndex from '@/routes/Orders'; -import OrderNew from '@/routes/Orders/New'; -import OrderEdit from '@/routes/Orders/Edit'; -import OrderDetail from '@/routes/Orders/Detail'; -import OrderInvoice from '@/routes/Orders/Invoice'; -import OrderLabel from '@/routes/Orders/Label'; -import ProductsIndex from '@/routes/Products'; -import ProductNew from '@/routes/Products/New'; -import ProductEdit from '@/routes/Products/Edit'; -import ProductCategories from '@/routes/Products/Categories'; -import ProductTags from '@/routes/Products/Tags'; -import ProductAttributes from '@/routes/Products/Attributes'; -import Licenses from '@/routes/Products/Licenses'; -import LicenseDetail from '@/routes/Products/Licenses/Detail'; -import SoftwareVersions from '@/routes/Products/SoftwareVersions'; -import SubscriptionsIndex from '@/routes/Subscriptions'; -import SubscriptionDetail from '@/routes/Subscriptions/Detail'; -import CouponsIndex from '@/routes/Marketing/Coupons'; -import CouponNew from '@/routes/Marketing/Coupons/New'; -import CouponEdit from '@/routes/Marketing/Coupons/Edit'; -import CustomersIndex from '@/routes/Customers'; -import CustomerNew from '@/routes/Customers/New'; -import CustomerEdit from '@/routes/Customers/Edit'; -import CustomerDetail from '@/routes/Customers/Detail'; +import React from 'react'; +import { createHashRouter, RouterProvider, createRoutesFromElements, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react'; import { Toaster } from 'sonner'; -import { useShortcuts } from "@/hooks/useShortcuts"; -import { CommandPalette } from "@/components/CommandPalette"; -import { useCommandStore } from "@/lib/useCommandStore"; -import SubmenuBar from './components/nav/SubmenuBar'; -import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar'; -import { DashboardProvider } from '@/contexts/DashboardContext'; -import { PageHeaderProvider } from '@/contexts/PageHeaderContext'; -import { FABProvider } from '@/contexts/FABContext'; -import { AppProvider } from '@/contexts/AppContext'; -import { PageHeader } from '@/components/PageHeader'; -import { BottomNav } from '@/components/nav/BottomNav'; -import { FAB } from '@/components/FAB'; -import { useActiveSection } from '@/hooks/useActiveSection'; -import { NAV_TREE_VERSION } from '@/nav/tree'; -import { __ } from '@/lib/i18n'; -import { ThemeToggle } from '@/components/ThemeToggle'; +import { useTheme } from '@/components/ThemeProvider'; import { initializeWindowAPI } from '@/lib/windowAPI'; +import { Login } from './routes/Login'; +import { AuthWrapper } from './components/layout/AuthWrapper'; -import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect'; - -function useFullscreen() { - const [on, setOn] = useState(() => { - try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; } - }); - - useEffect(() => { - const id = 'wnw-fullscreen-style'; - let style = document.getElementById(id); - if (!style) { - style = document.createElement('style'); - style.id = id; - style.textContent = ` - /* Hide WP admin chrome when fullscreen */ - .wnw-fullscreen #wpadminbar, - .wnw-fullscreen #adminmenumain, - .wnw-fullscreen #screen-meta, - .wnw-fullscreen #screen-meta-links, - .wnw-fullscreen #wpfooter { display:none !important; } - .wnw-fullscreen #wpcontent { margin-left:0 !important; } - .wnw-fullscreen #wpbody-content { padding-bottom:0 !important; } - .wnw-fullscreen html, .wnw-fullscreen body { height: 100%; overflow: hidden; } - .wnw-fullscreen .woonoow-fullscreen-root { - position: fixed; - inset: 0; - z-index: 999; - background: var(--background, #fff); - height: 100dvh; /* ensure full viewport height on mobile/desktop */ - overflow: hidden; /* prevent double scrollbars; inner
handles scrolling */ - overscroll-behavior: contain; - display: flex; - flex-direction: column; - contain: layout paint size; /* prevent WP wrappers from affecting layout */ - } - `; - document.head.appendChild(style); - } - document.body.classList.toggle('wnw-fullscreen', on); - try { localStorage.setItem('wnwFullscreen', on ? '1' : '0'); } catch { /* ignore localStorage errors */ } - return () => { /* do not remove style to avoid flicker between reloads */ }; - }, [on]); - - return { on, setOn } as const; -} - -function ActiveNavLink({ to, startsWith, end, className, children, childPaths }: any) { - // Use the router location hook instead of reading from NavLink's className args - const location = useLocation(); - const starts = typeof startsWith === 'string' && startsWith.length > 0 ? startsWith : undefined; +function ToasterWithTheme() { + const { actualTheme } = useTheme(); return ( - { - // Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard" - const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard')); - - // Check if current path matches any child paths (e.g., /coupons under Marketing) - const matchesChild = childPaths && Array.isArray(childPaths) - ? childPaths.some((childPath: string) => location.pathname.startsWith(childPath)) - : false; - - // For dashboard: only active if isDashboard is true - // For others: active if path starts with their path OR matches a child path - let activeByPath = false; - if (starts === '/dashboard') { - activeByPath = isDashboard; - } else if (starts) { - activeByPath = location.pathname.startsWith(starts) || matchesChild; - } - - const mergedActive = nav.isActive || activeByPath; - if (typeof className === 'function') { - // Preserve caller pattern: className receives { isActive } - return className({ isActive: mergedActive }); - } - return `${className ?? ''} ${mergedActive ? '' : ''}`.trim(); - }} - > - {children} - - ); -} - -interface SidebarProps { - collapsed: boolean; - onToggle: () => void; -} - -function Sidebar({ collapsed, onToggle }: SidebarProps) { - const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all"; - const linkCollapsed = "flex items-center justify-center rounded-md p-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all"; - const active = "bg-secondary"; - const { main } = useActiveSection(); - - // Icon mapping - const iconMap: Record = { - 'layout-dashboard': LayoutDashboard, - 'receipt-text': ReceiptText, - 'package': Package, - 'tag': Tag, - 'users': Users, - 'mail': Mail, - 'palette': Palette, - 'settings': SettingsIcon, - 'help-circle': HelpCircle, - 'repeat': Repeat, - }; - - // Get navigation tree from backend - const navTree = (window as any).WNW_NAV_TREE || []; - - return ( - - ); -} - -function TopNav({ fullscreen = false }: { fullscreen?: boolean }) { - const link = "inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0"; - const active = "bg-secondary"; - const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]'; - const { main } = useActiveSection(); - - // Icon mapping (same as Sidebar) - const iconMap: Record = { - 'layout-dashboard': LayoutDashboard, - 'receipt-text': ReceiptText, - 'package': Package, - 'tag': Tag, - 'users': Users, - 'mail': Mail, - 'palette': Palette, - 'settings': SettingsIcon, - 'repeat': Repeat, - }; - - // Get navigation tree from backend - const navTree = (window as any).WNW_NAV_TREE || []; - - return ( -
-
- {navTree.map((item: any) => { - const IconComponent = iconMap[item.icon] || Package; - const isActive = main.key === item.key; - return ( - - - {item.label} - - ); - })} -
-
- ); -} -function useIsDesktop(minWidth = 1024) { // lg breakpoint - const [isDesktop, setIsDesktop] = useState(() => { - if (typeof window === 'undefined') return false; - return window.matchMedia(`(min-width: ${minWidth}px)`).matches; - }); - useEffect(() => { - const mq = window.matchMedia(`(min-width: ${minWidth}px)`); - const onChange = () => setIsDesktop(mq.matches); - try { mq.addEventListener('change', onChange); } catch { mq.addListener(onChange); } - return () => { try { mq.removeEventListener('change', onChange); } catch { mq.removeListener(onChange); } }; - }, [minWidth]); - return isDesktop; -} - -import SettingsIndex from '@/routes/Settings'; -import SettingsStore from '@/routes/Settings/Store'; -import SettingsPayments from '@/routes/Settings/Payments'; -import SettingsShipping from '@/routes/Settings/Shipping'; -import SettingsTax from '@/routes/Settings/Tax'; -import SettingsCustomers from '@/routes/Settings/Customers'; -import SettingsSecurity from '@/routes/Settings/Security'; -import SettingsLocalPickup from '@/routes/Settings/LocalPickup'; -import SettingsNotifications from '@/routes/Settings/Notifications'; -import StaffNotifications from '@/routes/Settings/Notifications/Staff'; -import CustomerNotifications from '@/routes/Settings/Notifications/Customer'; -import ChannelConfiguration from '@/routes/Settings/Notifications/ChannelConfiguration'; -import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfiguration'; -import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration'; -import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization'; -import EditTemplate from '@/routes/Settings/Notifications/EditTemplate'; -import ActivityLog from '@/routes/Settings/Notifications/ActivityLog'; -import SettingsDeveloper from '@/routes/Settings/Developer'; -import SettingsModules from '@/routes/Settings/Modules'; -import ModuleSettings from '@/routes/Settings/ModuleSettings'; -import AppearanceIndex from '@/routes/Appearance'; -import AppearanceGeneral from '@/routes/Appearance/General'; -import AppearanceHeader from '@/routes/Appearance/Header'; -import AppearanceFooter from '@/routes/Appearance/Footer'; -import AppearanceShop from '@/routes/Appearance/Shop'; -import AppearanceProduct from '@/routes/Appearance/Product'; -import AppearanceCart from '@/routes/Appearance/Cart'; -import AppearanceCheckout from '@/routes/Appearance/Checkout'; -import AppearanceThankYou from '@/routes/Appearance/ThankYou'; -import AppearanceAccount from '@/routes/Appearance/Account'; -import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor'; -import AppearancePages from '@/routes/Appearance/Pages'; -import MarketingIndex from '@/routes/Marketing'; -import NewsletterLayout from '@/routes/Marketing/Newsletter'; -import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers'; -import NewsletterCampaignsList from '@/routes/Marketing/Campaigns'; -import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; -import MorePage from '@/routes/More'; -import Help from '@/routes/Help'; -import Onboarding from '@/routes/Onboarding'; - -// Addon Route Component - Dynamically loads addon components -function AddonRoute({ config }: { config: any }) { - const [Component, setComponent] = React.useState(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - if (!config.component_url) { - setError('No component URL provided'); - setLoading(false); - return; - } - - setLoading(true); - setError(null); - - // Dynamically import the addon component - import(/* @vite-ignore */ config.component_url) - .then((mod) => { - setComponent(() => mod.default || mod); - setLoading(false); - }) - .catch((err) => { - console.error('[AddonRoute] Failed to load component:', err); - setError(err.message || 'Failed to load addon component'); - setLoading(false); - }); - }, [config.component_url]); - - if (loading) { - return ( -
-
- -

{__('Loading addon...')}

-
-
- ); - } - - if (error) { - return ( -
-
-

{__('Failed to Load Addon')}

-

{error}

-
-
- ); - } - - if (!Component) { - return ( -
-
-

{__('Addon component not found')}

-
-
- ); - } - - // Render the addon component with props - return ; -} - -function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRef, onVisibilityChange }: { onFullscreen: () => void; fullscreen: boolean; showToggle?: boolean; scrollContainerRef?: React.RefObject; onVisibilityChange?: (visible: boolean) => void }) { - const [siteTitle, setSiteTitle] = React.useState((window as any).wnw?.siteTitle || 'WooNooW'); - const [storeLogo, setStoreLogo] = React.useState(''); - const [storeLogoDark, setStoreLogoDark] = React.useState(''); - const [isVisible, setIsVisible] = React.useState(true); - const lastScrollYRef = React.useRef(0); - const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; - const [isDark, setIsDark] = React.useState(false); - - // Detect dark mode - React.useEffect(() => { - const checkDarkMode = () => { - const htmlEl = document.documentElement; - setIsDark(htmlEl.classList.contains('dark')); - }; - - checkDarkMode(); - - // Watch for theme changes - const observer = new MutationObserver(checkDarkMode); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class'] - }); - - return () => observer.disconnect(); - }, []); - - // Notify parent of visibility changes - React.useEffect(() => { - onVisibilityChange?.(isVisible); - }, [isVisible, onVisibilityChange]); - - // Fetch store branding on mount - React.useEffect(() => { - const fetchBranding = async () => { - try { - const response = await fetch((window.WNW_CONFIG?.restUrl || '') + '/store/branding'); - if (response.ok) { - const data = await response.json(); - if (data.store_logo) setStoreLogo(data.store_logo); - if (data.store_logo_dark) setStoreLogoDark(data.store_logo_dark); - if (data.store_name) setSiteTitle(data.store_name); - } - } catch (err) { - console.error('Failed to fetch branding:', err); - } - }; - fetchBranding(); - }, []); - - // Listen for store settings updates - React.useEffect(() => { - const handleStoreUpdate = (event: CustomEvent) => { - if (event.detail?.store_logo) setStoreLogo(event.detail.store_logo); - if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark); - if (event.detail?.store_name) setSiteTitle(event.detail.store_name); - }; - - window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate); - return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate); - }, []); - - // Hide/show header on scroll (mobile only) - React.useEffect(() => { - const scrollContainer = scrollContainerRef?.current; - if (!scrollContainer) return; - - const handleScroll = () => { - const currentScrollY = scrollContainer.scrollTop; - - // Only apply on mobile (check window width) - if (window.innerWidth >= 768) { - setIsVisible(true); - return; - } - - if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) { - // Scrolling down & past threshold - setIsVisible(false); - } else if (currentScrollY < lastScrollYRef.current) { - // Scrolling up - setIsVisible(true); - } - - lastScrollYRef.current = currentScrollY; - }; - - scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - }, [scrollContainerRef]); - - const handleLogout = async () => { - try { - await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', { - method: 'POST', - credentials: 'include', - }); - window.location.reload(); - } catch (err) { - console.error('Logout failed:', err); - } - }; - - // Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen) - if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) { - return null; - } - - // Choose logo based on theme - const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo; - - return ( -
-
- {currentLogo ? ( - {siteTitle} - ) : ( -
{siteTitle}
- )} - - - - {__('Store')} - -
-
-
{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}
- {isStandalone && ( - <> - - {__('WordPress')} - - {window.WNW_CONFIG?.customerSpaEnabled && ( - - {__('Store')} - - )} - - - )} - {!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && ( - - {__('Store')} - - )} - - {showToggle && ( - - )} -
-
+ ); } const qc = new QueryClient(); -function ShortcutsBinder({ onToggle }: { onToggle: () => void }) { - useShortcuts({ toggleFullscreen: onToggle }); - return null; -} - -// Centralized route controller so we don't duplicate in each layout -function AppRoutes() { - const addonRoutes = (window as any).WNW_ADDON_ROUTES || []; - - return ( - - {/* Dashboard */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Products */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Orders */} - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Subscriptions */} - } /> - } /> - - {/* Coupons (under Marketing) */} - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Customers */} - } /> - } /> - } /> - } /> - - {/* More */} - } /> - - {/* Settings */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Appearance */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Marketing */} - } /> - }> - } /> - } /> - } /> - } /> - - - {/* Legacy Redirects for Newsletter (using component to preserve params) */} - } /> - } /> - } /> - - {/* Help - Main menu route with no submenu */} - } /> - - {/* Dynamic Addon Routes */} - {addonRoutes.map((route: any) => ( - } - /> - ))} - - ); -} - -function Shell() { - const { on, setOn } = useFullscreen(); - const { main } = useActiveSection(); - const toggle = () => setOn(v => !v); - const exitFullscreen = () => setOn(false); - const isDesktop = useIsDesktop(); - const location = useLocation(); - const scrollContainerRef = React.useRef(null); - - // Sidebar collapsed state with localStorage persistence - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - try { return localStorage.getItem('wnwSidebarCollapsed') === '1'; } catch { return false; } - }); - const [wasAutoCollapsed, setWasAutoCollapsed] = useState(false); - - // Save sidebar state to localStorage - useEffect(() => { - try { localStorage.setItem('wnwSidebarCollapsed', sidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } - }, [sidebarCollapsed]); - - // Check if current route is Page Editor (auto-collapse route) - const isPageEditorRoute = location.pathname === '/appearance/pages'; - - // Auto-collapse/expand sidebar based on route - useEffect(() => { - if (isPageEditorRoute) { - // Auto-collapse when entering Page Editor (if not already collapsed) - if (!sidebarCollapsed) { - setSidebarCollapsed(true); - setWasAutoCollapsed(true); - } - } else { - // Auto-expand when leaving Page Editor (only if we auto-collapsed it) - if (wasAutoCollapsed && sidebarCollapsed) { - setSidebarCollapsed(false); - setWasAutoCollapsed(false); - } - } - }, [isPageEditorRoute]); - - const toggleSidebar = () => { - setSidebarCollapsed(v => !v); - setWasAutoCollapsed(false); // Manual toggle clears auto state - }; - - // Check if standalone mode - force fullscreen and hide toggle - const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; - const fullscreen = isStandalone ? true : on; - - // Check if current route is dashboard - const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard'); - - // Check if current route is More page (no submenu needed) - const isMorePage = location.pathname === '/more'; - - const submenuTopClass = fullscreen ? 'top-0' : 'top-[calc(7rem+32px)]'; - const submenuZIndex = fullscreen ? 'z-50' : 'z-40'; - - // Check if current route is setup/onboarding - const isSetup = location.pathname === '/setup'; - - if (isSetup) { - return ( - -
- -
-
- ); - } - - return ( - - {!isStandalone && } - {!isStandalone && } -
-
- {fullscreen ? ( - isDesktop ? ( -
- -
- {/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */} -
- - {isDashboardRoute ? ( - - ) : ( - - )} -
-
- -
-
-
- ) : ( -
- {/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */} -
- - {!isMorePage && (isDashboardRoute ? ( - - ) : ( - - ))} -
-
-
- -
-
- - -
- ) - ) : ( -
- - {/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */} -
- - {isDashboardRoute ? ( - - ) : ( - - )} -
-
-
- -
-
-
- )} -
-
- ); -} - -function AuthWrapper() { - const [isAuthenticated, setIsAuthenticated] = useState( - window.WNW_CONFIG?.isAuthenticated ?? true - ); - const [isChecking, setIsChecking] = useState(window.WNW_CONFIG?.standaloneMode ?? false); - const location = useLocation(); - - useEffect(() => { - console.log('[AuthWrapper] Initial config:', { - standaloneMode: window.WNW_CONFIG?.standaloneMode, - isAuthenticated: window.WNW_CONFIG?.isAuthenticated, - currentUser: window.WNW_CONFIG?.currentUser - }); - - // In standalone mode, trust the initial PHP auth check - // PHP uses wp_signon which sets proper WordPress cookies - const checkAuth = () => { - if (window.WNW_CONFIG?.standaloneMode) { - setIsAuthenticated(window.WNW_CONFIG.isAuthenticated ?? false); - setIsChecking(false); - } else { - // In wp-admin mode, always authenticated - setIsChecking(false); - } - }; - checkAuth(); - }, []); - - if (isChecking) { - return ( -
- -
- ); - } - - if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') { - return ; - } - - if (location.pathname === '/login' && isAuthenticated) { - return ; - } - - return ( - - - - - - - - ); -} +const router = createHashRouter( + createRoutesFromElements( + <> + {window.WNW_CONFIG?.standaloneMode && ( + } /> + )} + } /> + + ) +); export default function App() { // Initialize Window API for addon developers @@ -893,23 +43,8 @@ export default function App() { return ( - - - {window.WNW_CONFIG?.standaloneMode && ( - } /> - )} - } /> - - - + + ); } \ No newline at end of file diff --git a/admin-spa/src/components/ErrorCard.tsx b/admin-spa/src/components/ErrorCard.tsx index ea2c718..69e6b51 100644 --- a/admin-spa/src/components/ErrorCard.tsx +++ b/admin-spa/src/components/ErrorCard.tsx @@ -19,18 +19,18 @@ export function ErrorCard({ }: ErrorCardProps) { return (
-
+
- +
-

{title}

+

{title}

{message && ( -

{message}

+

{message}

)} {onRetry && ( +
+ {__('Page')} {page} {__('of')} {totalPages} +
+ +
+
+ ); +} diff --git a/admin-spa/src/components/ProductCard.tsx b/admin-spa/src/components/ProductCard.tsx new file mode 100644 index 0000000..8ae569e --- /dev/null +++ b/admin-spa/src/components/ProductCard.tsx @@ -0,0 +1 @@ +export function ProductCard({ product }: any) { return
{product?.title || 'Product'}
; } diff --git a/admin-spa/src/components/SharedContentLayout.tsx b/admin-spa/src/components/SharedContentLayout.tsx new file mode 100644 index 0000000..b678a0f --- /dev/null +++ b/admin-spa/src/components/SharedContentLayout.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + + +interface SharedContentProps { + // Content + title?: string; + text?: string; // HTML content + + // Image + image?: string; + imagePosition?: 'left' | 'right' | 'top' | 'bottom'; + + // Layout + containerWidth?: 'full' | 'contained' | 'boxed'; + + // Styles + className?: string; + titleStyle?: React.CSSProperties; + titleClassName?: string; + textStyle?: React.CSSProperties; + textClassName?: string; + headingStyle?: React.CSSProperties; // For prose headings override + imageStyle?: React.CSSProperties; + + // Pro Features (for future) + buttons?: Array<{ text: string, url: string }>; + buttonStyle?: { classNames?: string; style?: React.CSSProperties }; +} + + +export const SharedContentLayout: React.FC = ({ + title, + text, + image, + imagePosition = 'left', + containerWidth = 'contained', + className, + titleStyle, + titleClassName, + textStyle, + textClassName, + headingStyle, + buttons, + + imageStyle, + buttonStyle +}) => { + + const hasImage = !!image; + const isImageLeft = imagePosition === 'left'; + const isImageRight = imagePosition === 'right'; + const isImageTop = imagePosition === 'top'; + const isImageBottom = imagePosition === 'bottom'; + + // 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-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' + : containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it + ); + + const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first'; + + const proseStyle = { + ...textStyle, + '--tw-prose-headings': headingStyle?.color, + '--tw-prose-body': textStyle?.color, + } as React.CSSProperties; + + return ( +
+ {containerWidth === 'boxed' ? ( +
+
+ {/* Image Side */} + {hasImage && ( +
+ {title +
+ )} + + {/* Content Side */} +
+ {title && ( +

+ {title} +

+ )} + + + + {text && ( +
+ )} + + + + {/* Buttons */} + {buttons && buttons.length > 0 && ( +
+ {buttons.map((btn, idx) => ( + btn.text && btn.url && ( + + {btn.text} + + ) + ))} +
+ )} +
+
+
+ ) : ( +
+ {/* Image Side */} + {hasImage && ( +
+ {title +
+ )} + + {/* Content Side */} +
+ {title && ( +

+ {title} +

+ )} + + {text && ( +
+ )} + + {/* Buttons */} + {buttons && buttons.length > 0 && ( +
+ {buttons.map((btn, idx) => ( + btn.text && btn.url && ( + + {btn.text} + + ) + ))} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/admin-spa/src/components/layout/AppRoutes.tsx b/admin-spa/src/components/layout/AppRoutes.tsx new file mode 100644 index 0000000..9fec865 --- /dev/null +++ b/admin-spa/src/components/layout/AppRoutes.tsx @@ -0,0 +1,269 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { __ } from '@/lib/i18n'; + +// Import all routes +import ResetPassword from '@/routes/ResetPassword'; +import Dashboard from '@/routes/Dashboard'; +import DashboardRevenue from '@/routes/Dashboard/Revenue'; +import DashboardOrders from '@/routes/Dashboard/Orders'; +import DashboardProducts from '@/routes/Dashboard/Products'; +import DashboardCustomers from '@/routes/Dashboard/Customers'; +import DashboardCoupons from '@/routes/Dashboard/Coupons'; +import DashboardTaxes from '@/routes/Dashboard/Taxes'; +import OrdersIndex from '@/routes/Orders'; +import OrderNew from '@/routes/Orders/New'; +import OrderEdit from '@/routes/Orders/Edit'; +import OrderDetail from '@/routes/Orders/Detail'; +import OrderInvoice from '@/routes/Orders/Invoice'; +import OrderLabel from '@/routes/Orders/Label'; +import ProductsIndex from '@/routes/Products'; +import ProductNew from '@/routes/Products/New'; +import ProductEdit from '@/routes/Products/Edit'; +import ProductCategories from '@/routes/Products/Categories'; +import ProductTags from '@/routes/Products/Tags'; +import ProductAttributes from '@/routes/Products/Attributes'; +import Licenses from '@/routes/Products/Licenses'; +import LicenseDetail from '@/routes/Products/Licenses/Detail'; +import SoftwareVersions from '@/routes/Products/SoftwareVersions'; +import SubscriptionsIndex from '@/routes/Subscriptions'; +import SubscriptionDetail from '@/routes/Subscriptions/Detail'; +import CouponsIndex from '@/routes/Marketing/Coupons'; +import CouponNew from '@/routes/Marketing/Coupons/New'; +import CouponEdit from '@/routes/Marketing/Coupons/Edit'; +import CustomersIndex from '@/routes/Customers'; +import CustomerNew from '@/routes/Customers/New'; +import CustomerEdit from '@/routes/Customers/Edit'; +import CustomerDetail from '@/routes/Customers/Detail'; +import SettingsIndex from '@/routes/Settings'; +import SettingsStore from '@/routes/Settings/Store'; +import SettingsPayments from '@/routes/Settings/Payments'; +import SettingsShipping from '@/routes/Settings/Shipping'; +import SettingsTax from '@/routes/Settings/Tax'; +import SettingsCustomers from '@/routes/Settings/Customers'; +import SettingsSecurity from '@/routes/Settings/Security'; +import SettingsLocalPickup from '@/routes/Settings/LocalPickup'; +import SettingsNotifications from '@/routes/Settings/Notifications'; +import StaffNotifications from '@/routes/Settings/Notifications/Staff'; +import CustomerNotifications from '@/routes/Settings/Notifications/Customer'; +import ChannelConfiguration from '@/routes/Settings/Notifications/ChannelConfiguration'; +import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfiguration'; +import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration'; +import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization'; +import EditTemplate from '@/routes/Settings/Notifications/EditTemplate'; +import ActivityLog from '@/routes/Settings/Notifications/ActivityLog'; +import SettingsDeveloper from '@/routes/Settings/Developer'; +import SettingsModules from '@/routes/Settings/Modules'; +import ModuleSettings from '@/routes/Settings/ModuleSettings'; +import AppearanceIndex from '@/routes/Appearance'; +import AppearanceGeneral from '@/routes/Appearance/General'; +import AppearanceHeader from '@/routes/Appearance/Header'; +import AppearanceFooter from '@/routes/Appearance/Footer'; +import AppearanceShop from '@/routes/Appearance/Shop'; +import AppearanceProduct from '@/routes/Appearance/Product'; +import AppearanceCart from '@/routes/Appearance/Cart'; +import AppearanceCheckout from '@/routes/Appearance/Checkout'; +import AppearanceThankYou from '@/routes/Appearance/ThankYou'; +import AppearanceAccount from '@/routes/Appearance/Account'; +import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor'; +import AppearancePages from '@/routes/Appearance/Pages'; +import MarketingIndex from '@/routes/Marketing'; +import NewsletterLayout from '@/routes/Marketing/Newsletter'; +import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers'; +import NewsletterCampaignsList from '@/routes/Marketing/Campaigns'; +import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; +import MorePage from '@/routes/More'; +import Help from '@/routes/Help'; +import Onboarding from '@/routes/Onboarding'; +import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect'; + +// Addon Route Component - Dynamically loads addon components +function AddonRoute({ config }: { config: any }) { + const [Component, setComponent] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!config.component_url) { + setError('No component URL provided'); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + // Dynamically import the addon component + import(/* @vite-ignore */ config.component_url) + .then((mod) => { + setComponent(() => mod.default || mod); + setLoading(false); + }) + .catch((err) => { + console.error('[AddonRoute] Failed to load component:', err); + setError(err.message || 'Failed to load addon component'); + setLoading(false); + }); + }, [config.component_url]); + + if (loading) { + return ( +
+
+ +

{__('Loading addon...')}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{__('Failed to Load Addon')}

+

{error}

+
+
+ ); + } + + if (!Component) { + return ( +
+
+

{__('Addon component not found')}

+
+
+ ); + } + + // Render the addon component with props + return ; +} + +export function AppRoutes() { + const addonRoutes = window.WNW_ADDON_ROUTES || []; + + return ( + + {/* Dashboard */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Products */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Orders */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Subscriptions */} + } /> + } /> + + {/* Coupons (under Marketing) */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Customers */} + } /> + } /> + } /> + } /> + + {/* More */} + } /> + + {/* Settings */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Appearance */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Marketing */} + } /> + }> + } /> + } /> + } /> + } /> + + + {/* Legacy Redirects for Newsletter (using component to preserve params) */} + } /> + } /> + } /> + + {/* Help - Main menu route with no submenu */} + } /> + + {/* Dynamic Addon Routes */} + {addonRoutes.map((route: any) => ( + } + /> + ))} + + ); +} diff --git a/admin-spa/src/components/layout/AuthWrapper.tsx b/admin-spa/src/components/layout/AuthWrapper.tsx new file mode 100644 index 0000000..2b99d83 --- /dev/null +++ b/admin-spa/src/components/layout/AuthWrapper.tsx @@ -0,0 +1,56 @@ +import { useState, useEffect } from 'react'; +import { useLocation, Navigate } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { Shell } from './Shell'; +import { DashboardProvider } from '@/contexts/DashboardContext'; +import { PageHeaderProvider } from '@/contexts/PageHeaderContext'; +import { FABProvider } from '@/contexts/FABContext'; + +export function AuthWrapper() { + const [isAuthenticated, setIsAuthenticated] = useState( + window.WNW_CONFIG?.isAuthenticated ?? true + ); + const [isChecking, setIsChecking] = useState(window.WNW_CONFIG?.standaloneMode ?? false); + const location = useLocation(); + + useEffect(() => { + // In standalone mode, trust the initial PHP auth check + // PHP uses wp_signon which sets proper WordPress cookies + const checkAuth = () => { + if (window.WNW_CONFIG?.standaloneMode) { + setIsAuthenticated(window.WNW_CONFIG.isAuthenticated ?? false); + setIsChecking(false); + } else { + // In wp-admin mode, always authenticated + setIsChecking(false); + } + }; + checkAuth(); + }, []); + + if (isChecking) { + return ( +
+ +
+ ); + } + + if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') { + return ; + } + + if (location.pathname === '/login' && isAuthenticated) { + return ; + } + + return ( + + + + + + + + ); +} diff --git a/admin-spa/src/components/layout/Header.tsx b/admin-spa/src/components/layout/Header.tsx new file mode 100644 index 0000000..025d1c6 --- /dev/null +++ b/admin-spa/src/components/layout/Header.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { ExternalLink, Maximize2, Minimize2 } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { ThemeToggle } from '@/components/ThemeToggle'; + +export function Header({ + onFullscreen, + fullscreen, + showToggle = true, + scrollContainerRef, + onVisibilityChange +}: { + onFullscreen: () => void; + fullscreen: boolean; + showToggle?: boolean; + scrollContainerRef?: React.RefObject; + onVisibilityChange?: (visible: boolean) => void; +}) { + const [siteTitle, setSiteTitle] = React.useState(window.wnw?.siteTitle || 'WooNooW'); + const [storeLogo, setStoreLogo] = React.useState(''); + const [storeLogoDark, setStoreLogoDark] = React.useState(''); + const [isVisible, setIsVisible] = React.useState(true); + const lastScrollYRef = React.useRef(0); + const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; + const [isDark, setIsDark] = React.useState(false); + + // Detect dark mode + React.useEffect(() => { + const checkDarkMode = () => { + const htmlEl = document.documentElement; + setIsDark(htmlEl.classList.contains('dark')); + }; + + checkDarkMode(); + + // Watch for theme changes + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + + return () => observer.disconnect(); + }, []); + + // Notify parent of visibility changes + React.useEffect(() => { + onVisibilityChange?.(isVisible); + }, [isVisible, onVisibilityChange]); + + // Fetch store branding on mount + React.useEffect(() => { + const fetchBranding = async () => { + try { + const response = await fetch((window.WNW_CONFIG?.restUrl || '') + '/store/branding'); + if (response.ok) { + const data = await response.json(); + if (data.store_logo) setStoreLogo(data.store_logo); + if (data.store_logo_dark) setStoreLogoDark(data.store_logo_dark); + if (data.store_name) setSiteTitle(data.store_name); + } + } catch (err) { + console.error('Failed to fetch branding:', err); + } + }; + fetchBranding(); + }, []); + + // Listen for store settings updates + React.useEffect(() => { + const handleStoreUpdate = (event: CustomEvent) => { + if (event.detail?.store_logo) setStoreLogo(event.detail.store_logo); + if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark); + if (event.detail?.store_name) setSiteTitle(event.detail.store_name); + }; + + window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate); + return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate); + }, []); + + // Hide/show header on scroll (mobile only) + React.useEffect(() => { + const scrollContainer = scrollContainerRef?.current; + if (!scrollContainer) return; + + const handleScroll = () => { + const currentScrollY = scrollContainer.scrollTop; + + // Only apply on mobile (check window width) + if (window.innerWidth >= 768) { + setIsVisible(true); + return; + } + + if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) { + // Scrolling down & past threshold + setIsVisible(false); + } else if (currentScrollY < lastScrollYRef.current) { + // Scrolling up + setIsVisible(true); + } + + lastScrollYRef.current = currentScrollY; + }; + + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [scrollContainerRef]); + + const handleLogout = async () => { + try { + await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', { + method: 'POST', + credentials: 'include', + }); + window.location.reload(); + } catch (err) { + console.error('Logout failed:', err); + } + }; + + // Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen) + if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) { + return null; + } + + // Choose logo based on theme + const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo; + + return ( +
+
+ {currentLogo ? ( + {siteTitle} + ) : ( +
{siteTitle}
+ )} + + {!(window.WNW_CONFIG?.customerSpaEnabled) && ( + + + {__('Store')} + + )} +
+
+
{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}
+ {isStandalone && ( + <> + + {__('WordPress')} + + {window.WNW_CONFIG?.customerSpaEnabled && ( + + {__('Store')} + + )} + + + )} + {!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && ( + + {__('Store')} + + )} + + {showToggle && ( + + )} +
+
+ ); +} diff --git a/admin-spa/src/components/layout/Shell.tsx b/admin-spa/src/components/layout/Shell.tsx new file mode 100644 index 0000000..85c5a50 --- /dev/null +++ b/admin-spa/src/components/layout/Shell.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useFullscreen } from '@/hooks/useFullscreen'; +import { useIsDesktop } from '@/hooks/useIsDesktop'; +import { useActiveSection } from '@/hooks/useActiveSection'; +import { useShortcuts } from '@/hooks/useShortcuts'; +import { CommandPalette } from '@/components/CommandPalette'; +import { PageHeader } from '@/components/PageHeader'; +import { BottomNav } from '@/components/nav/BottomNav'; +import { FAB } from '@/components/FAB'; +import SubmenuBar from '@/components/nav/SubmenuBar'; +import DashboardSubmenuBar from '@/components/nav/DashboardSubmenuBar'; +import { AppProvider } from '@/contexts/AppContext'; +import { Header } from './Header'; +import { Sidebar } from './Sidebar'; +import { TopNav } from './TopNav'; +import { AppRoutes } from './AppRoutes'; + +function ShortcutsBinder({ onToggle }: { onToggle: () => void }) { + useShortcuts({ toggleFullscreen: onToggle }); + return null; +} + +export function Shell() { + const { on, setOn } = useFullscreen(); + const { main } = useActiveSection(); + const toggle = () => setOn(v => !v); + const exitFullscreen = () => setOn(false); + const isDesktop = useIsDesktop(); + const location = useLocation(); + const scrollContainerRef = useRef(null); + + // Sidebar collapsed state with localStorage persistence + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + try { return localStorage.getItem('wnwSidebarCollapsed') === '1'; } catch { return false; } + }); + const [wasAutoCollapsed, setWasAutoCollapsed] = useState(false); + + // Save sidebar state to localStorage + useEffect(() => { + try { localStorage.setItem('wnwSidebarCollapsed', sidebarCollapsed ? '1' : '0'); } catch { /* ignore */ } + }, [sidebarCollapsed]); + + // Check if current route is Page Editor (auto-collapse route) + const isPageEditorRoute = location.pathname === '/appearance/pages'; + + // Auto-collapse/expand sidebar based on route + useEffect(() => { + if (isPageEditorRoute) { + // Auto-collapse when entering Page Editor (if not already collapsed) + if (!sidebarCollapsed) { + setSidebarCollapsed(true); + setWasAutoCollapsed(true); + } + } else { + // Auto-expand when leaving Page Editor (only if we auto-collapsed it) + if (wasAutoCollapsed && sidebarCollapsed) { + setSidebarCollapsed(false); + setWasAutoCollapsed(false); + } + } + }, [isPageEditorRoute]); + + const toggleSidebar = () => { + setSidebarCollapsed(v => !v); + setWasAutoCollapsed(false); // Manual toggle clears auto state + }; + + // Check if standalone mode - force fullscreen and hide toggle + const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false; + const fullscreen = isStandalone ? true : on; + + // Check if current route is dashboard + const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard'); + + // Check if current route is More page (no submenu needed) + const isMorePage = location.pathname === '/more'; + + const submenuTopClass = fullscreen ? 'top-0' : 'top-[calc(7rem+32px)]'; + const submenuZIndex = fullscreen ? 'z-50' : 'z-40'; + + // Check if current route is setup/onboarding + const isSetup = location.pathname === '/setup'; + + if (isSetup) { + return ( + +
+ +
+
+ ); + } + + return ( + + {!isStandalone && } + {!isStandalone && } +
+
+ {fullscreen ? ( + isDesktop ? ( +
+ +
+ {/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */} +
+ + {isDashboardRoute ? ( + + ) : ( + + )} +
+
+ +
+
+
+ ) : ( +
+ {/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */} +
+ + {!isMorePage && (isDashboardRoute ? ( + + ) : ( + + ))} +
+
+
+ +
+
+ + +
+ ) + ) : ( +
+ + {/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */} +
+ + {isDashboardRoute ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/admin-spa/src/components/layout/Sidebar.tsx b/admin-spa/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..752151f --- /dev/null +++ b/admin-spa/src/components/layout/Sidebar.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { PanelLeft, PanelLeftClose, Package } from 'lucide-react'; +import { useActiveSection } from '@/hooks/useActiveSection'; +import { __ } from '@/lib/i18n'; +import { iconMap } from '@/lib/nav-icons'; + +interface SidebarProps { + collapsed: boolean; + onToggle: () => void; +} + +export function Sidebar({ collapsed, onToggle }: SidebarProps) { + const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all"; + const linkCollapsed = "flex items-center justify-center rounded-md p-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all"; + const active = "bg-secondary"; + const { main } = useActiveSection(); + + // Get navigation tree from backend + const navTree = window.WNW_NAV_TREE || []; + + return ( + + ); +} diff --git a/admin-spa/src/components/layout/TopNav.tsx b/admin-spa/src/components/layout/TopNav.tsx new file mode 100644 index 0000000..37d3172 --- /dev/null +++ b/admin-spa/src/components/layout/TopNav.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Package } from 'lucide-react'; +import { useActiveSection } from '@/hooks/useActiveSection'; +import { iconMap } from '@/lib/nav-icons'; + +export function TopNav({ fullscreen = false }: { fullscreen?: boolean }) { + const link = "inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0"; + const active = "bg-secondary"; + const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]'; + const { main } = useActiveSection(); + + // Get navigation tree from backend + const navTree = window.WNW_NAV_TREE || []; + + return ( +
+
+ {navTree.map((item: any) => { + const IconComponent = iconMap[item.icon] || Package; + const isActive = main.key === item.key; + return ( + + + {item.label} + + ); + })} +
+
+ ); +} diff --git a/admin-spa/src/hooks/useFABConfig.tsx b/admin-spa/src/hooks/useFABConfig.tsx index 94e2c83..c1bfb85 100644 --- a/admin-spa/src/hooks/useFABConfig.tsx +++ b/admin-spa/src/hooks/useFABConfig.tsx @@ -21,7 +21,7 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon const handleCouponsClick = useCallback(() => navigate('/coupons/new'), [navigate]); const handleDashboardClick = useCallback(() => { // TODO: Implement speed dial menu - console.log('Quick actions menu'); + // TODO: Implement speed dial menu for quick actions }, []); useEffect(() => { diff --git a/admin-spa/src/hooks/useFullscreen.ts b/admin-spa/src/hooks/useFullscreen.ts new file mode 100644 index 0000000..aa0e1a8 --- /dev/null +++ b/admin-spa/src/hooks/useFullscreen.ts @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; + +export function useFullscreen() { + const [on, setOn] = useState(() => { + try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; } + }); + + useEffect(() => { + const id = 'wnw-fullscreen-style'; + let style = document.getElementById(id); + if (!style) { + style = document.createElement('style'); + style.id = id; + style.textContent = ` + /* Hide WP admin chrome when fullscreen */ + .wnw-fullscreen #wpadminbar, + .wnw-fullscreen #adminmenumain, + .wnw-fullscreen #screen-meta, + .wnw-fullscreen #screen-meta-links, + .wnw-fullscreen #wpfooter { display:none !important; } + .wnw-fullscreen #wpcontent { margin-left:0 !important; } + .wnw-fullscreen #wpbody-content { padding-bottom:0 !important; } + .wnw-fullscreen html, .wnw-fullscreen body { height: 100%; overflow: hidden; } + .wnw-fullscreen .woonoow-fullscreen-root { + position: fixed; + inset: 0; + z-index: 999; + background: var(--background, #fff); + height: 100dvh; /* ensure full viewport height on mobile/desktop */ + overflow: hidden; /* prevent double scrollbars; inner
handles scrolling */ + overscroll-behavior: contain; + display: flex; + flex-direction: column; + contain: layout paint size; /* prevent WP wrappers from affecting layout */ + } + `; + document.head.appendChild(style); + } + document.body.classList.toggle('wnw-fullscreen', on); + try { localStorage.setItem('wnwFullscreen', on ? '1' : '0'); } catch { /* ignore localStorage errors */ } + return () => { /* do not remove style to avoid flicker between reloads */ }; + }, [on]); + + return { on, setOn } as const; +} diff --git a/admin-spa/src/hooks/useIsDesktop.ts b/admin-spa/src/hooks/useIsDesktop.ts new file mode 100644 index 0000000..b307a4c --- /dev/null +++ b/admin-spa/src/hooks/useIsDesktop.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useIsDesktop(minWidth = 1024) { // lg breakpoint + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia(`(min-width: ${minWidth}px)`).matches; + }); + + useEffect(() => { + const mq = window.matchMedia(`(min-width: ${minWidth}px)`); + const onChange = () => setIsDesktop(mq.matches); + try { mq.addEventListener('change', onChange); } catch { mq.addListener(onChange); } + return () => { try { mq.removeEventListener('change', onChange); } catch { mq.removeListener(onChange); } }; + }, [minWidth]); + + return isDesktop; +} diff --git a/admin-spa/src/hooks/useShortcuts.tsx b/admin-spa/src/hooks/useShortcuts.tsx index b199ac8..cf775e2 100644 --- a/admin-spa/src/hooks/useShortcuts.tsx +++ b/admin-spa/src/hooks/useShortcuts.tsx @@ -47,6 +47,13 @@ export function useShortcuts({ toggleFullscreen }: { toggleFullscreen?: () => vo return; } + // Global Save: Ctrl/Cmd + S + if (mod && key === "s") { + e.preventDefault(); + window.dispatchEvent(new CustomEvent('woonoow:shortcut:save')); + return; + } + // Fullscreen toggle: Ctrl/Cmd + Shift + F if (mod && e.shiftKey && key === "f") { e.preventDefault(); diff --git a/admin-spa/src/hooks/useUnsavedChanges.ts b/admin-spa/src/hooks/useUnsavedChanges.ts new file mode 100644 index 0000000..f47080f --- /dev/null +++ b/admin-spa/src/hooks/useUnsavedChanges.ts @@ -0,0 +1,52 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useBlocker } from 'react-router-dom'; + +export function useUnsavedChanges(isDirty: boolean) { + const [showPrompt, setShowPrompt] = useState(false); + + // React Router v6 blocker + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname + ); + + // Handle browser back/refresh + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + e.returnValue = ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isDirty]); + + // Sync blocker state with our prompt state + useEffect(() => { + if (blocker.state === 'blocked') { + setShowPrompt(true); + } + }, [blocker.state]); + + const confirmNavigation = useCallback(() => { + if (blocker.state === 'blocked') { + blocker.proceed(); + } + setShowPrompt(false); + }, [blocker]); + + const cancelNavigation = useCallback(() => { + if (blocker.state === 'blocked') { + blocker.reset(); + } + setShowPrompt(false); + }, [blocker]); + + return { + showPrompt, + confirmNavigation, + cancelNavigation + }; +} diff --git a/admin-spa/src/lib/api.ts b/admin-spa/src/lib/api.ts index 9f5dace..86c0747 100644 --- a/admin-spa/src/lib/api.ts +++ b/admin-spa/src/lib/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { 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) { + async get(path: string, params?: Record): Promise { 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(path + (qs ? `?${qs}` : '')); }, - async post(path: string, body?: any) { - return api.wpFetch(path, { + async post(path: string, body?: any): Promise { + return api.wpFetch(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(path: string, body?: any): Promise { + return api.wpFetch(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(path: string): Promise { + return api.wpFetch(path, { method: 'DELETE' }); }, }; -export type CreateOrderPayload = { - items: { product_id: number; qty: number }[]; - billing?: Record; - shipping?: Record; - status?: string; - payment_method?: string; -}; - -export const OrdersApi = { - list: (params?: Record) => 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) || []; } } \ No newline at end of file diff --git a/admin-spa/src/lib/api/client.ts b/admin-spa/src/lib/api/client.ts new file mode 100644 index 0000000..5025cac --- /dev/null +++ b/admin-spa/src/lib/api/client.ts @@ -0,0 +1,2 @@ +import { api } from '../api'; +export const apiClient = api; diff --git a/admin-spa/src/lib/api/coupons.ts b/admin-spa/src/lib/api/coupons.ts index 95ff308..10ca492 100644 --- a/admin-spa/src/lib/api/coupons.ts +++ b/admin-spa/src/lib/api/coupons.ts @@ -62,7 +62,7 @@ export const CouponsApi = { search?: string; discount_type?: string; }): Promise => { - return api.get('/coupons', { params }); + return api.get('/coupons', params); }, /** diff --git a/admin-spa/src/lib/api/customers.ts b/admin-spa/src/lib/api/customers.ts index 353e920..e415fb8 100644 --- a/admin-spa/src/lib/api/customers.ts +++ b/admin-spa/src/lib/api/customers.ts @@ -69,7 +69,7 @@ export const CustomersApi = { search?: string; role?: string; }): Promise => { - return api.get('/customers', { params }); + return api.get('/customers', params); }, /** @@ -104,6 +104,6 @@ export const CustomersApi = { * Search customers (for autocomplete) */ search: async (query: string, limit?: number): Promise => { - return api.get('/customers/search', { params: { q: query, limit } }); + return api.get('/customers/search', { q: query, limit }); }, }; diff --git a/admin-spa/src/lib/api/orders.ts b/admin-spa/src/lib/api/orders.ts new file mode 100644 index 0000000..8cb0234 --- /dev/null +++ b/admin-spa/src/lib/api/orders.ts @@ -0,0 +1,23 @@ +import { api } from '../api'; + +export type CreateOrderPayload = { + items: { product_id: number; qty: number }[]; + billing?: Record; + shipping?: Record; + status?: string; + payment_method?: string; +}; + +export const OrdersApi = { + list: (params?: Record) => 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'), +}; diff --git a/admin-spa/src/lib/api/products.ts b/admin-spa/src/lib/api/products.ts new file mode 100644 index 0000000..d109300 --- /dev/null +++ b/admin-spa/src/lib/api/products.ts @@ -0,0 +1,12 @@ +import { api } from '../api'; + +export const ProductsApi = { + search: (search: string, limit = 10) => api.get('/products/search', { search, limit }), + list: (params?: { page?: number; per_page?: number; search?: string; status?: string; category?: string; sort?: string }) => + api.get('/products', params), + get: (id: number) => api.get(`/products/${id}`), + create: (data: any) => api.post('/products', data), + update: (id: number, data: any) => api.put(`/products/${id}`, data), + delete: (id: number, force: boolean = false) => api.del(`/products/${id}?force=${force ? 'true' : 'false'}`), + categories: () => api.get('/products/categories'), +}; diff --git a/admin-spa/src/lib/cart/store.ts b/admin-spa/src/lib/cart/store.ts new file mode 100644 index 0000000..1274245 --- /dev/null +++ b/admin-spa/src/lib/cart/store.ts @@ -0,0 +1 @@ +export const useCartStore = () => ({ addToCart: () => {}, isAdding: false }); diff --git a/admin-spa/src/lib/currency.ts b/admin-spa/src/lib/currency.ts index 54579a8..54591ba 100644 --- a/admin-spa/src/lib/currency.ts +++ b/admin-spa/src/lib/currency.ts @@ -97,6 +97,8 @@ export function formatMoney(value: MoneyInput, opts: MoneyOptions = {}): string } } +export const formatPrice = formatMoney; + export function makeMoneyFormatter(opts: MoneyOptions) { const store = getStoreCurrency(); const currency = opts.currency || store.currency || 'USD'; diff --git a/admin-spa/src/lib/nav-icons.ts b/admin-spa/src/lib/nav-icons.ts new file mode 100644 index 0000000..1f3f74e --- /dev/null +++ b/admin-spa/src/lib/nav-icons.ts @@ -0,0 +1,26 @@ +import { + LayoutDashboard, + ReceiptText, + Package, + Tag, + Users, + Settings as SettingsIcon, + Palette, + Mail, + HelpCircle, + Repeat +} from 'lucide-react'; +import { ElementType } from 'react'; + +export const iconMap: Record = { + 'layout-dashboard': LayoutDashboard, + 'receipt-text': ReceiptText, + 'package': Package, + 'tag': Tag, + 'users': Users, + 'mail': Mail, + 'palette': Palette, + 'settings': SettingsIcon, + 'help-circle': HelpCircle, + 'repeat': Repeat, +}; diff --git a/admin-spa/src/lib/sectionStyles.ts b/admin-spa/src/lib/sectionStyles.ts new file mode 100644 index 0000000..37515cd --- /dev/null +++ b/admin-spa/src/lib/sectionStyles.ts @@ -0,0 +1,85 @@ +export interface SectionStyleResult { + classNames?: string; + style?: React.CSSProperties; + hasOverlay?: boolean; + overlayOpacity?: number; + backgroundImage?: string; +} + +/** + * Shared utility to compute background styles from section styles. + * Used by all customer SPA section components. + */ +export function getSectionBackground(styles?: Record): SectionStyleResult { + if (!styles) { + return { style: {}, hasOverlay: false, overlayOpacity: 0 }; + } + + const bgType = styles.backgroundType || 'solid'; + const style: React.CSSProperties = {}; + let hasOverlay = false; + let overlayOpacity = 0; + let backgroundImage: string | undefined; + + if (styles.paddingTop) { + style.paddingTop = styles.paddingTop; + } + + if (styles.paddingBottom) { + style.paddingBottom = styles.paddingBottom; + } + + switch (bgType) { + case 'gradient': + style.background = `linear-gradient(${styles.gradientAngle ?? 135}deg, ${styles.gradientFrom || '#9333ea'}, ${styles.gradientTo || '#3b82f6'})`; + break; + case 'image': + if (styles.backgroundImage) { + backgroundImage = styles.backgroundImage; + overlayOpacity = (styles.backgroundOverlay || 0) / 100; + hasOverlay = overlayOpacity > 0; + } + break; + case 'solid': + default: + if (styles.backgroundColor) { + style.backgroundColor = styles.backgroundColor; + } + // Legacy: if backgroundImage exists without explicit type + if (!styles.backgroundType && styles.backgroundImage) { + backgroundImage = styles.backgroundImage; + overlayOpacity = (styles.backgroundOverlay || 0) / 100; + hasOverlay = overlayOpacity > 0; + } + break; + } + + 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'; +} diff --git a/admin-spa/src/lib/windowAPI.ts b/admin-spa/src/lib/windowAPI.ts index 75852e7..e4b0f28 100644 --- a/admin-spa/src/lib/windowAPI.ts +++ b/admin-spa/src/lib/windowAPI.ts @@ -196,5 +196,7 @@ export function initializeWindowAPI() { // Expose to window (window as any).WooNooW = windowAPI; - console.log('✅ WooNooW API initialized for addon developers'); + if ((window as any).WNW_API?.isDev) { + console.log('✅ WooNooW API initialized for addon developers'); + } } diff --git a/admin-spa/src/routes/Appearance/Footer.tsx b/admin-spa/src/routes/Appearance/Footer.tsx index 2a4c933..a32e2c7 100644 --- a/admin-spa/src/routes/Appearance/Footer.tsx +++ b/admin-spa/src/routes/Appearance/Footer.tsx @@ -158,7 +158,7 @@ export default function AppearanceFooter() { })); } } catch (err) { - console.log('Store identity not available'); + // Store identity endpoint is optional — silently ignore } } catch (error) { console.error('Failed to load settings:', error); diff --git a/admin-spa/src/routes/Appearance/General.tsx b/admin-spa/src/routes/Appearance/General.tsx index 2a1b73e..ae40a6e 100644 --- a/admin-spa/src/routes/Appearance/General.tsx +++ b/admin-spa/src/routes/Appearance/General.tsx @@ -90,16 +90,13 @@ export default function AppearanceGeneral() { // Load available pages const pagesResponse = await api.get('/pages/list'); - console.log('Pages API response:', pagesResponse); if (pagesResponse.data) { - console.log('Pages loaded:', pagesResponse.data); setAvailablePages(pagesResponse.data); } else { console.warn('No pages data in response:', pagesResponse); } } catch (error) { - console.error('Failed to load settings:', error); - console.error('Error details:', error); + // Error is non-critical — pages list may not be available } finally { setLoading(false); } diff --git a/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx b/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx index b878b4e..ac1fe63 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx @@ -15,7 +15,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { cn } from '@/lib/utils'; -import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react'; +import { Plus, Monitor, Smartphone, LayoutTemplate, GalleryHorizontalEnd, LayoutGrid, Pointer, ScanText } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -23,16 +23,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; - import { CanvasSection } from './CanvasSection'; -import { - HeroRenderer, - ContentRenderer, - ImageTextRenderer, - FeatureGridRenderer, - CTABannerRenderer, - ContactFormRenderer, -} from './section-renderers'; + +import { HeroSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/HeroSection'; +import { ContentSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ContentSection'; +import { ImageTextSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ImageTextSection'; +import { FeatureGridSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/FeatureGridSection'; +import { CTABannerSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/CTABannerSection'; +import { ContactFormSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ContactFormSection'; +import { BentoCategoryGrid } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/BentoCategoryGrid'; +import { ProductCarousel } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ProductCarousel'; +import { ShoppableImage } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ShoppableImage'; +import { MarqueeBanner } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/MarqueeBanner'; +import { normalizeFeatureGridProps, SECTION_SCHEMA_LIST } from '../schema/sectionSchema'; interface Section { id: string; @@ -40,10 +43,13 @@ interface Section { layoutVariant?: string; colorScheme?: string; props: Record; + elementStyles?: Record; + styles?: any; } interface CanvasRendererProps { sections: Section[]; + previewSections?: Section[]; selectedSectionId: string | null; deviceMode: 'desktop' | 'mobile'; onSelectSection: (id: string | null) => void; @@ -58,26 +64,72 @@ interface CanvasRendererProps { } const SECTION_TYPES = [ - { type: 'hero', label: 'Hero', icon: LayoutTemplate }, - { type: 'content', label: 'Content', icon: LayoutTemplate }, - { type: 'image-text', label: 'Image + Text', icon: LayoutTemplate }, - { type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate }, - { type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate }, - { type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate }, -]; + { type: 'hero', icon: LayoutTemplate }, + { type: 'content', icon: LayoutTemplate }, + { type: 'image-text', icon: LayoutTemplate }, + { type: 'feature-grid', icon: LayoutTemplate }, + { type: 'cta-banner', icon: LayoutTemplate }, + { type: 'contact-form', icon: LayoutTemplate }, + { type: 'bento-category-grid', icon: LayoutGrid }, + { type: 'product-carousel', icon: GalleryHorizontalEnd }, + { type: 'shoppable-image', icon: Pointer }, + { type: 'marquee-banner', icon: ScanText }, +].map((item) => ({ + ...item, + label: SECTION_SCHEMA_LIST.find((schema) => schema.type === item.type)?.label || item.type, +})); -// Map section type to renderer component +function flattenSectionProps(section: Section): Record { + const flattened: Record = {}; + const props = section.type === 'feature-grid' + ? normalizeFeatureGridProps(section.props || {}) + : section.props || {}; + + for (const [key, value] of Object.entries(props)) { + if (value && typeof value === 'object' && 'type' in value && 'value' in value) { + flattened[key] = value.value; + } else if (value && typeof value === 'object' && 'type' in value && 'source' in value) { + flattened[key] = `[${value.source}]`; + } else { + flattened[key] = value; + } + } + return flattened; +} + +function withSectionWrapper(Component: any) { + return function SectionWrapper({ section, className }: { section: Section; className?: string }) { + const flatProps = flattenSectionProps(section); + return ( + + ); + } +} + +// Map section type to exact customer-spa components via HOC adapter const SECTION_RENDERERS: Record> = { - 'hero': HeroRenderer, - 'content': ContentRenderer, - 'image-text': ImageTextRenderer, - 'feature-grid': FeatureGridRenderer, - 'cta-banner': CTABannerRenderer, - 'contact-form': ContactFormRenderer, + 'hero': withSectionWrapper(HeroSection), + 'content': withSectionWrapper(ContentSection), + 'image-text': withSectionWrapper(ImageTextSection), + 'feature-grid': withSectionWrapper(FeatureGridSection), + 'cta-banner': withSectionWrapper(CTABannerSection), + 'contact-form': withSectionWrapper(ContactFormSection), + 'bento-category-grid': withSectionWrapper(BentoCategoryGrid), + 'product-carousel': withSectionWrapper(ProductCarousel), + 'shoppable-image': withSectionWrapper(ShoppableImage), + 'marquee-banner': withSectionWrapper(MarqueeBanner), }; export function CanvasRenderer({ sections, + previewSections, selectedSectionId, deviceMode, onSelectSection, @@ -91,6 +143,7 @@ export function CanvasRenderer({ containerWidth = 'default', }: CanvasRendererProps) { const [hoveredSectionId, setHoveredSectionId] = useState(null); + const previewSectionsById = new Map((previewSections || []).map((section) => [section.id, section])); const sensors = useSensors( useSensor(PointerSensor, { @@ -145,17 +198,19 @@ export function CanvasRenderer({
- {/* Canvas viewport */}
{sections.length === 0 ? ( @@ -188,9 +243,7 @@ export function CanvasRenderer({ {/* Top Insertion Zone */} onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index. - // Actually onAddSection in Props is (type) => void. I need to update Props too. - // Let's check props interface above. + onAdd={(type) => onAddSection(type, 0)} /> {sections.map((section, index) => { const Renderer = SECTION_RENDERERS[section.type]; + const renderSection = previewSectionsById.get(section.id) || section; return ( @@ -223,7 +277,7 @@ export function CanvasRenderer({ canMoveDown={index < sections.length - 1} > {Renderer ? ( - + ) : (
Unknown section type: {section.type} diff --git a/admin-spa/src/routes/Appearance/Pages/components/InspectorPanel.tsx b/admin-spa/src/routes/Appearance/Pages/components/InspectorPanel.tsx index d9a6aa8..4d7178a 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/InspectorPanel.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/InspectorPanel.tsx @@ -33,6 +33,7 @@ import { InspectorField, SectionProp } from './InspectorField'; import { InspectorRepeater } from './InspectorRepeater'; import { MediaUploader } from '@/components/MediaUploader'; import { SectionStyles, ElementStyle, PageItem } from '../store/usePageEditorStore'; +import { SECTION_SCHEMAS } from '../schema/sectionSchema'; interface Section { id: string; @@ -64,64 +65,15 @@ interface InspectorPanelProps { onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void; } -// Section field configurations -const SECTION_FIELDS: Record = { - hero: [ - { name: 'title', label: 'Title', type: 'text', dynamic: true }, - { name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true }, - { name: 'image', label: 'Image URL', type: 'url', dynamic: true }, - { name: 'cta_text', label: 'Button Text', type: 'text' }, - { name: 'cta_url', label: 'Button URL', type: 'url' }, - ], - content: [ - { name: 'content', label: 'Content', type: 'rte', dynamic: true }, - { name: 'cta_text', label: 'Button Text', type: 'text' }, - { name: 'cta_url', label: 'Button URL', type: 'url' }, - ], - 'image-text': [ - { name: 'title', label: 'Title', type: 'text', dynamic: true }, - { name: 'text', label: 'Text', type: 'textarea', dynamic: true }, - { name: 'image', label: 'Image URL', type: 'url', dynamic: true }, - { name: 'cta_text', label: 'Button Text', type: 'text' }, - { name: 'cta_url', label: 'Button URL', type: 'url' }, - ], - 'feature-grid': [ - { name: 'heading', label: 'Heading', type: 'text' }, - ], - 'cta-banner': [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'text', label: 'Description', type: 'text' }, - { name: 'button_text', label: 'Button Text', type: 'text' }, - { name: 'button_url', label: 'Button URL', type: 'url' }, - ], - 'contact-form': [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'webhook_url', label: 'Webhook URL', type: 'url' }, - { name: 'redirect_url', label: 'Redirect URL', type: 'url' }, - ], -}; +const SECTION_FIELDS = Object.fromEntries( + Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.fields]) +); -const LAYOUT_OPTIONS: Record = { - hero: [ - { value: 'default', label: 'Centered' }, - { value: 'hero-left-image', label: 'Image Left' }, - { value: 'hero-right-image', label: 'Image Right' }, - ], - 'image-text': [ - { value: 'image-left', label: 'Image Left' }, - { value: 'image-right', label: 'Image Right' }, - ], - 'feature-grid': [ - { value: 'grid-2', label: '2 Columns' }, - { value: 'grid-3', label: '3 Columns' }, - { value: 'grid-4', label: '4 Columns' }, - ], - content: [ - { value: 'default', label: 'Full Width' }, - { value: 'narrow', label: 'Narrow' }, - { value: 'medium', label: 'Medium' }, - ], -}; +const LAYOUT_OPTIONS = Object.fromEntries( + Object.entries(SECTION_SCHEMAS) + .filter(([, schema]) => !!schema.layouts) + .map(([type, schema]) => [type, schema.layouts || []]) +); const COLOR_SCHEMES = [ { value: 'default', label: 'Default' }, @@ -130,42 +82,9 @@ const COLOR_SCHEMES = [ { value: 'muted', label: 'Muted' }, ]; -const STYLABLE_ELEMENTS: Record = { - hero: [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'subtitle', label: 'Subtitle', type: 'text' }, - { name: 'image', label: 'Image', type: 'image' }, - { name: 'cta_text', label: 'Button', type: 'text' }, - ], - content: [ - { name: 'heading', label: 'Headings', type: 'text' }, - { name: 'text', label: 'Body Text', type: 'text' }, - { name: 'link', label: 'Links', type: 'text' }, - { name: 'image', label: 'Images', type: 'image' }, - { name: 'button', label: 'Button', type: 'text' }, - { name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style - ], - 'image-text': [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'text', label: 'Text', type: 'text' }, - { name: 'image', label: 'Image', type: 'image' }, - { name: 'button', label: 'Button', type: 'text' }, - ], - 'feature-grid': [ - { name: 'heading', label: 'Heading', type: 'text' }, - { name: 'feature_item', label: 'Feature Item (Card)', type: 'text' }, - ], - 'cta-banner': [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'text', label: 'Description', type: 'text' }, - { name: 'button_text', label: 'Button', type: 'text' }, - ], - 'contact-form': [ - { name: 'title', label: 'Title', type: 'text' }, - { name: 'button', label: 'Button', type: 'text' }, - { name: 'fields', label: 'Input Fields', type: 'text' }, - ], -}; +const STYLABLE_ELEMENTS = Object.fromEntries( + Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.stylableElements || []]) +); export function InspectorPanel({ page, @@ -454,31 +373,81 @@ export function InspectorPanel({ {/* Feature Grid Repeater */} {selectedSection.type === 'feature-grid' && (() => { - const featuresProp = selectedSection.props.features; - const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source; - const items = Array.isArray(featuresProp?.value) ? featuresProp.value : []; + const itemsProp = selectedSection.props.items || selectedSection.props.features; + const isDynamicItems = itemsProp?.type === 'dynamic' && !!itemsProp?.source; + const items = Array.isArray(itemsProp?.value) ? itemsProp.value : []; return (
onSectionPropChange('features', { type: 'static', value: newItems })} + onChange={(newItems) => onSectionPropChange('items', { 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} + isDynamic={isDynamicItems} dynamicLabel={ - isDynamicFeatures - ? `⚡ Auto-populated from "${featuresProp.source}" at runtime` + isDynamicItems + ? `Auto-populated from "${itemsProp.source}" at runtime` : undefined } />
); })()} + + {/* Bento Category Grid Repeater */} + {selectedSection.type === 'bento-category-grid' && (() => { + const itemsProp = selectedSection.props.items; + const items = Array.isArray(itemsProp?.value) ? itemsProp.value : []; + return ( +
+ onSectionPropChange('items', { type: 'static', value: newItems })} + fields={[ + { name: 'label', label: 'Label', type: 'text' }, + { name: 'image', label: 'Image', type: 'image' }, + { name: 'url', label: 'Link URL', type: 'text' }, + { name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' }, + ]} + itemLabelKey="label" + /> +
+ ); + })()} + + {/* Shoppable Image Hotspots Repeater */} + {selectedSection.type === 'shoppable-image' && (() => { + const hotspotsProp = selectedSection.props.hotspots; + const hotspots = Array.isArray(hotspotsProp?.value) ? hotspotsProp.value : []; + return ( +
+ onSectionPropChange('hotspots', { type: 'static', value: newItems })} + fields={[ + { name: 'product_slug', label: 'Product', type: 'product' }, + // Allow advanced override/editing of asset/data if needed + { name: 'product_name', label: 'Product Name', type: 'text' }, + { name: 'product_price', label: 'Price', type: 'text' }, + { name: 'product_image', label: 'Product Image URL', type: 'text' }, + { name: 'x', label: 'X Position (%)', type: 'text' }, + { name: 'y', label: 'Y Position (%)', type: 'text' }, + ]} + itemLabelKey="product_name" + /> +

+ X and Y are percentages (0-100) from the top-left of the image. +

+
+ ); + })()} {/* Design Tab */} diff --git a/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx b/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx index 3ba2703..9e5d7d4 100644 --- a/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx +++ b/admin-spa/src/routes/Appearance/Pages/components/InspectorRepeater.tsx @@ -4,241 +4,365 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, } from '@/components/ui/accordion'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@/components/ui/select'; import { Plus, Trash2, GripVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, } from '@dnd-kit/core'; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, } from '@dnd-kit/sortable'; +import { MediaUploader } from '@/components/MediaUploader'; +import RepeaterProductField from './RepeaterProductField'; interface RepeaterFieldDef { - name: string; - label: string; - type: 'text' | 'textarea' | 'url' | 'image' | 'icon'; - placeholder?: string; + name: string; + label: string; + type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product'; + placeholder?: string; } interface InspectorRepeaterProps { - label: string; - items: any[]; - 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 + label: string; + items: any[]; + 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 -function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - } = useSortable({ id }); +function SortableItem({ + id, + item, + index, + fields, + itemLabelKey, + onChange, + onDelete, +}: any) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ id }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; - // List of available icons for selection - const ICON_OPTIONS = [ - 'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings', - 'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase', - 'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database', - 'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image', - 'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle', - 'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power', - 'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart', - 'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool', - 'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2', - 'Wifi', 'Wrench' - ].sort(); + // List of available icons for selection + const ICON_OPTIONS = [ + 'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings', + 'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase', + 'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database', + 'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image', + 'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle', + 'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power', + 'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart', + 'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool', + 'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2', + 'Wifi', 'Wrench', + ].sort(); - return ( -
- -
- - - {item[itemLabelKey] || `Item ${index + 1}`} - - -
- - {fields.map((field: RepeaterFieldDef) => ( -
- - {field.type === 'textarea' ? ( -