Files
WooNooW/admin-spa/src/App.tsx
Dwindi Ramadhana 3d5191aab3 feat: add Newsletter Campaigns frontend UI
- Add Campaigns list page with table, status badges, search, actions
- Add Campaign editor with title, subject, content fields
- Add preview modal, test email dialog, send confirmation
- Update Marketing index to show hub with Newsletter, Campaigns, Coupons cards
- Add routes in App.tsx
2025-12-31 18:59:49 +07:00

769 lines
31 KiB
TypeScript

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 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 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 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2 } 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 { initializeWindowAPI } from '@/lib/windowAPI';
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;
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>
);
}
function Sidebar() {
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";
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,
};
// Get navigation tree from backend
const navTree = (window as any).WNW_NAV_TREE || [];
return (
<aside className="w-56 flex-shrink-0 p-3 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background">
<nav className="flex flex-col gap-1">
{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>{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,
};
// Get navigation tree from backend
const navTree = (window as any).WNW_NAV_TREE || [];
return (
<div 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 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 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 MarketingIndex from '@/routes/Marketing';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
import CampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import MorePage from '@/routes/More';
// 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>
)}
</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>
<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>
</>
)}
<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>
);
}
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="/dashboard" replace />} />
<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 />} />
{/* 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 />} />
{/* 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/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/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 />} />
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
<Route path="/marketing/campaigns" element={<CampaignsList />} />
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
{/* 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);
// 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';
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 />
<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>
);
}
export default function App() {
// Initialize Window API for addon developers
React.useEffect(() => {
initializeWindowAPI();
}, []);
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>
</QueryClientProvider>
);
}