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

Major improvements to WooNooW Page Editor system:

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

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

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

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

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

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

View File

@@ -1 +0,0 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };

View File

@@ -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<boolean>(() => {
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 <main> 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 (
<NavLink
to={to}
end={end}
className={(nav) => {
// 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}
</NavLink>
);
}
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<string, any> = {
'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 (
<aside className={`flex-shrink-0 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background flex flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
{/* Toggle button */}
<div className={`p-2 border-b border-border ${collapsed ? 'flex justify-center' : 'flex justify-end'}`}>
<button
onClick={onToggle}
className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
title={collapsed ? __('Expand sidebar') : __('Collapse sidebar')}
>
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className={`flex flex-col gap-1 flex-1 ${collapsed ? 'p-1' : 'p-3'}`}>
{navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package;
const isActive = main.key === item.key;
return (
<Link
key={item.key}
to={item.path}
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
title={collapsed ? item.label : undefined}
>
<IconComponent className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
</aside>
);
}
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<string, any> = {
'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 (
<div data-mainmenu className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
{navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package;
const isActive = main.key === item.key;
return (
<Link
key={item.key}
to={item.path}
className={`${link} ${isActive ? active : ''}`}
>
<IconComponent className="w-4 h-4" />
<span className="text-sm font-medium">{item.label}</span>
</Link>
);
})}
</div>
</div>
);
}
function useIsDesktop(minWidth = 1024) { // lg breakpoint
const [isDesktop, setIsDesktop] = useState<boolean>(() => {
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<any>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 opacity-50" />
<p className="text-sm opacity-70">{__('Loading addon...')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<h3 className="font-semibold text-red-900 mb-2">{__('Failed to Load Addon')}</h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
);
}
if (!Component) {
return (
<div className="p-6">
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="text-sm text-yellow-700">{__('Addon component not found')}</p>
</div>
</div>
);
}
// Render the addon component with props
return <Component {...(config.props || {})} />;
}
function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRef, onVisibilityChange }: { onFullscreen: () => void; fullscreen: boolean; showToggle?: boolean; scrollContainerRef?: React.RefObject<HTMLDivElement>; 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 (
<header className={`h-16 border-b border-border flex items-center px-4 justify-between sticky ${fullscreen ? `top-0` : `top-[32px]`} z-40 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 transition-transform duration-300 ${fullscreen && !isVisible ? '-translate-y-full md:translate-y-0' : 'translate-y-0'}`}>
<div className="flex items-center gap-3">
{currentLogo ? (
<img src={currentLogo} alt={siteTitle} className="h-8 object-contain" />
) : (
<div className="font-semibold">{siteTitle}</div>
)}
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
title={__('Visit Store')}
>
<ExternalLink className="w-4 h-4" />
<span className="hidden sm:inline">{__('Store')}</span>
</a>
</div>
<div className="flex items-center gap-3">
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
{isStandalone && (
<>
<a
href={window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Go to WordPress Admin"
>
<span>{__('WordPress')}</span>
</a>
{window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<button
onClick={handleLogout}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Logout"
>
<span>{__('Logout')}</span>
</button>
</>
)}
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<ThemeToggle />
{showToggle && (
<button
onClick={onFullscreen}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
<span className="hidden sm:inline">{fullscreen ? 'Exit' : 'Fullscreen'}</span>
</button>
)}
</div>
</header>
<Toaster
richColors
theme={actualTheme as 'light' | 'dark' | 'system'}
position="bottom-right"
closeButton
visibleToasts={3}
duration={4000}
offset="20px"
/>
);
}
const qc = new QueryClient();
function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
useShortcuts({ toggleFullscreen: onToggle });
return null;
}
// Centralized route controller so we don't duplicate <Routes> in each layout
function AppRoutes() {
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
return (
<Routes>
{/* Dashboard */}
<Route path="/" element={<Navigate to={(window as any).WNW_CONFIG?.onboardingCompleted ? "/dashboard" : "/setup"} replace />} />
<Route path="/setup" element={<Onboarding />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
<Route path="/dashboard/orders" element={<DashboardOrders />} />
<Route path="/dashboard/products" element={<DashboardProducts />} />
<Route path="/dashboard/customers" element={<DashboardCustomers />} />
<Route path="/dashboard/coupons" element={<DashboardCoupons />} />
<Route path="/dashboard/taxes" element={<DashboardTaxes />} />
{/* Products */}
<Route path="/products" element={<ProductsIndex />} />
<Route path="/products/new" element={<ProductNew />} />
<Route path="/products/:id/edit" element={<ProductEdit />} />
<Route path="/products/categories" element={<ProductCategories />} />
<Route path="/products/tags" element={<ProductTags />} />
<Route path="/products/attributes" element={<ProductAttributes />} />
<Route path="/products/licenses" element={<Licenses />} />
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
<Route path="/products/software" element={<SoftwareVersions />} />
{/* Orders */}
<Route path="/orders" element={<OrdersIndex />} />
<Route path="/orders/new" element={<OrderNew />} />
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/:id/edit" element={<OrderEdit />} />
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
<Route path="/orders/:id/label" element={<OrderLabel />} />
{/* Subscriptions */}
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
{/* Coupons (under Marketing) */}
<Route path="/coupons" element={<CouponsIndex />} />
<Route path="/coupons/new" element={<CouponNew />} />
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
<Route path="/marketing/coupons" element={<CouponsIndex />} />
<Route path="/marketing/coupons/new" element={<CouponNew />} />
<Route path="/marketing/coupons/:id/edit" element={<CouponEdit />} />
{/* Customers */}
<Route path="/customers" element={<CustomersIndex />} />
<Route path="/customers/new" element={<CustomerNew />} />
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
<Route path="/customers/:id" element={<CustomerDetail />} />
{/* More */}
<Route path="/more" element={<MorePage />} />
{/* Settings */}
<Route path="/settings" element={<SettingsIndex />} />
<Route path="/settings/store" element={<SettingsStore />} />
<Route path="/settings/payments" element={<SettingsPayments />} />
<Route path="/settings/shipping" element={<SettingsShipping />} />
<Route path="/settings/tax" element={<SettingsTax />} />
<Route path="/settings/customers" element={<SettingsCustomers />} />
<Route path="/settings/security" element={<SettingsSecurity />} />
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
<Route path="/settings/checkout" element={<SettingsIndex />} />
<Route path="/settings/notifications" element={<SettingsNotifications />} />
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
<Route path="/settings/notifications/channels" element={<ChannelConfiguration />} />
<Route path="/settings/notifications/channels/email" element={<EmailConfiguration />} />
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
<Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} />
<Route path="/settings/modules" element={<SettingsModules />} />
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
{/* Appearance */}
<Route path="/appearance" element={<AppearanceIndex />} />
<Route path="/appearance/general" element={<AppearanceGeneral />} />
<Route path="/appearance/header" element={<AppearanceHeader />} />
<Route path="/appearance/footer" element={<AppearanceFooter />} />
<Route path="/appearance/shop" element={<AppearanceShop />} />
<Route path="/appearance/product" element={<AppearanceProduct />} />
<Route path="/appearance/cart" element={<AppearanceCart />} />
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/menus" element={<AppearanceMenus />} />
<Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
<Route index element={<Navigate to="subscribers" replace />} />
<Route path="subscribers" element={<NewsletterSubscribers />} />
<Route path="campaigns" element={<NewsletterCampaignsList />} />
<Route path="campaigns/:id" element={<CampaignEdit />} />
</Route>
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
{/* Help - Main menu route with no submenu */}
<Route path="/help" element={<Help />} />
{/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => (
<Route
key={route.path}
path={route.path}
element={<AddonRoute config={route} />}
/>
))}
</Routes>
);
}
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<HTMLDivElement>(null);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
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 (
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
<div className="min-h-screen bg-background text-foreground flex flex-col">
<AppRoutes />
</div>
</AppProvider>
);
}
return (
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
{!isStandalone && <ShortcutsBinder onToggle={toggle} />}
{!isStandalone && <CommandPalette toggleFullscreen={toggle} />}
<div className={`flex flex-col min-h-screen ${fullscreen ? 'woonoow-fullscreen-root' : ''}`}>
<Header onFullscreen={toggle} fullscreen={fullscreen} showToggle={!isStandalone} scrollContainerRef={scrollContainerRef} />
{fullscreen ? (
isDesktop ? (
<div className="flex flex-1 min-h-0">
<Sidebar collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
<main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
<div className="flex flex-col-reverse">
<PageHeader fullscreen={true} />
{isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={true} />
) : (
<SubmenuBar items={main.children} fullscreen={true} />
)}
</div>
<div className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
</div>
) : (
<div className="flex flex-1 flex-col min-h-0">
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
<PageHeader fullscreen={true} />
{!isMorePage && (isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={true} />
) : (
<SubmenuBar items={main.children} fullscreen={true} />
))}
</div>
<main className="flex-1 flex flex-col min-h-0 min-w-0 pb-14">
<div ref={scrollContainerRef} className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
<BottomNav />
<FAB />
</div>
)
) : (
<div className="flex flex-1 flex-col min-h-0">
<TopNav />
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
<PageHeader fullscreen={false} />
{isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={false} />
) : (
<SubmenuBar items={main.children} fullscreen={false} />
)}
</div>
<main className="flex-1 flex flex-col min-h-0 min-w-0">
<div className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
</div>
)}
</div>
</AppProvider>
);
}
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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
</div>
);
}
if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') {
return <Navigate to="/login" replace />;
}
if (location.pathname === '/login' && isAuthenticated) {
return <Navigate to="/" replace />;
}
return (
<FABProvider>
<PageHeaderProvider>
<DashboardProvider>
<Shell />
</DashboardProvider>
</PageHeaderProvider>
</FABProvider>
);
}
const router = createHashRouter(
createRoutesFromElements(
<>
{window.WNW_CONFIG?.standaloneMode && (
<Route path="/login" element={<Login />} />
)}
<Route path="/*" element={<AuthWrapper />} />
</>
)
);
export default function App() {
// Initialize Window API for addon developers
@@ -893,23 +43,8 @@ export default function App() {
return (
<QueryClientProvider client={qc}>
<HashRouter>
<Routes>
{window.WNW_CONFIG?.standaloneMode && (
<Route path="/login" element={<Login />} />
)}
<Route path="/*" element={<AuthWrapper />} />
</Routes>
<Toaster
richColors
theme="light"
position="bottom-right"
closeButton
visibleToasts={3}
duration={4000}
offset="20px"
/>
</HashRouter>
<RouterProvider router={router} />
<ToasterWithTheme />
</QueryClientProvider>
);
}

View File

@@ -19,18 +19,18 @@ export function ErrorCard({
}: ErrorCardProps) {
return (
<div className="flex items-center justify-center p-8">
<div className="max-w-md w-full bg-red-50 border border-red-200 rounded-lg p-6">
<div className="max-w-md w-full bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-medium text-red-900">{title}</h3>
<h3 className="font-medium text-red-900 dark:text-red-200">{title}</h3>
{message && (
<p className="text-sm text-red-700 mt-1">{message}</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{message}</p>
)}
{onRetry && (
<button
onClick={onRetry}
className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-red-900 hover:text-red-700 transition-colors"
className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-red-900 dark:text-red-200 hover:text-red-700 dark:hover:text-red-100 transition-colors"
>
<RefreshCw className="w-4 h-4" />
{__('Try again')}
@@ -48,7 +48,7 @@ export function ErrorCard({
*/
export function ErrorMessage({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md p-3">
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md p-3">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>{message}</span>
</div>

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { __ } from '@/lib/i18n';
interface PaginationProps {
page: number;
perPage: number;
total: number;
onPageChange: (newPage: number) => void;
className?: string;
}
export function Pagination({ page, perPage, total, onPageChange, className = '' }: PaginationProps) {
if (total <= perPage) return null;
const startItem = ((page - 1) * perPage) + 1;
const endItem = Math.min(page * perPage, total);
const totalPages = Math.ceil(total / perPage);
return (
<div className={`flex flex-col sm:flex-row justify-between items-center gap-4 pt-4 ${className}`}>
<div className="text-sm text-muted-foreground order-2 sm:order-1">
{__('Showing')} {startItem} - {endItem} {__('of')} {total}
</div>
<div className="flex gap-2 order-1 sm:order-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
>
{__('Previous')}
</Button>
<div className="flex items-center sm:hidden text-sm opacity-80 px-2">
{__('Page')} {page} {__('of')} {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
{__('Next')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }

View File

@@ -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<SharedContentProps> = ({
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 (
<div className={containerClasses}>
{containerWidth === 'boxed' ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
</div>
) : (
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8'
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -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<any>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<div className="flex items-center justify-center p-8">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 opacity-50" />
<p className="text-sm opacity-70">{__('Loading addon...')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/50 p-4">
<h3 className="font-semibold text-red-900 dark:text-red-200 mb-2">{__('Failed to Load Addon')}</h3>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
);
}
if (!Component) {
return (
<div className="p-6">
<div className="rounded-lg border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/50 p-4">
<p className="text-sm text-yellow-700 dark:text-yellow-300">{__('Addon component not found')}</p>
</div>
</div>
);
}
// Render the addon component with props
return <Component {...(config.props || {})} />;
}
export function AppRoutes() {
const addonRoutes = window.WNW_ADDON_ROUTES || [];
return (
<Routes>
{/* Dashboard */}
<Route path="/" element={<Navigate to={window.WNW_CONFIG?.onboardingCompleted ? "/dashboard" : "/setup"} replace />} />
<Route path="/setup" element={<Onboarding />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
<Route path="/dashboard/orders" element={<DashboardOrders />} />
<Route path="/dashboard/products" element={<DashboardProducts />} />
<Route path="/dashboard/customers" element={<DashboardCustomers />} />
<Route path="/dashboard/coupons" element={<DashboardCoupons />} />
<Route path="/dashboard/taxes" element={<DashboardTaxes />} />
{/* Products */}
<Route path="/products" element={<ProductsIndex />} />
<Route path="/products/new" element={<ProductNew />} />
<Route path="/products/:id/edit" element={<ProductEdit />} />
<Route path="/products/categories" element={<ProductCategories />} />
<Route path="/products/tags" element={<ProductTags />} />
<Route path="/products/attributes" element={<ProductAttributes />} />
<Route path="/products/licenses" element={<Licenses />} />
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
<Route path="/products/software" element={<SoftwareVersions />} />
{/* Orders */}
<Route path="/orders" element={<OrdersIndex />} />
<Route path="/orders/new" element={<OrderNew />} />
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/:id/edit" element={<OrderEdit />} />
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
<Route path="/orders/:id/label" element={<OrderLabel />} />
{/* Subscriptions */}
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
{/* Coupons (under Marketing) */}
<Route path="/coupons" element={<Navigate to="/marketing/coupons" replace />} />
<Route path="/coupons/new" element={<Navigate to="/marketing/coupons/new" replace />} />
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
<Route path="/marketing/coupons" element={<CouponsIndex />} />
<Route path="/marketing/coupons/new" element={<CouponNew />} />
<Route path="/marketing/coupons/:id/edit" element={<CouponEdit />} />
{/* Customers */}
<Route path="/customers" element={<CustomersIndex />} />
<Route path="/customers/new" element={<CustomerNew />} />
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
<Route path="/customers/:id" element={<CustomerDetail />} />
{/* More */}
<Route path="/more" element={<MorePage />} />
{/* Settings */}
<Route path="/settings" element={<SettingsIndex />} />
<Route path="/settings/store" element={<SettingsStore />} />
<Route path="/settings/payments" element={<SettingsPayments />} />
<Route path="/settings/shipping" element={<SettingsShipping />} />
<Route path="/settings/tax" element={<SettingsTax />} />
<Route path="/settings/customers" element={<SettingsCustomers />} />
<Route path="/settings/security" element={<SettingsSecurity />} />
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
<Route path="/settings/checkout" element={<SettingsIndex />} />
<Route path="/settings/notifications" element={<SettingsNotifications />} />
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
<Route path="/settings/notifications/channels" element={<ChannelConfiguration />} />
<Route path="/settings/notifications/channels/email" element={<EmailConfiguration />} />
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
<Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} />
<Route path="/settings/modules" element={<SettingsModules />} />
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
{/* Appearance */}
<Route path="/appearance" element={<AppearanceIndex />} />
<Route path="/appearance/general" element={<AppearanceGeneral />} />
<Route path="/appearance/header" element={<AppearanceHeader />} />
<Route path="/appearance/footer" element={<AppearanceFooter />} />
<Route path="/appearance/shop" element={<AppearanceShop />} />
<Route path="/appearance/product" element={<AppearanceProduct />} />
<Route path="/appearance/cart" element={<AppearanceCart />} />
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/menus" element={<AppearanceMenus />} />
<Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
<Route index element={<Navigate to="subscribers" replace />} />
<Route path="subscribers" element={<NewsletterSubscribers />} />
<Route path="campaigns" element={<NewsletterCampaignsList />} />
<Route path="campaigns/:id" element={<CampaignEdit />} />
</Route>
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
{/* Help - Main menu route with no submenu */}
<Route path="/help" element={<Help />} />
{/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => (
<Route
key={route.path}
path={route.path}
element={<AddonRoute config={route} />}
/>
))}
</Routes>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
</div>
);
}
if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') {
return <Navigate to="/login" replace />;
}
if (location.pathname === '/login' && isAuthenticated) {
return <Navigate to="/" replace />;
}
return (
<FABProvider>
<PageHeaderProvider>
<DashboardProvider>
<Shell />
</DashboardProvider>
</PageHeaderProvider>
</FABProvider>
);
}

View File

@@ -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<HTMLDivElement>;
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 (
<header className={`h-16 border-b border-border flex items-center px-4 justify-between sticky ${fullscreen ? `top-0` : `top-[32px]`} z-40 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 transition-transform duration-300 ${fullscreen && !isVisible ? '-translate-y-full md:translate-y-0' : 'translate-y-0'}`}>
<div className="flex items-center gap-3">
{currentLogo ? (
<img src={currentLogo} alt={siteTitle} className="h-8 object-contain" />
) : (
<div className="font-semibold">{siteTitle}</div>
)}
{!(window.WNW_CONFIG?.customerSpaEnabled) && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
title={__('Visit Store')}
>
<ExternalLink className="w-4 h-4" />
<span className="hidden sm:inline">{__('Store')}</span>
</a>
)}
</div>
<div className="flex items-center gap-3">
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
{isStandalone && (
<>
<a
href={window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Go to WordPress Admin"
>
<span>{__('WordPress')}</span>
</a>
{window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<button
onClick={handleLogout}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Logout"
>
<span>{__('Logout')}</span>
</button>
</>
)}
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<ThemeToggle />
{showToggle && (
<button
onClick={onFullscreen}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
<span className="hidden sm:inline">{fullscreen ? 'Exit' : 'Fullscreen'}</span>
</button>
)}
</div>
</header>
);
}

View File

@@ -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<HTMLDivElement>(null);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
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 (
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
<div className="min-h-screen bg-background text-foreground flex flex-col">
<AppRoutes />
</div>
</AppProvider>
);
}
return (
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
{!isStandalone && <ShortcutsBinder onToggle={toggle} />}
{!isStandalone && <CommandPalette toggleFullscreen={toggle} />}
<div className={`flex flex-col min-h-screen ${fullscreen ? 'woonoow-fullscreen-root' : ''}`}>
<Header onFullscreen={toggle} fullscreen={fullscreen} showToggle={!isStandalone} scrollContainerRef={scrollContainerRef} />
{fullscreen ? (
isDesktop ? (
<div className="flex flex-1 min-h-0">
<Sidebar collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
<main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
<div className="flex flex-col-reverse">
<PageHeader fullscreen={true} />
{isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={true} />
) : (
<SubmenuBar items={main.children} fullscreen={true} />
)}
</div>
<div className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
</div>
) : (
<div className="flex flex-1 flex-col min-h-0">
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
<PageHeader fullscreen={true} />
{!isMorePage && (isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={true} />
) : (
<SubmenuBar items={main.children} fullscreen={true} />
))}
</div>
<main className="flex-1 flex flex-col min-h-0 min-w-0 pb-14">
<div ref={scrollContainerRef} className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
<BottomNav />
<FAB />
</div>
)
) : (
<div className="flex flex-1 flex-col min-h-0">
<TopNav fullscreen={false} />
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
<PageHeader fullscreen={false} />
{isDashboardRoute ? (
<DashboardSubmenuBar items={main.children} fullscreen={false} />
) : (
<SubmenuBar items={main.children} fullscreen={false} />
)}
</div>
<main className="flex-1 flex flex-col min-h-0 min-w-0">
<div className="flex-1 overflow-auto p-4 min-w-0">
<AppRoutes />
</div>
</main>
</div>
)}
</div>
</AppProvider>
);
}

View File

@@ -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 (
<aside className={`flex-shrink-0 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background flex flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
{/* Toggle button */}
<div className={`p-2 border-b border-border ${collapsed ? 'flex justify-center' : 'flex justify-end'}`}>
<button
onClick={onToggle}
className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
title={collapsed ? __('Expand sidebar') : __('Collapse sidebar')}
>
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className={`flex flex-col gap-1 flex-1 ${collapsed ? 'p-1' : 'p-3'}`}>
{navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package;
const isActive = main.key === item.key;
return (
<Link
key={item.key}
to={item.path}
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
title={collapsed ? item.label : undefined}
>
<IconComponent className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -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 (
<div data-mainmenu className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
{navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package;
const isActive = main.key === item.key;
return (
<Link
key={item.key}
to={item.path}
className={`${link} ${isActive ? active : ''}`}
>
<IconComponent className="w-4 h-4" />
<span className="text-sm font-medium">{item.label}</span>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -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(() => {

View File

@@ -0,0 +1,45 @@
import { useState, useEffect } from 'react';
export function useFullscreen() {
const [on, setOn] = useState<boolean>(() => {
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 <main> 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;
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useIsDesktop(minWidth = 1024) { // lg breakpoint
const [isDesktop, setIsDesktop] = useState<boolean>(() => {
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;
}

View File

@@ -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();

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
import { api } from '../api';
export const apiClient = api;

View File

@@ -62,7 +62,7 @@ export const CouponsApi = {
search?: string;
discount_type?: string;
}): Promise<CouponListResponse> => {
return api.get('/coupons', { params });
return api.get('/coupons', params);
},
/**

View File

@@ -69,7 +69,7 @@ export const CustomersApi = {
search?: string;
role?: string;
}): Promise<CustomerListResponse> => {
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<CustomerSearchResult[]> => {
return api.get('/customers/search', { params: { q: query, limit } });
return api.get('/customers/search', { q: query, limit });
},
};

View File

@@ -0,0 +1,23 @@
import { api } from '../api';
export type CreateOrderPayload = {
items: { product_id: number; qty: number }[];
billing?: Record<string, any>;
shipping?: Record<string, any>;
status?: string;
payment_method?: string;
};
export const OrdersApi = {
list: (params?: Record<string, any>) => api.get('/orders', params),
get: (id: number) => api.get(`/orders/${id}`),
create: (payload: CreateOrderPayload) => api.post('/orders', payload),
update: (id: number, payload: any) => api.wpFetch(`/orders/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
payments: async () => api.get('/payments'),
shippings: async () => api.get('/shippings'),
countries: () => api.get('/countries'),
};

View File

@@ -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'),
};

View File

@@ -0,0 +1 @@
export const useCartStore = () => ({ addToCart: () => {}, isAdding: false });

View File

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

View File

@@ -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<string, ElementType> = {
'layout-dashboard': LayoutDashboard,
'receipt-text': ReceiptText,
'package': Package,
'tag': Tag,
'users': Users,
'mail': Mail,
'palette': Palette,
'settings': SettingsIcon,
'help-circle': HelpCircle,
'repeat': Repeat,
};

View File

@@ -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<string, any>): 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';
}

View File

@@ -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');
}
}

View File

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

View File

@@ -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);
}

View File

@@ -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<string, any>;
elementStyles?: Record<string, any>;
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<string, any> {
const flattened: Record<string, any> = {};
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 (
<Component
id={section.id}
layout={section.layoutVariant}
colorScheme={section.colorScheme}
elementStyles={section.elementStyles}
styles={section.styles}
{...flatProps}
/>
);
}
}
// Map section type to exact customer-spa components via HOC adapter
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
'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<string | null>(null);
const previewSectionsById = new Map((previewSections || []).map((section) => [section.id, section]));
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -145,17 +198,19 @@ export function CanvasRenderer({
</Button>
</div>
{/* Canvas viewport */}
<div
className="flex-1 overflow-y-auto p-6"
className={cn(
"flex-1 overflow-y-auto",
deviceMode === 'desktop' ? "p-0" : "p-6"
)}
onClick={handleCanvasClick}
>
<div
className={cn(
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
deviceMode === 'mobile' ? 'max-w-sm' : (
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
)
'bg-white transition-all duration-300 min-h-[500px]',
deviceMode === 'mobile'
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
: 'w-full h-full'
)}
>
{sections.length === 0 ? (
@@ -188,9 +243,7 @@ export function CanvasRenderer({
{/* Top Insertion Zone */}
<InsertionZone
index={0}
onAdd={(type) => 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)}
/>
<DndContext
@@ -205,6 +258,7 @@ export function CanvasRenderer({
<div className="flex flex-col">
{sections.map((section, index) => {
const Renderer = SECTION_RENDERERS[section.type];
const renderSection = previewSectionsById.get(section.id) || section;
return (
<React.Fragment key={section.id}>
@@ -223,7 +277,7 @@ export function CanvasRenderer({
canMoveDown={index < sections.length - 1}
>
{Renderer ? (
<Renderer section={section} />
<Renderer section={renderSection} />
) : (
<div className="p-8 text-center text-gray-400">
Unknown section type: {section.type}

View File

@@ -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<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
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<string, { value: string; label: string }[]> = {
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<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
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 (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Features')}
items={items}
onChange={(newItems) => 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
}
/>
</div>
);
})()}
{/* Bento Category Grid Repeater */}
{selectedSection.type === 'bento-category-grid' && (() => {
const itemsProp = selectedSection.props.items;
const items = Array.isArray(itemsProp?.value) ? itemsProp.value : [];
return (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Grid Items')}
items={items}
onChange={(newItems) => 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"
/>
</div>
);
})()}
{/* Shoppable Image Hotspots Repeater */}
{selectedSection.type === 'shoppable-image' && (() => {
const hotspotsProp = selectedSection.props.hotspots;
const hotspots = Array.isArray(hotspotsProp?.value) ? hotspotsProp.value : [];
return (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Hotspots')}
items={hotspots}
onChange={(newItems) => 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"
/>
<p className="text-xs text-muted-foreground mt-2">
X and Y are percentages (0-100) from the top-left of the image.
</p>
</div>
);
})()}
</TabsContent>
{/* Design Tab */}

View File

@@ -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 (
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
<AccordionItem value={`item-${index}`} className="border-0">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
<GripVertical className="w-4 h-4" />
</button>
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
{item[itemLabelKey] || `Item ${index + 1}`}
</AccordionTrigger>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation();
onDelete(index);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<AccordionContent className="p-3 space-y-3">
{fields.map((field: RepeaterFieldDef) => (
<div key={field.name} className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
{field.type === 'textarea' ? (
<Textarea
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="text-xs min-h-[60px]"
/>
) : field.type === 'icon' ? (
<Select
value={item[field.name] || ''}
onValueChange={(val) => onChange(index, field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{ICON_OPTIONS.map(iconName => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2">
<span>{iconName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="text"
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
/>
)}
</div>
))}
</AccordionContent>
</AccordionItem>
const handleFieldChange = (fieldName: string, value: any) => {
onChange(index, fieldName, value);
};
return (
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
<AccordionItem value={`item-${index}`} className="border-0">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
<button
{...attributes}
{...listeners}
className="cursor-grab text-gray-400 hover:text-gray-600"
>
<GripVertical className="w-4 h-4" />
</button>
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
{item[itemLabelKey] || `Item ${index + 1}`}
</AccordionTrigger>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={(e: any) => {
e.stopPropagation();
onDelete(index);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
<AccordionContent className="p-3 space-y-3">
{fields.map((field: RepeaterFieldDef) => (
<RepeaterFieldRenderer
key={field.name}
field={field}
item={item}
index={index}
onChange={handleFieldChange}
ICON_OPTIONS={ICON_OPTIONS}
/>
))}
</AccordionContent>
</AccordionItem>
</div>
);
}
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title', isDynamic = false, dynamicLabel }: InspectorRepeaterProps) {
// Generate simple stable IDs for sorting if items don't have them
const itemIds = items.map((_, i) => `item-${i}`);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = itemIds.indexOf(active.id as string);
const newIndex = itemIds.indexOf(over.id as string);
onChange(arrayMove(items, oldIndex, newIndex));
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
onChange(newItems);
};
const handleAddItem = () => {
const newItem: any = {};
fields.forEach(f => newItem[f.name] = '');
onChange([...items, newItem]);
};
const handleDeleteItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
};
function RepeaterFieldRenderer({
field,
item,
index,
onChange,
ICON_OPTIONS,
}: {
field: RepeaterFieldDef;
item: any;
index: number;
onChange: (fieldName: string, value: any) => void;
ICON_OPTIONS: string[];
}) {
const value = item[field.name] || '';
if (field.type === 'textarea') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Textarea
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
placeholder={field.placeholder}
className="text-xs min-h-[60px]"
/>
</div>
);
}
if (field.type === 'icon') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Select
value={value}
onValueChange={(val) => onChange(field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{ICON_OPTIONS.map((iconName) => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2">
<span>{iconName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (field.type === 'image') {
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
{!isDynamic && (
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
<Plus className="w-3 h-3 mr-1" />
Add Item
</Button>
)}
</div>
<Accordion type="single" collapsible className="w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={itemIds}
strategy={verticalListSortingStrategy}
>
{items.map((item, index) => (
<SortableItem
key={`item-${index}`} // Note: In a real app with IDs, use item.id
id={`item-${index}`}
index={index}
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={handleItemChange}
onDelete={handleDeleteItem}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
{items.length === 0 && (
<div className={cn(
"text-xs text-center py-4 border rounded-md",
isDynamic
? "text-blue-600 border-blue-200 bg-blue-50"
: "text-gray-400 border-dashed bg-gray-50"
)}>
{isDynamic
? (dynamicLabel || '⚡ Auto-populated from related posts at runtime')
: 'No items yet. Click "Add Item" to start.'}
{value ? (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
<img src={value} alt={field.label} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">Change</span>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
onChange(field.name, '');
}}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
type="button"
aria-label="Remove image"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</MediaUploader>
) : (
<MediaUploader
onSelect={(url) => onChange(field.name, url)}
type="image"
>
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal justify-start">
Select Image
</Button>
</MediaUploader>
)}
</div>
</div>
);
}
if (field.type === 'product') {
return (
<RepeaterProductField
label={field.label}
value={item.product_slug || value || ''}
onChange={(fieldName, nextValue) => {
// fieldName is expected to be one of the product_* keys.
onChange(fieldName, nextValue);
}}
/>
);
}
// default: text/url inputs
const inputType = field.type === 'url' ? 'url' : 'text';
return (
<div key={field.name} className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
<Input
type={inputType}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
/>
</div>
);
}
export function InspectorRepeater({
label,
items = [],
fields,
onChange,
itemLabelKey = 'title',
isDynamic = false,
dynamicLabel,
}: InspectorRepeaterProps) {
const itemIds = items.map((_, i) => `item-${i}`);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = itemIds.indexOf(active.id as string);
const newIndex = itemIds.indexOf(over.id as string);
onChange(arrayMove(items, oldIndex, newIndex));
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
onChange(newItems);
};
const handleAddItem = () => {
const newItem: any = {};
fields.forEach((f) => (newItem[f.name] = ''));
onChange([...items, newItem]);
};
const handleDeleteItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
{!isDynamic && (
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
<Plus className="w-3 h-3 mr-1" />
Add Item
</Button>
)}
</div>
<Accordion type="single" collapsible className="w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{items.map((item, index) => (
<SortableItem
key={`item-${index}`}
id={`item-${index}`}
index={index}
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={(idx: number, fieldName: string, value: string) => handleItemChange(idx, fieldName, value)}
onDelete={handleDeleteItem}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
{items.length === 0 && (
<div
className={cn(
'text-xs text-center py-4 border rounded-md',
isDynamic
? 'text-blue-600 border-blue-200 bg-blue-50'
: 'text-gray-400 border-dashed bg-gray-50',
)}
>
{isDynamic
? dynamicLabel || '⚡ Auto-populated from related posts at runtime'
: 'No items yet. Click "Add Item" to start.'}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { ProductsApi } from '@/lib/api/products';
export default function RepeaterProductField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (fieldName: string, nextValue: any) => void;
}) {
const [search, setSearch] = React.useState('');
const [options, setOptions] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
async function run() {
const q = search.trim();
if (q.length < 2) {
setOptions([]);
return;
}
setLoading(true);
try {
const res = await ProductsApi.search(q, 10);
const rows = (res as any)?.rows ?? (res as any)?.data ?? (res as any)?.items ?? [];
const mapped = (Array.isArray(rows) ? rows : []).map((p: any) => {
const productSlug =
p.slug || p.product_slug || p.permalink_slug || p.slug?.toString?.() || '';
const name = p.name || 'Product';
const skuSuffix = p.sku ? ` (${p.sku})` : '';
const labelStr = `${name}${skuSuffix}`;
const triggerLabel = productSlug ? `${name} /${productSlug}` : name;
return {
value: String(productSlug || p.id),
searchText: `${name} ${p.sku ?? ''} ${productSlug ?? ''}`.trim(),
label: labelStr,
triggerLabel,
product: {
...p,
product_slug: productSlug || p.product_slug || '',
id: p.id,
price: p.price ?? p.sale_price ?? p.regular_price ?? null,
image_url: p.image_url ?? p.image ?? p.thumbnail_url ?? '',
},
};
});
if (!cancelled) setOptions(mapped);
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, [search]);
const selectedOption = options.find((o) => o.value === (value || ''));
return (
<div className="space-y-1.5">
<Label className="text-xs text-gray-500">{label}</Label>
<SearchableSelect
value={selectedOption?.value || (value || '')}
onChange={(v) => {
const selected = options.find((o) => o.value === v)?.product;
if (!selected) return;
onChange('product_slug', selected.product_slug || '');
onChange('product_name', selected.name || '');
onChange('product_price', selected.sale_price ?? selected.price ?? '');
onChange('product_image', selected.image_url ?? '');
onChange('product_id', selected.id ? Number(selected.id) : 0);
}}
options={options.map((o) => ({
value: String(o.value ?? ''),
label: o.label,
triggerLabel: o.triggerLabel,
searchText: String(o.searchText ?? ''),
}))}
placeholder={loading ? 'Searching…' : 'Search product…'}
emptyLabel={loading ? 'Searching…' : 'No products found'}
search={search}
onSearch={setSearch}
showCheckIndicator={false}
/>
</div>
);
}

View File

@@ -23,7 +23,7 @@ import { Card } from '@/components/ui/card';
import {
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
Loader2
Loader2, GalleryHorizontalEnd, ScanText, LayoutGrid, Pointer
} from 'lucide-react';
import {
DropdownMenu,
@@ -71,6 +71,10 @@ const SECTION_TYPES = [
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
{ type: 'bento-category-grid', label: 'Bento Grid', icon: LayoutGrid },
{ type: 'product-carousel', label: 'Product Carousel', icon: GalleryHorizontalEnd },
{ type: 'shoppable-image', label: 'Shoppable Image', icon: Pointer },
{ type: 'marquee-banner', label: 'Marquee Banner', icon: ScanText },
];
// Sortable Section Card Component

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '@/lib/utils';
export function BentoCategoryGridRenderer({ section, className }: { section: any; className?: string }) {
const { title, items } = section.props;
const styles = section.styles || {};
const elementStyles = section.elementStyles || {};
const displayTitle = title?.value || 'Categories';
return (
<div
className={cn('py-8 px-4', className)}
>
<div className="max-w-4xl mx-auto">
{displayTitle && (
<h2
className="text-2xl font-bold mb-6"
style={{ color: elementStyles?.title?.color }}
>
{displayTitle}
</h2>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 auto-rows-auto opacity-70">
<div className="col-span-2 row-span-2 min-h-[200px] bg-indigo-100 rounded-xl border-2 border-indigo-200 border-dashed flex items-center justify-center p-4">
<span className="font-semibold text-indigo-800 text-center">Bento Grid Item (Large)</span>
</div>
<div className="col-span-2 row-span-1 min-h-[100px] bg-rose-100 rounded-xl border-2 border-rose-200 border-dashed flex items-center justify-center p-4">
<span className="font-semibold text-rose-800 text-center">Bento Grid Item (Medium)</span>
</div>
<div className="col-span-1 row-span-1 min-h-[100px] bg-amber-100 rounded-xl border-2 border-amber-200 border-dashed flex items-center justify-center p-4">
<span className="font-semibold text-amber-800 text-center text-sm">Item (Small)</span>
</div>
<div className="col-span-1 row-span-1 min-h-[100px] bg-emerald-100 rounded-xl border-2 border-emerald-200 border-dashed flex items-center justify-center p-4">
<span className="font-semibold text-emerald-800 text-center text-sm">Item (Small)</span>
</div>
</div>
<p className="text-center text-xs text-muted-foreground mt-4 italic">Grid items configured in inspector panel</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { ScanText } from 'lucide-react';
import { cn } from '@/lib/utils';
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
const { text, separator } = section.props;
const styles = section.styles || {};
const displayText = text?.value || 'Marquee Banner Text Here';
const displaySeparator = separator?.value || '✦';
return (
<div
className={cn('py-4 overflow-hidden relative', className)}
style={{ backgroundColor: styles?.backgroundColor || 'var(--wn-primary, #1a1a1a)', color: '#fff' }}
>
<div className="flex whitespace-nowrap opacity-70">
<div className="flex items-center gap-8 pr-8">
{[1, 2, 3].map((idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
{displayText}
<span className="opacity-50 text-xs">{displaySeparator}</span>
</span>
))}
</div>
</div>
{/* Admin Overlay Indicator */}
<div className="absolute inset-0 flex items-center justify-center bg-black/10 pointer-events-none">
<div className="bg-background/90 text-foreground text-xs px-2 py-1 rounded shadow-sm border border-border flex items-center gap-1 backdrop-blur-sm">
<ScanText className="w-3 h-3" /> Auto-scrolling Banner
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { GalleryHorizontalEnd, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ProductCarouselRenderer({ section, className }: { section: any; className?: string }) {
const { title, subtitle, cta_text } = section.props;
const elementStyles = section.elementStyles || {};
const displayTitle = section.props.title?.value || 'Trending Now';
const displaySubtitle = section.props.subtitle?.value;
const displayCta = section.props.cta_text?.value;
return (
<div className={cn('py-12 px-4', className)}>
<div className="max-w-6xl mx-auto">
<div className="flex items-end justify-between mb-8">
<div>
<h2
className="text-3xl font-bold"
style={{ color: elementStyles?.title?.color }}
>
{displayTitle}
</h2>
{displaySubtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
{displaySubtitle}
</p>
)}
</div>
<div className="flex items-center gap-3">
{displayCta && (
<span className="text-sm font-semibold mr-4 text-primary">{displayCta} </span>
)}
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center bg-background">
<ChevronLeft className="w-4 h-4 text-muted-foreground" />
</div>
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center bg-background">
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
</div>
</div>
<div className="flex gap-4 overflow-hidden opacity-60">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="w-48 md:w-60 flex-shrink-0">
<div className="aspect-square bg-muted rounded-lg mb-3 flex items-center justify-center border-2 border-dashed border-gray-300">
<GalleryHorizontalEnd className="w-8 h-8 text-gray-400" />
</div>
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-4 bg-muted rounded w-1/4"></div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { Pointer } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ShoppableImageRenderer({ section, className }: { section: any; className?: string }) {
const { title, subtitle, image, hotspots } = section.props;
const styles = section.styles || {};
const elementStyles = section.elementStyles || {};
const displayTitle = title?.value;
const displaySubtitle = subtitle?.value;
const displayImage = image?.value;
const displayHotspots = hotspots?.value || [];
return (
<div className={cn('py-12 px-4', className)}>
<div className="max-w-4xl mx-auto">
{(displayTitle || displaySubtitle) && (
<div className="mb-8 text-center">
{displayTitle && (
<h2
className="text-3xl font-bold"
style={{ color: elementStyles?.title?.color }}
>
{displayTitle}
</h2>
)}
{displaySubtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
{displaySubtitle}
</p>
)}
</div>
)}
<div className="relative rounded-xl overflow-hidden bg-gray-100 aspect-[16/9] border-2 border-dashed border-gray-300 flex items-center justify-center">
{displayImage ? (
<>
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
{displayHotspots.map((hotspot: any, idx: number) => (
<div
key={idx}
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
>
{idx + 1}
</div>
))}
</>
) : (
<div className="text-center text-gray-400">
<Pointer className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="font-medium">Shoppable Image Area</p>
<p className="text-sm">Configure image and hotspots in the inspector</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -4,3 +4,7 @@ export { ImageTextRenderer } from './ImageTextRenderer';
export { FeatureGridRenderer } from './FeatureGridRenderer';
export { CTABannerRenderer } from './CTABannerRenderer';
export { ContactFormRenderer } from './ContactFormRenderer';
export { BentoCategoryGridRenderer } from './BentoCategoryGridRenderer';
export { ProductCarouselRenderer } from './ProductCarouselRenderer';
export { ShoppableImageRenderer } from './ShoppableImageRenderer';
export { MarqueeBannerRenderer } from './MarqueeBannerRenderer';

View File

@@ -5,17 +5,33 @@ import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
import { PageSidebar } from './components/PageSidebar';
import { CanvasRenderer } from './components/CanvasRenderer';
import { InspectorPanel } from './components/InspectorPanel';
import { CreatePageModal } from './components/CreatePageModal';
import { usePageEditorStore, Section, PageItem } from './store/usePageEditorStore';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
export default function AppearancePages() {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [pendingPage, setPendingPage] = useState<PageItem | null>(null);
const [previewPostId, setPreviewPostId] = useState<number | null>(null);
// Zustand store
const {
@@ -49,8 +65,11 @@ export default function AppearancePages() {
unsetSpaLanding,
} = usePageEditorStore();
const { showPrompt, confirmNavigation, cancelNavigation } = useUnsavedChanges(hasUnsavedChanges);
// Get selected section object
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
const isTemplate = currentPage?.type === 'template';
// Fetch all pages and templates
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
@@ -85,6 +104,30 @@ export default function AppearancePages() {
queryFn: async () => api.get('/appearance/settings'),
});
const { data: previewSamples } = useQuery({
queryKey: ['template-preview-samples', currentPage?.cpt],
queryFn: async () => {
if (!currentPage?.cpt) return { items: [] };
return api.get(`/preview/samples/${currentPage.cpt}`);
},
enabled: isTemplate && !!currentPage?.cpt,
});
// Resolve dynamic template props for canvas preview without mutating saved section JSON.
const { data: templatePreviewData } = useQuery({
queryKey: ['template-preview-resolved', currentPage?.cpt, previewPostId, sections],
queryFn: async () => {
if (!currentPage?.cpt) return null;
return api.post(`/preview/resolve/${currentPage.cpt}`, { sections, sample_post_id: previewPostId });
},
enabled: isTemplate && !!currentPage?.cpt && sections.length > 0,
staleTime: 10 * 1000,
});
const previewSections = isTemplate && templatePreviewData?.resolved
? templatePreviewData.sections as Section[]
: undefined;
// Update store when page data loads
useEffect(() => {
if (pageData?.structure?.sections) {
@@ -129,6 +172,17 @@ export default function AppearancePages() {
},
});
// Keyboard shortcut listener
useEffect(() => {
const handleSaveShortcut = () => {
if (hasUnsavedChanges && !saveMutation.isPending) {
saveMutation.mutate();
}
};
window.addEventListener('woonoow:shortcut:save', handleSaveShortcut);
return () => window.removeEventListener('woonoow:shortcut:save', handleSaveShortcut);
}, [hasUnsavedChanges, saveMutation]);
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
@@ -199,8 +253,14 @@ export default function AppearancePages() {
// Handle page selection
const handleSelectPage = (page: PageItem) => {
if (hasUnsavedChanges) {
if (!confirm(__('You have unsaved changes. Continue?'))) return;
setPendingPage(page);
setShowUnsavedDialog(true);
return;
}
processPageSelection(page);
};
const processPageSelection = (page: PageItem) => {
if (page.type === 'page') {
setCurrentPage({
...page,
@@ -209,6 +269,7 @@ export default function AppearancePages() {
} else {
setCurrentPage(page);
};
setPreviewPostId(null);
setSelectedSection(null);
};
@@ -222,18 +283,12 @@ export default function AppearancePages() {
const handleDeletePage = () => {
if (!currentPage || !currentPage.id) return;
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
deleteMutation.mutate(currentPage.id);
}
setShowDeleteDialog(true);
};
const handleDeleteTemplate = () => {
if (!currentPage || currentPage.type !== 'template' || !currentPage.cpt) return;
if (confirm(__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.'))) {
deleteTemplateMutation.mutate(currentPage.cpt);
}
setShowResetDialog(true);
};
return (
@@ -252,6 +307,21 @@ export default function AppearancePages() {
</p>
</div>
<div className="flex items-center gap-3">
{isTemplate && (previewSamples?.items || []).length > 0 && (
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={previewPostId ?? ''}
onChange={(event) => setPreviewPostId(event.target.value ? Number(event.target.value) : null)}
title={__('Preview content')}
>
<option value="">{__('Auto preview content')}</option>
{previewSamples.items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
)}
<Button
variant="ghost"
size="icon"
@@ -308,6 +378,7 @@ export default function AppearancePages() {
currentPage ? (
<CanvasRenderer
sections={sections}
previewSections={previewSections}
selectedSectionId={selectedSectionId}
deviceMode={deviceMode}
onSelectSection={setSelectedSection}
@@ -394,6 +465,97 @@ export default function AppearancePages() {
}
</div >
{/* Unsaved Changes Dialog (Route Navigation) */}
<AlertDialog open={showPrompt} onOpenChange={cancelNavigation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Unsaved Changes')}</AlertDialogTitle>
<AlertDialogDescription>
{__('You have unsaved changes. Are you sure you want to leave this page?')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelNavigation}>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmNavigation} className="bg-destructive hover:bg-destructive/90">
{__('Leave Page')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Unsaved Changes Dialog (Page Switch) */}
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Unsaved Changes')}</AlertDialogTitle>
<AlertDialogDescription>
{__('You have unsaved changes. Are you sure you want to discard them and continue?')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setShowUnsavedDialog(false);
setPendingPage(null);
}}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
setShowUnsavedDialog(false);
if (pendingPage) processPageSelection(pendingPage);
setPendingPage(null);
}} className="bg-destructive hover:bg-destructive/90">
{__('Discard Changes')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Page')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to delete this page? This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowDeleteDialog(false)}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
if (currentPage?.id) deleteMutation.mutate(currentPage.id);
setShowDeleteDialog(false);
}} className="bg-destructive hover:bg-destructive/90">
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Reset Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Reset to WordPress Default')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowResetDialog(false)}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
if (currentPage?.cpt) deleteTemplateMutation.mutate(currentPage.cpt);
setShowResetDialog(false);
}} className="bg-destructive hover:bg-destructive/90">
{__('Reset')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Create Page Modal */}
< CreatePageModal
open={showCreateModal}

View File

@@ -0,0 +1,301 @@
export type SectionPropType = 'static' | 'dynamic';
export type SectionFieldType = 'text' | 'textarea' | 'url' | 'image' | 'rte';
export type SectionContentWidth = 'full' | 'contained' | 'boxed';
export interface SectionPropSchema {
type: SectionPropType;
value?: any;
source?: string;
}
export interface SectionFieldSchema {
name: string;
label: string;
type: SectionFieldType;
dynamic?: boolean;
}
export interface SectionOption {
value: string;
label: string;
}
export interface StylableElementSchema {
name: string;
label: string;
type: 'text' | 'image';
}
export interface SectionSchema {
type: string;
label: string;
defaultProps: Record<string, SectionPropSchema>;
defaultStyles?: {
contentWidth?: SectionContentWidth;
};
fields: SectionFieldSchema[];
layouts?: SectionOption[];
stylableElements?: StylableElementSchema[];
}
export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
hero: {
type: 'hero',
label: 'Hero',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
title: { type: 'static', value: 'Welcome to Our Site' },
subtitle: { type: 'static', value: 'Discover amazing products and services' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: 'Get Started' },
cta_url: { type: 'static', value: '#' },
},
fields: [
{ 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' },
],
layouts: [
{ value: 'default', label: 'Centered' },
{ value: 'hero-left-image', label: 'Image Left' },
{ value: 'hero-right-image', label: 'Image Right' },
],
stylableElements: [
{ 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: {
type: 'content',
label: 'Content',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
fields: [
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
layouts: [
{ value: 'default', label: 'Full Width' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'medium', label: 'Medium' },
],
stylableElements: [
{ 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' },
],
},
'image-text': {
type: 'image-text',
label: 'Image + Text',
defaultStyles: { contentWidth: 'contained' },
defaultProps: {
title: { type: 'static', value: 'Section Title' },
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
fields: [
{ 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' },
],
layouts: [
{ value: 'image-left', label: 'Image Left' },
{ value: 'image-right', label: 'Image Right' },
],
stylableElements: [
{ 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': {
type: 'feature-grid',
label: 'Feature Grid',
defaultStyles: { contentWidth: 'contained' },
defaultProps: {
heading: { type: 'static', value: 'Our Features' },
items: { type: 'static', value: [] },
},
fields: [
{ name: 'heading', label: 'Heading', type: 'text' },
],
layouts: [
{ value: 'grid-2', label: '2 Columns' },
{ value: 'grid-3', label: '3 Columns' },
{ value: 'grid-4', label: '4 Columns' },
],
stylableElements: [
{ name: 'heading', label: 'Heading', type: 'text' },
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
],
},
'cta-banner': {
type: 'cta-banner',
label: 'CTA Banner',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
title: { type: 'static', value: 'Ready to get started?' },
text: { type: 'static', value: 'Join thousands of happy customers today.' },
button_text: { type: 'static', value: 'Get Started' },
button_url: { type: 'static', value: '#' },
},
fields: [
{ 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' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Description', type: 'text' },
{ name: 'button_text', label: 'Button', type: 'text' },
],
},
'contact-form': {
type: 'contact-form',
label: 'Contact Form',
defaultStyles: { contentWidth: 'contained' },
defaultProps: {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
},
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'fields', label: 'Input Fields', type: 'text' },
],
},
'bento-category-grid': {
type: 'bento-category-grid',
label: 'Bento Grid',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
title: { type: 'static', value: 'Shop by Category' },
items: { type: 'static', value: [] },
},
fields: [
{ name: 'title', label: 'Section Title', type: 'text' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
],
},
'product-carousel': {
type: 'product-carousel',
label: 'Product Carousel',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
title: { type: 'static', value: 'Trending Now' },
subtitle: { type: 'static', value: '' },
cta_text: { type: 'static', value: 'Shop All' },
cta_url: { type: 'static', value: '' },
source: { type: 'static', value: 'trending' },
limit: { type: 'static', value: '8' },
},
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'cta_text', label: 'CTA Label', type: 'text' },
{ name: 'cta_url', label: 'CTA URL', type: 'url' },
{ name: 'source', label: 'Product Source', type: 'text' },
{ name: 'limit', label: 'Max Products', type: 'text' },
],
layouts: [
{ value: 'trending', label: 'Trending' },
{ value: 'new', label: 'New Arrivals' },
{ value: 'on_sale', label: 'On Sale' },
{ value: 'featured', label: 'Featured' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
],
},
'shoppable-image': {
type: 'shoppable-image',
label: 'Shoppable Image',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
title: { type: 'static', value: 'Shop the Look' },
subtitle: { type: 'static', value: '' },
image: { type: 'static', value: '' },
alt: { type: 'static', value: '' },
hotspots: { type: 'static', value: [] },
},
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'image', label: 'Image URL', type: 'url' },
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
],
stylableElements: [
{ name: 'title', label: 'Title', type: 'text' },
],
},
'marquee-banner': {
type: 'marquee-banner',
label: 'Marquee Banner',
defaultStyles: { contentWidth: 'full' },
defaultProps: {
text: { type: 'static', value: 'Free Shipping on orders over $50' },
separator: { type: 'static', value: '*' },
speed: { type: 'static', value: '20' },
},
fields: [
{ name: 'text', label: 'Banner Text', type: 'text' },
{ name: 'separator', label: 'Separator', type: 'text' },
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
],
},
};
export const SECTION_SCHEMA_LIST = Object.values(SECTION_SCHEMAS);
export function getSectionSchema(type: string): SectionSchema | undefined {
return SECTION_SCHEMAS[type];
}
export function cloneDefaultProps(type: string): Record<string, SectionPropSchema> {
const schema = getSectionSchema(type);
return schema ? JSON.parse(JSON.stringify(schema.defaultProps)) : {};
}
export function cloneDefaultStyles(type: string): SectionSchema['defaultStyles'] {
const schema = getSectionSchema(type);
return schema?.defaultStyles ? { ...schema.defaultStyles } : undefined;
}
export function normalizeFeatureGridProps(props: Record<string, any>): Record<string, any> {
if (!props || props.items !== undefined || props.features === undefined) {
return props;
}
return {
...props,
items: props.features,
};
}

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { cloneDefaultProps, cloneDefaultStyles, getSectionSchema } from '../schema/sectionSchema';
// Simple ID generator (replaces uuid)
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
@@ -126,55 +127,6 @@ interface PageEditorState {
savePage: () => Promise<void>;
}
// Default props for each section type
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
hero: {
title: { type: 'static', value: 'Welcome to Our Site' },
subtitle: { type: 'static', value: 'Discover amazing products and services' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: 'Get Started' },
cta_url: { type: 'static', value: '#' },
},
content: {
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'image-text': {
title: { type: 'static', value: 'Section Title' },
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'feature-grid': {
heading: { type: 'static', value: 'Our Features' },
features: { type: 'static', value: '' },
},
'cta-banner': {
title: { type: 'static', value: 'Ready to get started?' },
text: { type: 'static', value: 'Join thousands of happy customers today.' },
button_text: { type: 'static', value: 'Get Started' },
button_url: { type: 'static', value: '#' },
},
'contact-form': {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
},
};
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
};
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
// Initial state
currentPage: null,
@@ -200,15 +152,15 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
// Section actions
addSection: (type, index) => {
const { sections } = get();
const sectionConfig = SECTION_CONFIGS[type];
const sectionConfig = getSectionSchema(type);
if (!sectionConfig) return;
const newSection: Section = {
id: generateId(),
type,
props: { ...sectionConfig.defaultProps },
styles: { ...sectionConfig.defaultStyles }
props: cloneDefaultProps(type) as Record<string, SectionProp>,
styles: cloneDefaultStyles(type) as SectionStyles,
};
const newSections = [...sections];

View File

@@ -3,7 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { __ } from '@/lib/i18n';
import { CustomersApi } from '@/lib/api/customers';
import { OrdersApi } from '@/lib/api';
import { api } from '@/lib/api';
import { OrdersApi } from '@/lib/api/orders';
import { showErrorToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { ErrorCard } from '@/components/ErrorCard';

View File

@@ -7,9 +7,21 @@ import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib
import { useFABConfig } from '@/hooks/useFABConfig';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { FilterBottomSheet } from '@/components/filters/FilterBottomSheet';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ErrorCard } from '@/components/ErrorCard';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
import {
@@ -29,6 +41,9 @@ export default function CustomersIndex() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const perPage = 20;
// FAB config - 'none' because submenu has 'New' tab (per SOP)
useFABConfig('none');
@@ -36,7 +51,7 @@ export default function CustomersIndex() {
// Fetch customers
const customersQuery = useQuery({
queryKey: ['customers', page, search],
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
queryFn: () => CustomersApi.list({ page, per_page: perPage, search }),
});
// Delete mutation
@@ -51,6 +66,8 @@ export default function CustomersIndex() {
},
onError: (error: any) => {
showErrorToast(error);
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
@@ -69,10 +86,22 @@ export default function CustomersIndex() {
}
};
const handleDelete = () => {
if (selectedIds.length === 0) return;
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
deleteMutation.mutate(selectedIds);
const handleDeleteClick = (id?: number) => {
if (id) {
setDeleteTargetId(id);
setShowDeleteDialog(true);
} else if (selectedIds.length > 0) {
setDeleteTargetId(null);
setShowDeleteDialog(true);
}
};
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate([deleteTargetId]);
} else if (selectedIds.length > 0) {
deleteMutation.mutate(selectedIds);
}
};
const handleRefresh = () => {
@@ -128,14 +157,15 @@ export default function CustomersIndex() {
{/* Left: Bulk Actions */}
<div className="flex gap-3">
{selectedIds.length > 0 && (
<button
onClick={handleDelete}
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick()}
disabled={deleteMutation.isPending}
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{__('Delete')} ({selectedIds.length})
</button>
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete Selected')}
</Button>
)}
<button
@@ -251,11 +281,7 @@ export default function CustomersIndex() {
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this customer?'))) {
deleteMutation.mutate([customer.id]);
}
}}
onClick={() => handleDeleteClick(customer.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
@@ -333,29 +359,48 @@ export default function CustomersIndex() {
</div>
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1 || customersQuery.isFetching}
>
{__('Previous')}
</Button>
<span className="px-4 py-2 text-sm">
{__('Page')} {page} {__('of')} {pagination.total_pages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(pagination.total_pages, p + 1))}
disabled={page === pagination.total_pages || customersQuery.isFetching}
>
{__('Next')}
</Button>
</div>
{!customersQuery.isLoading && !customersQuery.isError && pagination && (
<Pagination
page={page}
perPage={perPage}
total={pagination.total_items}
onPageChange={setPage}
/>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Customers')}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTargetId
? __('Are you sure you want to delete this customer?')
: __('Are you sure you want to delete ') + selectedIds.length + __(' customers?')}
<br />
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setShowDeleteDialog(false);
setDeleteTargetId(null);
}}
disabled={deleteMutation.isPending}
>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -153,18 +153,9 @@ export default function CustomersAnalytics() {
}));
}, [data, period]);
// Debug logging
console.log('[CustomersAnalytics] State:', {
isLoading,
hasError: !!error,
errorMessage: error?.message,
hasData: !!data,
dataKeys: data ? Object.keys(data) : []
});
// Show loading state
if (isLoading) {
console.log('[CustomersAnalytics] Rendering loading state');
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
@@ -175,9 +166,7 @@ export default function CustomersAnalytics() {
);
}
// Show error state with clear message and retry button
if (error) {
console.log('[CustomersAnalytics] Rendering error state:', error);
return (
<ErrorCard
title={__('Failed to load customer analytics')}
@@ -187,7 +176,7 @@ export default function CustomersAnalytics() {
);
}
console.log('[CustomersAnalytics] Rendering normal content');
// Table columns
const customerColumns: Column<TopCustomer>[] = [

View File

@@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDashboardPeriod } from '@/hooks/useDashboardPeriod';
import { useOverviewAnalytics } from '@/hooks/useAnalytics';
import { LoadingState } from '@/components/LoadingState';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { useFABConfig } from '@/hooks/useFABConfig';
@@ -198,8 +199,8 @@ export default function Dashboard() {
const periodMetrics = useMemo(() => {
if (period === 'all') {
// For "all time", no comparison
const currentRevenue = DUMMY_DATA.salesChart.reduce((sum: number, d: any) => sum + d.revenue, 0);
const currentOrders = DUMMY_DATA.salesChart.reduce((sum: number, d: any) => sum + d.orders, 0);
const currentRevenue = data.salesChart.reduce((sum: number, d: any) => sum + d.revenue, 0);
const currentOrders = data.salesChart.reduce((sum: number, d: any) => sum + d.orders, 0);
return {
revenue: { current: currentRevenue, change: undefined },
@@ -210,7 +211,7 @@ export default function Dashboard() {
}
const currentData = chartData;
const previousData = DUMMY_DATA.salesChart.slice(-Number(period) * 2, -Number(period));
const previousData = data.salesChart.slice(-Number(period) * 2, -Number(period));
const currentRevenue = currentData.reduce((sum: number, d: any) => sum + d.revenue, 0);
const previousRevenue = previousData.reduce((sum: number, d: any) => sum + d.revenue, 0);
@@ -243,14 +244,7 @@ export default function Dashboard() {
// Show loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">{__('Loading analytics...')}</p>
</div>
</div>
);
return <LoadingState text={__('Loading analytics...')} className="h-96" />;
}
// Show error state

View File

@@ -10,7 +10,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { MultiSelect } from '@/components/ui/multi-select';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { ProductsApi } from '@/lib/api';
import { ProductsApi } from '@/lib/api/products';
import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
import type { Coupon, CouponFormData } from '@/lib/api/coupons';

View File

@@ -7,6 +7,16 @@ import { CouponsApi, type Coupon } from '@/lib/api/coupons';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -33,6 +43,8 @@ export default function CouponsIndex() {
const [discountType, setDiscountType] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
// Configure FAB to navigate to new coupon page
useFABConfig('coupons');
@@ -64,18 +76,32 @@ export default function CouponsIndex() {
queryClient.invalidateQueries({ queryKey: ['coupons'] });
showSuccessToast(__('Coupon deleted successfully'));
setSelectedIds([]);
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
onError: (error) => {
showErrorToast(error);
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
// Bulk delete
const handleBulkDelete = async () => {
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
const handleDeleteClick = (id?: number) => {
if (id) {
setDeleteTargetId(id);
} else {
setDeleteTargetId(null);
}
setShowDeleteDialog(true);
};
for (const id of selectedIds) {
await deleteMutation.mutateAsync(id);
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate(deleteTargetId);
} else {
for (const id of selectedIds) {
deleteMutation.mutate(id);
}
}
};
@@ -169,14 +195,15 @@ export default function CouponsIndex() {
<div className="flex gap-3">
{/* Delete - Show only when items selected */}
{selectedIds.length > 0 && (
<button
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
onClick={handleBulkDelete}
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteClick()}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete')} ({selectedIds.length})
</button>
</Button>
)}
{/* Refresh - Always visible (REQUIRED per SOP) */}
@@ -319,11 +346,7 @@ export default function CouponsIndex() {
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this coupon?'))) {
deleteMutation.mutate(coupon.id);
}
}}
onClick={() => handleDeleteClick(coupon.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}

View File

@@ -5,7 +5,16 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n';
@@ -38,15 +47,22 @@ export default function Subscribers() {
});
const deleteSubscriber = useMutation({
mutationFn: async (email: string) => {
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
mutationFn: async (emails: string[]) => {
for (const email of emails) {
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
toast.success(__('Subscriber removed successfully'));
toast.success(__('Subscriber(s) removed successfully'));
setSelectedIds([]);
setShowDeleteDialog(false);
setDeleteTargetEmail(null);
},
onError: () => {
toast.error(__('Failed to remove subscriber'));
toast.error(__('Failed to remove subscriber(s)'));
setShowDeleteDialog(false);
setDeleteTargetEmail(null);
},
});
@@ -73,8 +89,7 @@ export default function Subscribers() {
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
);
// Checkbox logic
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const toggleAll = () => {
if (selectedIds.length === filteredSubscribers.length) {
@@ -90,18 +105,13 @@ export default function Subscribers() {
);
};
const handleBulkDelete = async () => {
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
for (const email of selectedIds) {
await deleteSubscriber.mutateAsync(email);
}
setSelectedIds([]);
const confirmDelete = () => {
const emailsToDelete = deleteTargetEmail ? [deleteTargetEmail] : selectedIds;
deleteSubscriber.mutate(emailsToDelete);
};
return (
<div className="space-y-6">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
@@ -114,7 +124,7 @@ export default function Subscribers() {
</div>
<div className="flex gap-2">
{selectedIds.length > 0 && (
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
<Button onClick={() => setShowDeleteDialog(true)} variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')} ({selectedIds.length})
</Button>
@@ -126,96 +136,108 @@ export default function Subscribers() {
</div>
</div >
{/* Subscribers Table */}
{
isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
<Checkbox
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<TableHead>{__('Email')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Subscribed Date')}</TableHead>
<TableHead>{__('WP User')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="p-3">
<Checkbox
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
checked={selectedIds.includes(subscriber.email)}
onCheckedChange={() => toggleRow(subscriber.email)}
aria-label={__('Select subscriber')}
/>
</TableHead>
<TableHead>{__('Email')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Subscribed Date')}</TableHead>
<TableHead>{__('WP User')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableCell>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{subscriber.status || __('Active')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">{__('No')}</span>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setDeleteTargetEmail(subscriber.email);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Remove')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="p-3">
<Checkbox
checked={selectedIds.includes(subscriber.email)}
onCheckedChange={() => toggleRow(subscriber.email)}
aria-label={__('Select subscriber')}
/>
</TableCell>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{subscriber.status || __('Active')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">{__('No')}</span>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
deleteSubscriber.mutate(subscriber.email);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Remove')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
))}
</TableBody>
</Table>
</div>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This action cannot be undone. This will permanently remove the selected subscriber(s).')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive hover:bg-destructive/90">
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Email Template Settings */}
<SettingsCard
title={__('Email Templates')}
description={__('Customize newsletter email templates using the email builder')}

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, OrdersApi } from '@/lib/api';
import { api } from '@/lib/api';
import { OrdersApi } from '@/lib/api/orders';
import { formatRelativeOrDate } from '@/lib/dates';
import { formatMoney } from '@/lib/currency';
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';

View File

@@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import OrderForm from '@/routes/Orders/partials/OrderForm';
import { OrdersApi } from '@/lib/api';
import { api } from '@/lib/api';
import { OrdersApi } from '@/lib/api/orders';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { OrdersApi } from '@/lib/api';
import { api } from '@/lib/api';
import { OrdersApi } from '@/lib/api/orders';
import { useNavigate } from 'react-router-dom';
import OrderForm from '@/routes/Orders/partials/OrderForm';
import { getStoreCurrency } from '@/lib/currency';

View File

@@ -1,478 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, PackageOpen, Trash2 } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
import { useFABConfig } from '@/hooks/useFABConfig';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { formatRelativeOrDate } from "@/lib/dates";
import { Link, useNavigate } from 'react-router-dom';
function ItemsCell({ row }: { row: any }) {
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
const brief: string = row.items_brief || '';
const linesTotal: number | undefined = typeof row.lines_total === 'number' ? row.lines_total : undefined;
const linesPreview: number | undefined = typeof row.lines_preview === 'number' ? row.lines_preview : undefined;
const extra = linesTotal && linesPreview ? Math.max(0, linesTotal - linesPreview) : 0;
const label = `${count || '—'} item${count === 1 ? '' : 's'}`;
const inline = brief + (extra > 0 ? ` +${extra} more` : '');
return (
<div className="max-w-[280px] whitespace-nowrap overflow-hidden text-ellipsis">
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<span className="cursor-help">
{label}
{inline ? <> · {inline}</> : null}
</span>
</HoverCardTrigger>
<HoverCardContent className="max-w-sm text-sm">
<div className="font-medium mb-1">{label}</div>
<div className="opacity-80 leading-relaxed">
{row.items_full || brief || 'No items'}
</div>
</HoverCardContent>
</HoverCard>
</div>
);
}
import { Skeleton } from '@/components/ui/skeleton';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import DateRange from '@/components/filters/DateRange';
import OrderBy from '@/components/filters/OrderBy';
import { setQuery, getQuery } from '@/lib/query-params';
const statusStyle: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800',
processing: 'bg-blue-100 text-blue-800',
completed: 'bg-emerald-100 text-emerald-800',
'on-hold': 'bg-slate-200 text-slate-800',
cancelled: 'bg-zinc-200 text-zinc-800',
refunded: 'bg-purple-100 text-purple-800',
failed: 'bg-rose-100 text-rose-800',
};
function StatusBadge({ value }: { value?: string }) {
const v = (value || '').toLowerCase();
const cls = statusStyle[v] || 'bg-slate-100 text-slate-800';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${cls}`}>{v || 'unknown'}</span>
);
}
export default function Orders() {
useFABConfig('orders'); // Add FAB for creating orders
const initial = getQuery();
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const perPage = 20;
React.useEffect(() => {
setQuery({ page, status, date_start: dateStart, date_end: dateEnd, orderby, order });
}, [page, status, dateStart, dateEnd, orderby, order]);
const q = useQuery({
queryKey: ['orders', { page, perPage, status, dateStart, dateEnd, orderby, order }],
queryFn: () => api.get('/orders', {
page, per_page: perPage,
status,
date_start: dateStart,
date_end: dateEnd,
orderby,
order,
}),
placeholderData: keepPreviousData,
});
const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number };
const nav = useNavigate();
const store = getStoreCurrency();
// Bulk delete mutation
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(
ids.map(id => api.del(`/orders/${id}`))
);
const failed = results.filter(r => r.status === 'rejected').length;
return { total: ids.length, failed };
},
onSuccess: (result) => {
const { total, failed } = result;
if (failed === 0) {
toast.success(__('Orders deleted successfully'));
} else if (failed < total) {
toast.warning(__(`${total - failed} orders deleted, ${failed} failed`));
} else {
toast.error(__('Failed to delete orders'));
}
setSelectedIds([]);
setShowDeleteDialog(false);
q.refetch();
},
onError: () => {
toast.error(__('Failed to delete orders'));
setShowDeleteDialog(false);
},
});
// Checkbox handlers
const allIds = data?.rows?.map(r => r.id) || [];
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
const toggleAll = () => {
if (allSelected) {
setSelectedIds([]);
} else {
setSelectedIds(allIds);
}
};
const toggleRow = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleDeleteClick = () => {
if (selectedIds.length > 0) {
setShowDeleteDialog(true);
}
};
const confirmDelete = () => {
deleteMutation.mutate(selectedIds);
};
return (
<div className="space-y-4 w-[100%]">
<div className="rounded-lg border border-border p-4 bg-card flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3 w-full">
<div className="flex gap-3 justify-between">
<button className="border rounded-md px-3 py-2 text-sm bg-black text-white disabled:opacity-50" onClick={() => nav('/orders/new')}>
{__('New order')}
</button>
{selectedIds.length > 0 && (
<button
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{__('Delete')} ({selectedIds.length})
</button>
)}
{/* Mobile: condensed Filters button with HoverCard */}
<div className="flex items-center gap-2 lg:hidden">
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<button className="border rounded-md px-3 py-2 text-sm inline-flex items-center gap-2">
<Filter className="w-4 h-4" />
{__('Filters')}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-[calc(100vw-2rem)] mr-6 max-w-sm p-3 space-y-3">
<div className="flex items-center gap-2">
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : (v as typeof status));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="processing">{__('Processing')}</SelectItem>
<SelectItem value="completed">{__('Completed')}</SelectItem>
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<DateRange
value={{ date_start: dateStart, date_end: dateEnd }}
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
/>
<OrderBy
value={{ orderby, order }}
onChange={(v) => {
setPage(1);
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
}}
/>
<div className="flex justify-between items-center">
{(status || dateStart || dateEnd || orderby !== 'date' || order !== 'desc') ? (
<button
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
onClick={() => {
setStatus(undefined);
setDateStart(undefined);
setDateEnd(undefined);
setOrderby('date');
setOrder('desc');
setPage(1);
}}
>
{__('Reset')}
</button>
) : <span />}
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
{/* Desktop: full inline filters */}
<div className="hidden lg:flex gap-2 items-center">
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2">
<Filter className="w-4 h-4 opacity-60" />
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : (v as typeof status));
}}
>
<SelectTrigger className="min-w-[140px]">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="processing">{__('Processing')}</SelectItem>
<SelectItem value="completed">{__('Completed')}</SelectItem>
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<DateRange
value={{ date_start: dateStart, date_end: dateEnd }}
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
/>
<OrderBy
value={{ orderby, order }}
onChange={(v) => {
setPage(1);
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
}}
/>
</div>
{status && (
<button
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
onClick={() => {
setStatus(undefined);
setDateStart(undefined);
setDateEnd(undefined);
setOrderby('date');
setOrder('desc');
setPage(1);
}}
>
{__('Reset')}
</button>
)}
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
</div>
</div>
<div className="rounded-lg border border-border bg-card overflow-auto">
{q.isLoading && (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="w-full h-6" />
))}
</div>
)}
{q.isError && (
<ErrorCard
title={__('Failed to load orders')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
)}
{!q.isLoading && !q.isError && (
<table className="min-w-[800px] w-full text-sm">
<thead className="border-b">
<tr className="text-left">
<th className="px-3 py-2 w-12">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
className={someSelected ? 'data-[state=checked]:bg-gray-400' : ''}
/>
</th>
<th className="px-3 py-2">{__('Order')}</th>
<th className="px-3 py-2">{__('Date')}</th>
<th className="px-3 py-2">{__('Customer')}</th>
<th className="px-3 py-2">{__('Items')}</th>
<th className="px-3 py-2">{__('Status')}</th>
<th className="px-3 py-2 text-right">{__('Total')}</th>
<th className="px-3 py-2 text-center">{__('Actions')}</th>
</tr>
</thead>
<tbody>
{data?.rows?.map((row) => (
<tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2">
<Checkbox
checked={selectedIds.includes(row.id)}
onCheckedChange={() => toggleRow(row.id)}
aria-label={__('Select order')}
/>
</td>
<td className="px-3 py-2">
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
</td>
<td className="px-3 py-2 min-w-32">
<span title={row.date ?? ""}>
{formatRelativeOrDate(row.date_ts)}
</span>
</td>
<td className="px-3 py-2">{row.customer || '—'}</td>
<td className="px-3 py-2">
<ItemsCell row={row} />
</td>
<td className="px-3 py-2"><StatusBadge value={row.status} /></td>
<td className="px-3 py-2 text-right tabular-nums font-mono">
{formatMoney(row.total, {
currency: row.currency || store.currency,
symbol: row.currency_symbol || store.symbol,
thousandSep: store.thousand_sep,
decimalSep: store.decimal_sep,
position: store.position,
decimals: store.decimals,
})}
</td>
<td className="px-3 py-2 text-center space-x-2">
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && (
<tr>
<td className="px-3 py-12 text-center" colSpan={8}>
<div className="flex flex-col items-center gap-2">
<PackageOpen className="w-8 h-8 opacity-40" />
<div className="font-medium">{__('No orders found')}</div>
{status ? (
<p className="text-sm opacity-70">{__('Try adjusting filters.')}</p>
) : (
<p className="text-sm opacity-70">{__('Once you receive orders, they\'ll show up here.')}</p>
)}
</div>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
<div className="flex items-center gap-2">
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{__('Previous')}
</button>
<div className="text-sm opacity-80">{__('Page')} {page}</div>
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={!data || page * perPage >= data.total}
onClick={() => setPage((p) => p + 1)}
>
{__('Next')}
</button>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Orders')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('order') : __('orders')}?
<br />
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -45,6 +45,7 @@ import { setQuery, getQuery } from '@/lib/query-params';
import { OrderCard } from './components/OrderCard';
import { FilterBottomSheet } from './components/FilterBottomSheet';
import { SearchBar } from './components/SearchBar';
import { Pagination } from '@/components/Pagination';
function ItemsCell({ row }: { row: any }) {
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
@@ -517,24 +518,14 @@ export default function Orders() {
)}
{/* Pagination */}
{!q.isLoading && !q.isError && filteredOrders.length > 0 && (
<div className="flex items-center justify-center gap-2 px-4 md:px-0">
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{__('Previous')}
</button>
<div className="text-sm opacity-80">{__('Page')} {page}</div>
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={!data || page * perPage >= data.total}
onClick={() => setPage((p) => p + 1)}
>
{__('Next')}
</button>
</div>
{!q.isLoading && !q.isError && filteredOrders.length > 0 && data && (
<Pagination
page={page}
perPage={perPage}
total={data.total}
onPageChange={setPage}
className="px-4 md:px-0"
/>
)}
{/* Mobile: Filter Bottom Sheet */}

View File

@@ -24,7 +24,9 @@ type ProductSearchItem = {
import * as React from 'react';
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
import { useQuery } from '@tanstack/react-query';
import { api, ProductsApi, CustomersApi } from '@/lib/api';
import { api } from '@/lib/api';
import { ProductsApi } from '@/lib/api/products';
import { CustomersApi } from '@/lib/api/customers';
import { cn } from '@/lib/utils';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';

View File

@@ -2,7 +2,16 @@ import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -72,9 +81,13 @@ export default function ProductAttributes() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
toast.success(__('Attribute deleted successfully'));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete attribute'));
toast.error(__('Failed to delete attribute: ') + (error?.message || ''));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
@@ -110,9 +123,14 @@ export default function ProductAttributes() {
}
};
const handleDelete = (id: number) => {
if (confirm(__('Are you sure you want to delete this attribute?'))) {
deleteMutation.mutate(id);
const handleDeleteClick = (id: number) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate(deleteTargetId);
}
};
@@ -182,7 +200,7 @@ export default function ProductAttributes() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(attribute.attribute_id)}
onClick={() => handleDeleteClick(attribute.attribute_id)}
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@@ -2,7 +2,16 @@ import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -65,9 +74,13 @@ export default function ProductCategories() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
toast.success(__('Category deleted successfully'));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete category'));
toast.error(__('Failed to delete category: ') + error.message);
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
@@ -102,9 +115,14 @@ export default function ProductCategories() {
}
};
const handleDelete = (id: number) => {
if (confirm(__('Are you sure you want to delete this category?'))) {
deleteMutation.mutate(id);
const handleDeleteClick = (id: number) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate(deleteTargetId);
}
};
@@ -175,7 +193,7 @@ export default function ProductCategories() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(category.term_id)}
onClick={() => handleDeleteClick(category.term_id)}
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@@ -2,8 +2,18 @@ import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { api } from '@/lib/api';
@@ -24,6 +34,8 @@ export default function ProductTags() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [formData, setFormData] = useState({ name: '', slug: '', description: '' });
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const { data: tags = [], isLoading } = useQuery<Tag[]>({
queryKey: ['product-tags'],
@@ -64,9 +76,13 @@ export default function ProductTags() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
toast.success(__('Tag deleted successfully'));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete tag'));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
@@ -100,9 +116,14 @@ export default function ProductTags() {
}
};
const handleDelete = (id: number) => {
if (confirm(__('Are you sure you want to delete this tag?'))) {
deleteMutation.mutate(id);
const handleDeleteClick = (id: number) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate(deleteTargetId);
}
};
@@ -173,7 +194,7 @@ export default function ProductTags() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(tag.term_id)}
onClick={() => handleDeleteClick(tag.term_id)}
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { setQuery, getQuery } from '@/lib/query-params';
import { ProductCard } from './components/ProductCard';
import { FilterBottomSheet } from './components/FilterBottomSheet';
import { Pagination } from '@/components/Pagination';
import { SearchBar } from './components/SearchBar';
const stockStatusStyle: Record<string, string> = {
@@ -144,7 +145,7 @@ export default function Products() {
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(
ids.map(id => api.del(`/products/${id}/edit`))
ids.map(id => api.del(`/products/${id}`))
);
const failed = results.filter(r => r.status === 'rejected').length;
return { total: ids.length, failed };
@@ -481,30 +482,13 @@ export default function Products() {
)}
{/* Pagination */}
{data && data.total > perPage && (
<div className="flex justify-between items-center pt-4">
<div className="text-sm text-muted-foreground">
{__('Showing')} {((page - 1) * perPage) + 1} - {Math.min(page * perPage, data.total)} {__('of')} {data.total}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
{__('Previous')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={!data || page * perPage >= data.total}
>
{__('Next')}
</Button>
</div>
</div>
{data && (
<Pagination
page={page}
perPage={perPage}
total={data.total}
onPageChange={setPage}
/>
)}
{/* Delete Dialog */}

View File

@@ -421,27 +421,31 @@ export function GeneralTab({
<div className="space-y-3">
<Label>{__('Additional Options')}</Label>
<div className="flex flex-col gap-3">
<div className="flex items-center space-x-2">
<Checkbox
id="virtual"
checked={virtual}
onCheckedChange={(checked) => setVirtual(checked as boolean)}
/>
<Label htmlFor="virtual" className="cursor-pointer font-normal">
{__('Virtual product (no shipping required)')}
</Label>
</div>
{type === 'simple' && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="virtual"
checked={virtual}
onCheckedChange={(checked) => setVirtual(checked as boolean)}
/>
<Label htmlFor="virtual" className="cursor-pointer font-normal">
{__('Virtual product (no shipping required)')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="downloadable"
checked={downloadable}
onCheckedChange={(checked) => setDownloadable(checked as boolean)}
/>
<Label htmlFor="downloadable" className="cursor-pointer font-normal">
{__('Downloadable product')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="downloadable"
checked={downloadable}
onCheckedChange={(checked) => setDownloadable(checked as boolean)}
/>
<Label htmlFor="downloadable" className="cursor-pointer font-normal">
{__('Downloadable product')}
</Label>
</div>
</>
)}
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -20,6 +20,8 @@ export type ProductVariant = {
sale_price?: string;
stock_quantity?: number;
manage_stock?: boolean;
virtual?: boolean;
downloadable?: boolean;
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
image?: string;
license_duration_days?: string;
@@ -111,6 +113,8 @@ export function VariationsTab({
sale_price: '',
stock_quantity: 0,
manage_stock: false,
virtual: false,
downloadable: false,
stock_status: 'instock',
}));
@@ -297,7 +301,55 @@ export function VariationsTab({
{__('Override license duration for this variation. 0 = never expires.')}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Variation Options */}
<div className="flex flex-wrap gap-4 pt-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`virtual-${index}`}
checked={variation.virtual || false}
onCheckedChange={(checked) => {
const updated = [...variations];
updated[index].virtual = checked as boolean;
setVariations(updated);
}}
/>
<Label htmlFor={`virtual-${index}`} className="cursor-pointer font-normal text-xs">
{__('Virtual')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`downloadable-${index}`}
checked={variation.downloadable || false}
onCheckedChange={(checked) => {
const updated = [...variations];
updated[index].downloadable = checked as boolean;
setVariations(updated);
}}
/>
<Label htmlFor={`downloadable-${index}`} className="cursor-pointer font-normal text-xs">
{__('Downloadable')}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`manage-stock-${index}`}
checked={variation.manage_stock || false}
onCheckedChange={(checked) => {
const updated = [...variations];
updated[index].manage_stock = checked as boolean;
setVariations(updated);
}}
/>
<Label htmlFor={`manage-stock-${index}`} className="cursor-pointer font-normal text-xs">
{__('Manage Stock')}
</Label>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-2">
<Input
placeholder={__('SKU')}
value={variation.sku || ''}

View File

@@ -6,7 +6,16 @@ import { SettingsCard } from './components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { MapPin, Plus, Trash2, RefreshCw, Edit } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { __ } from '@/lib/i18n';
interface PickupLocation {
@@ -25,6 +34,8 @@ export default function LocalPickupSettings() {
const queryClient = useQueryClient();
const [showDialog, setShowDialog] = useState(false);
const [editingLocation, setEditingLocation] = useState<PickupLocation | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Fetch pickup locations
const { data: locations = [], isLoading, refetch } = useQuery({
@@ -58,13 +69,28 @@ export default function LocalPickupSettings() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pickup-locations'] });
toast.success(__('Pickup location deleted'));
toast.success(__('Location deleted successfully'));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete location'));
toast.error(__('Failed to delete location: ') + (error?.message || ''));
setShowDeleteDialog(false);
setDeleteTargetId(null);
},
});
const handleDeleteClick = (id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (deleteTargetId) {
deleteMutation.mutate(deleteTargetId);
}
};
// Toggle location mutation
const toggleMutation = useMutation({
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
@@ -228,11 +254,7 @@ export default function LocalPickupSettings() {
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(__('Are you sure you want to delete this location?'))) {
deleteMutation.mutate(location.id);
}
}}
onClick={() => handleDeleteClick(location.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />

View File

@@ -12,6 +12,16 @@ import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { markdownToHtml } from '@/lib/markdown-utils';
@@ -19,6 +29,7 @@ import { markdownToHtml } from '@/lib/markdown-utils';
export default function EditTemplate() {
// Mobile responsive check
const [isMobile, setIsMobile] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
@@ -59,22 +70,16 @@ export default function EditTemplate() {
const { data: template, isLoading, error } = useQuery({
queryKey: ['notification-template', eventId, channelId, recipientType],
queryFn: async () => {
console.log('Fetching template for:', eventId, channelId, recipientType);
const response = await api.get(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
console.log('API Response:', response);
console.log('API Response.data:', response.data);
console.log('API Response type:', typeof response);
// The api.get might already unwrap response.data
// Return the response directly if it has the template fields
if (response && (response.subject !== undefined || response.body !== undefined)) {
console.log('Returning response directly:', response);
return response;
}
// Otherwise return response.data
if (response && response.data) {
console.log('Returning response.data:', response.data);
return response.data;
}
@@ -112,9 +117,11 @@ export default function EditTemplate() {
}
};
const handleReset = async () => {
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
const handleReset = () => {
setShowResetDialog(true);
};
const confirmReset = async () => {
try {
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
@@ -122,6 +129,8 @@ export default function EditTemplate() {
toast.success(__('Template reset to default'));
} catch (error: any) {
toast.error(error?.message || __('Failed to reset template'));
} finally {
setShowResetDialog(false);
}
};
@@ -419,19 +428,6 @@ export default function EditTemplate() {
`;
};
// Helper function to get social icon emoji
const getSocialIcon = (platform: string) => {
const icons: Record<string, string> = {
facebook: '📘',
twitter: '🐦',
instagram: '📷',
linkedin: '💼',
youtube: '📺',
website: '🌐',
};
return icons[platform] || '🔗';
};
if (!eventId || !channelId) {
return (
<SettingsLayout
@@ -592,6 +588,26 @@ export default function EditTemplate() {
</Card>
</SettingsLayout>
{/* Reset Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Reset Template')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to reset this template to default? This will clear any custom content you have set. This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowResetDialog(false)}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={confirmReset} className="bg-destructive hover:bg-destructive/90">
{__('Reset')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Send Test Email Dialog */}
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
<DialogContent className="sm:max-w-md">

View File

@@ -7,6 +7,16 @@ import { SettingsCard } from '../components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { __ } from '@/lib/i18n';
import { ArrowLeft, RefreshCw, Upload, Plus, Trash2, Facebook, Twitter, Instagram, Linkedin, Youtube, Globe, MessageCircle, Music, Send, AtSign } from 'lucide-react';
import { toast } from 'sonner';
@@ -36,6 +46,7 @@ interface EmailSettings {
export default function EmailCustomization() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showResetDialog, setShowResetDialog] = useState(false);
// Fetch email settings
const { data: settings, isLoading } = useQuery({
@@ -99,6 +110,7 @@ export default function EmailCustomization() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-settings'] });
toast.success(__('Email settings reset to defaults'));
setShowResetDialog(false);
},
onError: (error: any) => {
toast.error(error.message || __('Failed to reset email settings'));
@@ -115,8 +127,7 @@ export default function EmailCustomization() {
};
const handleReset = () => {
if (!confirm(__('Are you sure you want to reset all email customization to defaults?'))) return;
resetMutation.mutate();
setShowResetDialog(true);
};
const handleChange = (field: keyof EmailSettings, value: string) => {
@@ -154,24 +165,6 @@ export default function EmailCustomization() {
}));
};
const getSocialIcon = (platform: string) => {
const icons: Record<string, any> = {
facebook: Facebook,
x: AtSign,
instagram: Instagram,
linkedin: Linkedin,
youtube: Youtube,
discord: MessageCircle,
spotify: Music,
telegram: Send,
whatsapp: MessageCircle,
threads: AtSign,
website: Globe,
};
const Icon = icons[platform] || Globe;
return <Icon className="h-4 w-4" />;
};
if (isLoading) {
return (
<SettingsLayout
@@ -494,6 +487,26 @@ export default function EmailCustomization() {
<strong>{__('Note:')}</strong> {__('These settings will apply to all email templates. Individual templates can still override specific content, but colors and branding will be consistent across all emails.')}
</p>
</div>
{/* Reset Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Reset to Defaults')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to reset all email customization to defaults? This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowResetDialog(false)} disabled={resetMutation.isPending}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={() => resetMutation.mutate()} disabled={resetMutation.isPending} className="bg-destructive hover:bg-destructive/90">
{resetMutation.isPending ? __('Resetting...') : __('Reset')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SettingsLayout>
);

View File

@@ -14,6 +14,17 @@ import { ColorPicker } from '@/components/ui/color-picker';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import flagsData from '@/data/flags.json';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
// Convert country code to emoji flag
function countryCodeToEmoji(countryCode: string): string {
@@ -194,6 +205,23 @@ export default function StoreDetailsPage() {
await saveMutation.mutateAsync(settings);
};
const isDirty = useMemo(() => {
return JSON.stringify(settings) !== JSON.stringify(initialSettings);
}, [settings, initialSettings]);
// Keyboard shortcut listener
useEffect(() => {
const handleSaveShortcut = () => {
if (isDirty && !saveMutation.isPending) {
handleSave();
}
};
window.addEventListener('woonoow:shortcut:save', handleSaveShortcut);
return () => window.removeEventListener('woonoow:shortcut:save', handleSaveShortcut);
}, [isDirty, saveMutation.isPending, handleSave]);
const { showPrompt, confirmNavigation, cancelNavigation } = useUnsavedChanges(isDirty);
const updateSetting = <K extends keyof StoreSettings>(
key: K,
value: StoreSettings[K]
@@ -626,6 +654,23 @@ export default function StoreDetailsPage() {
</SettingsSection>
</div>
</SettingsCard>
<AlertDialog open={showPrompt} onOpenChange={cancelNavigation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to leave this page?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelNavigation}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmNavigation} className="bg-destructive hover:bg-destructive/90">
Leave Page
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsLayout>
);
}

View File

@@ -1,381 +0,0 @@
import React, { useState, useEffect } from 'react';
import { SettingsLayout } from './components/SettingsLayout';
import { SettingsCard } from './components/SettingsCard';
import { SettingsSection } from './components/SettingsSection';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
interface StoreSettings {
storeName: string;
contactEmail: string;
supportEmail: string;
phone: string;
country: string;
address: string;
city: string;
state: string;
postcode: string;
currency: string;
currencyPosition: 'left' | 'right' | 'left_space' | 'right_space';
thousandSep: string;
decimalSep: string;
decimals: number;
timezone: string;
weightUnit: string;
dimensionUnit: string;
}
export default function StoreDetailsPage() {
const [isLoading, setIsLoading] = useState(true);
const [settings, setSettings] = useState<StoreSettings>({
storeName: '',
contactEmail: '',
supportEmail: '',
phone: '',
country: 'ID',
address: '',
city: '',
state: '',
postcode: '',
currency: 'IDR',
currencyPosition: 'left',
thousandSep: ',',
decimalSep: '.',
decimals: 0,
timezone: 'Asia/Jakarta',
weightUnit: 'kg',
dimensionUnit: 'cm',
});
useEffect(() => {
// TODO: Load settings from API
setTimeout(() => {
setSettings({
storeName: 'WooNooW Store',
contactEmail: 'contact@example.com',
supportEmail: 'support@example.com',
phone: '+62 812 3456 7890',
country: 'ID',
address: 'Jl. Example No. 123',
city: 'Jakarta',
state: 'DKI Jakarta',
postcode: '12345',
currency: 'IDR',
currencyPosition: 'left',
thousandSep: '.',
decimalSep: ',',
decimals: 0,
timezone: 'Asia/Jakarta',
weightUnit: 'kg',
dimensionUnit: 'cm',
});
setIsLoading(false);
}, 500);
}, []);
const handleSave = async () => {
// TODO: Save to API
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Your store details have been updated successfully.');
};
const updateSetting = <K extends keyof StoreSettings>(
key: K,
value: StoreSettings[K]
) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
// Currency preview
const formatCurrency = (amount: number) => {
const formatted = amount.toFixed(settings.decimals)
.replace('.', settings.decimalSep)
.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSep);
const symbol = settings.currency === 'IDR' ? 'Rp' : settings.currency === 'USD' ? '$' : '€';
switch (settings.currencyPosition) {
case 'left':
return `${symbol}${formatted}`;
case 'right':
return `${formatted}${symbol}`;
case 'left_space':
return `${symbol} ${formatted}`;
case 'right_space':
return `${formatted} ${symbol}`;
default:
return `${symbol}${formatted}`;
}
};
return (
<SettingsLayout
title="Store Details"
description="Manage your store's basic information and regional settings"
onSave={handleSave}
isLoading={isLoading}
>
{/* Store Identity */}
<SettingsCard
title="Store Identity"
description="Basic information about your store"
>
<SettingsSection label="Store name" required htmlFor="storeName">
<Input
id="storeName"
value={settings.storeName}
onChange={(e) => updateSetting('storeName', e.target.value)}
placeholder="My Awesome Store"
/>
</SettingsSection>
<SettingsSection
label="Contact email"
description="Customers will use this email to contact you"
htmlFor="contactEmail"
>
<Input
id="contactEmail"
type="email"
value={settings.contactEmail}
onChange={(e) => updateSetting('contactEmail', e.target.value)}
placeholder="contact@example.com"
/>
</SettingsSection>
<SettingsSection
label="Customer support email"
description="Separate email for customer support inquiries"
htmlFor="supportEmail"
>
<Input
id="supportEmail"
type="email"
value={settings.supportEmail}
onChange={(e) => updateSetting('supportEmail', e.target.value)}
placeholder="support@example.com"
/>
</SettingsSection>
<SettingsSection
label="Store phone"
description="Optional phone number for customer inquiries"
htmlFor="phone"
>
<Input
id="phone"
type="tel"
value={settings.phone}
onChange={(e) => updateSetting('phone', e.target.value)}
placeholder="+62 812 3456 7890"
/>
</SettingsSection>
</SettingsCard>
{/* Store Address */}
<SettingsCard
title="Store Address"
description="Used for shipping origin, invoices, and tax calculations"
>
<SettingsSection label="Country/Region" required htmlFor="country">
<Select value={settings.country} onValueChange={(v) => updateSetting('country', v)}>
<SelectTrigger id="country">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ID">🇮🇩 Indonesia</SelectItem>
<SelectItem value="US">🇺🇸 United States</SelectItem>
<SelectItem value="SG">🇸🇬 Singapore</SelectItem>
<SelectItem value="MY">🇲🇾 Malaysia</SelectItem>
<SelectItem value="TH">🇹🇭 Thailand</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<SettingsSection label="Street address" htmlFor="address">
<Input
id="address"
value={settings.address}
onChange={(e) => updateSetting('address', e.target.value)}
placeholder="Jl. Example No. 123"
/>
</SettingsSection>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SettingsSection label="City" htmlFor="city">
<Input
id="city"
value={settings.city}
onChange={(e) => updateSetting('city', e.target.value)}
placeholder="Jakarta"
/>
</SettingsSection>
<SettingsSection label="State/Province" htmlFor="state">
<Input
id="state"
value={settings.state}
onChange={(e) => updateSetting('state', e.target.value)}
placeholder="DKI Jakarta"
/>
</SettingsSection>
<SettingsSection label="Postal code" htmlFor="postcode">
<Input
id="postcode"
value={settings.postcode}
onChange={(e) => updateSetting('postcode', e.target.value)}
placeholder="12345"
/>
</SettingsSection>
</div>
</SettingsCard>
{/* Currency & Formatting */}
<SettingsCard
title="Currency & Formatting"
description="How prices are displayed in your store"
>
<SettingsSection label="Currency" required htmlFor="currency">
<Select value={settings.currency} onValueChange={(v) => updateSetting('currency', v)}>
<SelectTrigger id="currency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IDR">Indonesian Rupiah (Rp)</SelectItem>
<SelectItem value="USD">US Dollar ($)</SelectItem>
<SelectItem value="EUR">Euro ()</SelectItem>
<SelectItem value="SGD">Singapore Dollar (S$)</SelectItem>
<SelectItem value="MYR">Malaysian Ringgit (RM)</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<SettingsSection label="Currency position" htmlFor="currencyPosition">
<Select
value={settings.currencyPosition}
onValueChange={(v: any) => updateSetting('currencyPosition', v)}
>
<SelectTrigger id="currencyPosition">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left (Rp1234)</SelectItem>
<SelectItem value="right">Right (1234Rp)</SelectItem>
<SelectItem value="left_space">Left with space (Rp 1234)</SelectItem>
<SelectItem value="right_space">Right with space (1234 Rp)</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SettingsSection label="Thousand separator" htmlFor="thousandSep">
<Input
id="thousandSep"
value={settings.thousandSep}
onChange={(e) => updateSetting('thousandSep', e.target.value)}
maxLength={1}
placeholder=","
/>
</SettingsSection>
<SettingsSection label="Decimal separator" htmlFor="decimalSep">
<Input
id="decimalSep"
value={settings.decimalSep}
onChange={(e) => updateSetting('decimalSep', e.target.value)}
maxLength={1}
placeholder="."
/>
</SettingsSection>
<SettingsSection label="Number of decimals" htmlFor="decimals">
<Select
value={settings.decimals.toString()}
onValueChange={(v) => updateSetting('decimals', parseInt(v))}
>
<SelectTrigger id="decimals">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">0</SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
</div>
{/* Live Preview */}
<div className="mt-4 p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-2">Preview:</p>
<p className="text-2xl font-semibold">{formatCurrency(1234567.89)}</p>
</div>
</SettingsCard>
{/* Standards & Formats */}
<SettingsCard
title="Standards & Formats"
description="Timezone and measurement units"
>
<SettingsSection label="Timezone" htmlFor="timezone">
<Select value={settings.timezone} onValueChange={(v) => updateSetting('timezone', v)}>
<SelectTrigger id="timezone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Asia/Jakarta">Asia/Jakarta (WIB)</SelectItem>
<SelectItem value="Asia/Makassar">Asia/Makassar (WITA)</SelectItem>
<SelectItem value="Asia/Jayapura">Asia/Jayapura (WIT)</SelectItem>
<SelectItem value="Asia/Singapore">Asia/Singapore</SelectItem>
<SelectItem value="America/New_York">America/New_York (EST)</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SettingsSection label="Weight unit" htmlFor="weightUnit">
<Select value={settings.weightUnit} onValueChange={(v) => updateSetting('weightUnit', v)}>
<SelectTrigger id="weightUnit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kg">Kilogram (kg)</SelectItem>
<SelectItem value="g">Gram (g)</SelectItem>
<SelectItem value="lb">Pound (lb)</SelectItem>
<SelectItem value="oz">Ounce (oz)</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<SettingsSection label="Dimension unit" htmlFor="dimensionUnit">
<Select value={settings.dimensionUnit} onValueChange={(v) => updateSetting('dimensionUnit', v)}>
<SelectTrigger id="dimensionUnit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cm">Centimeter (cm)</SelectItem>
<SelectItem value="m">Meter (m)</SelectItem>
<SelectItem value="in">Inch (in)</SelectItem>
<SelectItem value="ft">Foot (ft)</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
</div>
</SettingsCard>
{/* Summary Card */}
<div className="bg-primary/10 border border-primary/20 rounded-lg p-4">
<p className="text-sm font-medium">
🇮🇩 Your store is located in {settings.country === 'ID' ? 'Indonesia' : settings.country}
</p>
<p className="text-sm text-muted-foreground mt-1">
Prices will be displayed in {settings.currency} Timezone: {settings.timezone}
</p>
</div>
</SettingsLayout>
);
}

View File

@@ -56,13 +56,10 @@ export default function TaxSettings() {
// Create tax rate
const createMutation = useMutation({
mutationFn: async (data: any) => {
console.log('[Tax] Creating rate:', data);
const response = await api.post('/settings/tax/rates', data);
console.log('[Tax] Create response:', response);
return response;
},
onSuccess: (data) => {
console.log('[Tax] Create success:', data);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
setShowAddRate(false);
@@ -107,7 +104,6 @@ export default function TaxSettings() {
// Quick add suggested rate
const quickAddMutation = useMutation({
mutationFn: async (suggestedRate: any) => {
console.log('[Tax] Quick adding rate:', suggestedRate);
const response = await api.post('/settings/tax/rates', {
country: suggestedRate.code,
state: '',
@@ -118,11 +114,9 @@ export default function TaxSettings() {
compound: 0,
shipping: 1,
});
console.log('[Tax] Quick add response:', response);
return response;
},
onSuccess: (data) => {
console.log('[Tax] Quick add success:', data);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
toast.success(__('Tax rate added'));

View File

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

View File

@@ -1,18 +1,9 @@
import React, { useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useNavigate, Link } from 'react-router-dom';
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,14 +14,30 @@ import {
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { setQuery, getQuery } from '@/lib/query-params';
import { Pagination } from '@/components/Pagination';
interface Subscription {
id: number;
@@ -51,11 +58,11 @@ interface Subscription {
}
const statusColors: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'pending': 'bg-amber-100 text-amber-800',
'active': 'bg-emerald-100 text-emerald-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'cancelled': 'bg-zinc-200 text-zinc-800',
'expired': 'bg-rose-100 text-rose-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
};
@@ -68,98 +75,97 @@ const statusLabels: Record<string, string> = {
'pending-cancel': __('Pending Cancel'),
};
async function fetchSubscriptions(params: Record<string, string>) {
const url = new URL(window.WNW_API.root + '/subscriptions');
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscriptions');
return res.json();
}
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', reason?: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.WNW_API.nonce,
},
body: JSON.stringify({ reason }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `Failed to ${action} subscription`);
}
return res.json();
function StatusBadge({ value }: { value?: string }) {
const v = (value || '').toLowerCase();
const cls = statusColors[v] || 'bg-slate-100 text-slate-800';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${cls}`}>
{statusLabels[v] || v || 'unknown'}
</span>
);
}
export default function SubscriptionsIndex() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const status = searchParams.get('status') || '';
const page = parseInt(searchParams.get('page') || '1');
const initial = getQuery();
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
const perPage = 20;
useEffect(() => {
setPageHeader(__('Subscriptions'));
return () => clearPageHeader();
}, [setPageHeader, clearPageHeader]);
const { data, isLoading, error } = useQuery({
useEffect(() => {
setQuery({ page, status });
}, [page, status]);
const q = useQuery({
queryKey: ['subscriptions', { status, page }],
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
placeholderData: keepPreviousData,
});
const data = q.data as undefined | { subscriptions: Subscription[]; total: number };
const subscriptions: Subscription[] = data?.subscriptions || [];
const total = data?.total || 0;
// Pull to refresh
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await q.refetch();
setTimeout(() => setIsRefreshing(false), 500);
}, [q]);
// Subscription action mutation (using centralized api)
const actionMutation = useMutation({
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
subscriptionAction(id, action, reason),
mutationFn: ({ id, action, reason }: { id: number; action: string; reason?: string }) =>
api.post(`/subscriptions/${id}/${action}`, { reason }),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
setShowCancelDialog(false);
setCancelTargetId(null);
},
onError: (error: Error) => {
toast.error(error.message);
setShowCancelDialog(false);
},
});
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
const handleAction = (id: number, action: string) => {
if (action === 'cancel') {
setCancelTargetId(id);
setShowCancelDialog(true);
return;
}
actionMutation.mutate({ id, action });
};
const handleStatusFilter = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === 'all') {
params.delete('status');
} else {
params.set('status', value);
const confirmCancel = () => {
if (cancelTargetId) {
actionMutation.mutate({ id: cancelTargetId, action: 'cancel' });
}
params.delete('page');
setSearchParams(params);
};
const subscriptions: Subscription[] = data?.subscriptions || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
// Checkbox logic
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const allIds = subscriptions.map(s => s.id);
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
const toggleAll = () => {
if (selectedIds.length === subscriptions.length) {
if (allSelected) {
setSelectedIds([]);
} else {
setSelectedIds(subscriptions.map(s => s.id));
setSelectedIds(allIds);
}
};
@@ -170,199 +176,330 @@ export default function SubscriptionsIndex() {
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={__('Filter by status')} />
<div className="space-y-4 w-full pb-4">
{/* Desktop: Toolbar Card */}
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
<div className="flex gap-3">
<button
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
onClick={handleRefresh}
disabled={q.isLoading || isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{__('Refresh')}
</button>
</div>
<div className="flex gap-2 items-center">
<Filter className="min-w-4 w-4 h-4 opacity-60" />
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : v);
}}
>
<SelectTrigger className="min-w-[140px]">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="expired">{__('Expired')}</SelectItem>
<SelectItem value="pending-cancel">{__('Pending Cancel')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{status && (
<button
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
onClick={() => { setStatus(undefined); setPage(1); }}
>
{__('Clear filters')}
</button>
)}
<span className="text-sm text-muted-foreground ml-2">
{total} {__('subscriptions')}
</span>
</div>
</div>
</div>
{/* Mobile: Status filter bar */}
<div className="md:hidden">
<div className="flex items-center gap-2">
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : v);
}}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="expired">{__('Expired')}</SelectItem>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="expired">{__('Expired')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
{__('Total')}: {total} {__('subscriptions')}
<button
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
onClick={handleRefresh}
disabled={q.isLoading || isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
<Checkbox
checked={subscriptions.length > 0 && selectedIds.length === subscriptions.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<TableHead className="w-[80px]">{__('ID')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Product')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Billing')}</TableHead>
<TableHead>{__('Next Payment')}</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
</TableRow>
))
) : subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Repeat className="w-8 h-8 opacity-50" />
<p>{__('No subscriptions found')}</p>
</div>
</TableCell>
</TableRow>
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell className="p-3">
<Checkbox
checked={selectedIds.includes(sub.id)}
onCheckedChange={() => toggleRow(sub.id)}
aria-label={__('Select subscription')}
/>
</TableCell>
<TableCell className="font-medium">
<Link to={`/subscriptions/${sub.id}`} className="hover:underline">
#{sub.id}
</Link>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{sub.user_name}</div>
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
</div>
</TableCell>
<TableCell>{sub.product_name}</TableCell>
<TableCell>
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
{statusLabels[sub.status] || sub.status}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">
{sub.billing_schedule}
</div>
</TableCell>
<TableCell>
{sub.next_payment_date ? (
<div className="text-sm">
{new Date(sub.next_payment_date).toLocaleDateString()}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
<Eye className="w-4 h-4 mr-2" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{sub.can_pause && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</DropdownMenuItem>
)}
{sub.can_resume && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</DropdownMenuItem>
)}
{sub.status === 'active' && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</DropdownMenuItem>
)}
{sub.can_cancel && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction(sub.id, 'cancel')}
className="text-red-600"
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page - 1));
setSearchParams(params);
}}
>
{__('Previous')}
</Button>
<span className="text-sm text-muted-foreground">
{__('Page')} {page} {__('of')} {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page + 1));
setSearchParams(params);
}}
>
{__('Next')}
</Button>
{/* Pull to Refresh Indicator */}
{isRefreshing && (
<div className="md:hidden flex justify-center py-2">
<RefreshCw className="w-5 h-5 animate-spin text-primary" />
</div>
)}
{/* Loading State */}
{q.isLoading && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="w-full h-24 rounded-lg" />
))}
</div>
)}
{/* Error State */}
{q.isError && (
<ErrorCard
title={__('Failed to load subscriptions')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
)}
{/* Content */}
{!q.isLoading && !q.isError && (
<>
{/* Mobile: Card List */}
<div className="md:hidden space-y-3">
{subscriptions.length > 0 ? (
subscriptions.map((sub) => (
<Link
key={sub.id}
to={`/subscriptions/${sub.id}`}
className="block rounded-lg border bg-card p-4 hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div>
<div className="font-medium">#{sub.id}</div>
<div className="text-sm text-muted-foreground">{sub.user_name}</div>
</div>
<StatusBadge value={sub.status} />
</div>
<div className="text-sm">{sub.product_name}</div>
<div className="flex items-center justify-between mt-2 text-sm text-muted-foreground">
<span>{sub.billing_schedule}</span>
{sub.next_payment_date && (
<span>{new Date(sub.next_payment_date).toLocaleDateString()}</span>
)}
</div>
</Link>
))
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Repeat className="w-12 h-12 opacity-40 mb-3" />
<div className="font-medium text-lg mb-1">{__('No subscriptions found')}</div>
{status ? (
<p className="text-sm text-muted-foreground">{__('Try adjusting filters.')}</p>
) : (
<p className="text-sm text-muted-foreground">{__('Subscriptions will appear here when customers subscribe.')}</p>
)}
</div>
)}
</div>
{/* Desktop: Table */}
<div className="hidden md:block rounded-lg border overflow-hidden">
<table className="min-w-[800px] w-full text-sm">
<thead className="bg-muted/50">
<tr className="border-b">
<th className="w-12 p-3">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
className={someSelected ? 'data-[state=checked]:bg-gray-400' : ''}
/>
</th>
<th className="text-left p-3 font-medium">{__('ID')}</th>
<th className="text-left p-3 font-medium">{__('Customer')}</th>
<th className="text-left p-3 font-medium">{__('Product')}</th>
<th className="text-left p-3 font-medium">{__('Status')}</th>
<th className="text-left p-3 font-medium">{__('Billing')}</th>
<th className="text-left p-3 font-medium">{__('Next Payment')}</th>
<th className="text-center p-3 font-medium">{__('Actions')}</th>
</tr>
</thead>
<tbody>
{subscriptions.map((sub) => (
<tr key={sub.id} className="border-b hover:bg-muted/30 last:border-0">
<td className="p-3">
<Checkbox
checked={selectedIds.includes(sub.id)}
onCheckedChange={() => toggleRow(sub.id)}
aria-label={__('Select subscription')}
/>
</td>
<td className="p-3">
<Link to={`/subscriptions/${sub.id}`} className="font-medium hover:underline">
#{sub.id}
</Link>
</td>
<td className="p-3">
<div>
<div className="font-medium">{sub.user_name}</div>
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
</div>
</td>
<td className="p-3">{sub.product_name}</td>
<td className="p-3">
<StatusBadge value={sub.status} />
</td>
<td className="p-3">
<div className="text-sm">{sub.billing_schedule}</div>
</td>
<td className="p-3">
{sub.next_payment_date ? (
<div className="text-sm">
{new Date(sub.next_payment_date).toLocaleDateString()}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
<Eye className="w-4 h-4 mr-2" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{sub.can_pause && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</DropdownMenuItem>
)}
{sub.can_resume && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</DropdownMenuItem>
)}
{sub.status === 'active' && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</DropdownMenuItem>
)}
{sub.can_cancel && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction(sub.id, 'cancel')}
className="text-destructive focus:text-destructive"
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
{subscriptions.length === 0 && (
<tr>
<td className="p-8 text-center text-muted-foreground" colSpan={8}>
<div className="flex flex-col items-center gap-2">
<Repeat className="w-8 h-8 opacity-40" />
<div className="font-medium">{__('No subscriptions found')}</div>
{status ? (
<p className="text-sm opacity-70">{__('Try adjusting filters.')}</p>
) : (
<p className="text-sm opacity-70">{__('Subscriptions will appear here when customers subscribe.')}</p>
)}
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
{/* Pagination */}
{!q.isLoading && !q.isError && data && (
<Pagination
page={page}
perPage={perPage}
total={total}
onPageChange={setPage}
/>
)}
{/* Cancel Confirmation Dialog (replaces native confirm()) */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Cancel Subscription')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to cancel this subscription?')}
<br />
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => { setShowCancelDialog(false); setCancelTargetId(null); }}
disabled={actionMutation.isPending}
>
{__('Keep Subscription')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmCancel}
disabled={actionMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{actionMutation.isPending ? __('Cancelling...') : __('Cancel Subscription')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -45,6 +45,7 @@ interface WNW_CONFIG {
customerSpaEnabled?: boolean;
nonce?: string;
pluginUrl?: string;
onboardingCompleted?: boolean;
}
interface WNW_Store {
@@ -66,6 +67,18 @@ declare global {
WNW_WC_MENUS?: WNW_WC_MENUS;
WNW_CONFIG?: WNW_CONFIG;
WNW_STORE?: WNW_Store;
WNW_NAV_TREE?: Array<{
key: string;
path: string;
icon: string;
label: string;
children?: any[];
}>;
WNW_ADDON_ROUTES?: Array<{
path: string;
component_url: string;
props?: Record<string, any>;
}>;
}
}

View File

@@ -2,7 +2,7 @@
module.exports = {
darkMode: ["class"],
important: '#woonoow-admin-app',
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}", "../customer-spa/src/**/*.{ts,tsx}"],
theme: {
container: { center: true, padding: "1rem" },
extend: {

View File

@@ -14,7 +14,8 @@
"allowJs": false,
"types": [],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
"paths": { "@/*": ["./src/*"] },
"ignoreDeprecations": "6.0"
},
"include": ["src"]
}

View File

@@ -8,7 +8,10 @@ const cert = fs.readFileSync(path.resolve(__dirname, '.cert/woonoow.local-cert.p
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom', 'lucide-react', '@tanstack/react-query']
},
server: {
host: 'woonoow.local',
port: 5173,