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:
@@ -1 +0,0 @@
|
||||
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
48
admin-spa/src/components/Pagination.tsx
Normal file
48
admin-spa/src/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
admin-spa/src/components/ProductCard.tsx
Normal file
1
admin-spa/src/components/ProductCard.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }
|
||||
238
admin-spa/src/components/SharedContentLayout.tsx
Normal file
238
admin-spa/src/components/SharedContentLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
269
admin-spa/src/components/layout/AppRoutes.tsx
Normal file
269
admin-spa/src/components/layout/AppRoutes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
admin-spa/src/components/layout/AuthWrapper.tsx
Normal file
56
admin-spa/src/components/layout/AuthWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
admin-spa/src/components/layout/Header.tsx
Normal file
211
admin-spa/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
admin-spa/src/components/layout/Shell.tsx
Normal file
162
admin-spa/src/components/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
admin-spa/src/components/layout/Sidebar.tsx
Normal file
54
admin-spa/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
admin-spa/src/components/layout/TopNav.tsx
Normal file
36
admin-spa/src/components/layout/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
45
admin-spa/src/hooks/useFullscreen.ts
Normal file
45
admin-spa/src/hooks/useFullscreen.ts
Normal 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;
|
||||
}
|
||||
17
admin-spa/src/hooks/useIsDesktop.ts
Normal file
17
admin-spa/src/hooks/useIsDesktop.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
52
admin-spa/src/hooks/useUnsavedChanges.ts
Normal file
52
admin-spa/src/hooks/useUnsavedChanges.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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) || [];
|
||||
}
|
||||
}
|
||||
2
admin-spa/src/lib/api/client.ts
Normal file
2
admin-spa/src/lib/api/client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { api } from '../api';
|
||||
export const apiClient = api;
|
||||
@@ -62,7 +62,7 @@ export const CouponsApi = {
|
||||
search?: string;
|
||||
discount_type?: string;
|
||||
}): Promise<CouponListResponse> => {
|
||||
return api.get('/coupons', { params });
|
||||
return api.get('/coupons', params);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
23
admin-spa/src/lib/api/orders.ts
Normal file
23
admin-spa/src/lib/api/orders.ts
Normal 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'),
|
||||
};
|
||||
12
admin-spa/src/lib/api/products.ts
Normal file
12
admin-spa/src/lib/api/products.ts
Normal 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'),
|
||||
};
|
||||
1
admin-spa/src/lib/cart/store.ts
Normal file
1
admin-spa/src/lib/cart/store.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const useCartStore = () => ({ addToCart: () => {}, isAdding: false });
|
||||
@@ -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';
|
||||
|
||||
26
admin-spa/src/lib/nav-icons.ts
Normal file
26
admin-spa/src/lib/nav-icons.ts
Normal 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,
|
||||
};
|
||||
85
admin-spa/src/lib/sectionStyles.ts
Normal file
85
admin-spa/src/lib/sectionStyles.ts
Normal 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';
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
301
admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts
Normal file
301
admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>[] = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ''}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
admin-spa/src/types/window.d.ts
vendored
13
admin-spa/src/types/window.d.ts
vendored
@@ -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>;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"allowJs": false,
|
||||
"types": [],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user