feat: add Newsletter Campaigns frontend UI
- Add Campaigns list page with table, status badges, search, actions - Add Campaign editor with title, subject, content fields - Add preview modal, test email dialog, send confirmation - Update Marketing index to show hub with Newsletter, Campaigns, Coupons cards - Add routes in App.tsx
This commit is contained in:
@@ -101,12 +101,12 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
||||
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)
|
||||
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;
|
||||
@@ -115,7 +115,7 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
||||
} else if (starts) {
|
||||
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
||||
}
|
||||
|
||||
|
||||
const mergedActive = nav.isActive || activeByPath;
|
||||
if (typeof className === 'function') {
|
||||
// Preserve caller pattern: className receives { isActive }
|
||||
@@ -133,7 +133,7 @@ function Sidebar() {
|
||||
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
|
||||
const active = "bg-secondary";
|
||||
const { main } = useActiveSection();
|
||||
|
||||
|
||||
// Icon mapping
|
||||
const iconMap: Record<string, any> = {
|
||||
'layout-dashboard': LayoutDashboard,
|
||||
@@ -145,10 +145,10 @@ function Sidebar() {
|
||||
'palette': Palette,
|
||||
'settings': SettingsIcon,
|
||||
};
|
||||
|
||||
|
||||
// Get navigation tree from backend
|
||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||
|
||||
|
||||
return (
|
||||
<aside className="w-56 flex-shrink-0 p-3 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background">
|
||||
<nav className="flex flex-col gap-1">
|
||||
@@ -176,7 +176,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
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,
|
||||
@@ -188,10 +188,10 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
'palette': Palette,
|
||||
'settings': SettingsIcon,
|
||||
};
|
||||
|
||||
|
||||
// Get navigation tree from backend
|
||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||
|
||||
|
||||
return (
|
||||
<div className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
|
||||
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||
@@ -257,6 +257,8 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
@@ -332,31 +334,31 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
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 () => {
|
||||
@@ -374,7 +376,7 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
};
|
||||
fetchBranding();
|
||||
}, []);
|
||||
|
||||
|
||||
// Listen for store settings updates
|
||||
React.useEffect(() => {
|
||||
const handleStoreUpdate = (event: CustomEvent) => {
|
||||
@@ -382,25 +384,25 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
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);
|
||||
@@ -408,17 +410,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
// 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', {
|
||||
@@ -430,15 +432,15 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
||||
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">
|
||||
@@ -494,7 +496,7 @@ function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
|
||||
// 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 */}
|
||||
@@ -560,7 +562,7 @@ function AppRoutes() {
|
||||
<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 />} />
|
||||
@@ -576,6 +578,8 @@ function AppRoutes() {
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
@@ -597,14 +601,14 @@ function Shell() {
|
||||
const isDesktop = useIsDesktop();
|
||||
const location = useLocation();
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// Check if standalone mode - force fullscreen and hide toggle
|
||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||
const fullscreen = isStandalone ? true : on;
|
||||
|
||||
|
||||
// Check if current route is dashboard
|
||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
||||
|
||||
|
||||
// Check if current route is More page (no submenu needed)
|
||||
const isMorePage = location.pathname === '/more';
|
||||
|
||||
@@ -740,7 +744,7 @@ export default function App() {
|
||||
React.useEffect(() => {
|
||||
initializeWindowAPI();
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<HashRouter>
|
||||
|
||||
Reference in New Issue
Block a user