Compare commits
61 Commits
0609c6e3d8
...
v1.0-pre-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0421e5010f | ||
|
|
da6255dd0c | ||
|
|
91ae4956e0 | ||
|
|
b010a88619 | ||
|
|
a98217897c | ||
|
|
316fcbf2f0 | ||
|
|
3f8d15de61 | ||
|
|
930e525421 | ||
|
|
802b64db9f | ||
|
|
8959af8270 | ||
|
|
1ce99e2bb6 | ||
|
|
0a33ba0401 | ||
|
|
2ce7c0b263 | ||
|
|
47f6370ce0 | ||
|
|
47a1e78eb7 | ||
|
|
1af1add5d4 | ||
|
|
6bd50c1659 | ||
|
|
5a831ddf9d | ||
|
|
70006beeb9 | ||
|
|
e84fa969bb | ||
|
|
ccdd88a629 | ||
|
|
b8f179a984 | ||
|
|
78d7bc1161 | ||
|
|
62f25b624b | ||
|
|
10b3c0e47f | ||
|
|
508ec682a7 | ||
|
|
c83ea78911 | ||
|
|
58681e272e | ||
|
|
38a7a4ee23 | ||
|
|
875ab7af34 | ||
|
|
861c45638b | ||
|
|
8bd2713385 | ||
|
|
9671c7255a | ||
|
|
52cea87078 | ||
|
|
e9e54f52a7 | ||
|
|
4fcc69bfcd | ||
|
|
56042d4b8e | ||
|
|
3d7eb5bf48 | ||
|
|
f97cca8061 | ||
|
|
f79938c5be | ||
|
|
0dd7c7af70 | ||
|
|
285589937a | ||
|
|
a87357d890 | ||
|
|
d7505252ac | ||
|
|
3d5191aab3 | ||
|
|
65dd847a66 | ||
|
|
2dbc43a4eb | ||
|
|
771c48e4bb | ||
|
|
4104c6d6ba | ||
|
|
82399d4ddf | ||
|
|
93523a74ac | ||
|
|
2c4050451c | ||
|
|
fe98e6233d | ||
|
|
f054a78c5d | ||
|
|
012effd11d | ||
|
|
48a5a5593b | ||
|
|
e0777c708b | ||
|
|
b2ac2996f9 | ||
|
|
c8ce892d15 | ||
|
|
b6a0a66000 | ||
|
|
3260c8c112 |
@@ -1,7 +1,7 @@
|
|||||||
# WooNooW Feature Roadmap - 2025
|
# WooNooW Feature Roadmap - 2025
|
||||||
|
|
||||||
**Last Updated**: December 26, 2025
|
**Last Updated**: December 31, 2025
|
||||||
**Status**: Planning Phase
|
**Status**: Active Development
|
||||||
|
|
||||||
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
|
||||||
|
|
||||||
@@ -22,11 +22,12 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
|||||||
- ✅ Newsletter Subscribers Management
|
- ✅ Newsletter Subscribers Management
|
||||||
- ✅ Coupon System
|
- ✅ Coupon System
|
||||||
- ✅ Customer Wishlist (basic)
|
- ✅ Customer Wishlist (basic)
|
||||||
- ✅ Product Reviews & Ratings
|
- ✅ Module Management System (enable/disable features)
|
||||||
- ✅ Admin SPA with modern UI
|
- ✅ Admin SPA with modern UI
|
||||||
- ✅ Customer SPA with theme system
|
- ✅ Customer SPA with theme system
|
||||||
- ✅ REST API infrastructure
|
- ✅ REST API infrastructure
|
||||||
- ✅ Addon bridge pattern
|
- ✅ Addon bridge pattern
|
||||||
|
- 🔲 Product Reviews & Ratings (not yet implemented)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
|
|||||||
### Overview
|
### Overview
|
||||||
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
||||||
|
|
||||||
### Status: **Planning** 🔵
|
### Status: **Built** ✅
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ class ModuleRegistry {
|
|||||||
#### Navigation Integration
|
#### Navigation Integration
|
||||||
Only show module routes if enabled in navigation tree.
|
Only show module routes if enabled in navigation tree.
|
||||||
|
|
||||||
### Priority: **High** 🔴
|
### Priority: ~~High~~ **Complete** ✅
|
||||||
### Effort: 1 week
|
### Effort: ~~1 week~~ Done
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
||||||
import { Login } from './routes/Login';
|
import { Login } from './routes/Login';
|
||||||
|
import ResetPassword from './routes/ResetPassword';
|
||||||
import Dashboard from '@/routes/Dashboard';
|
import Dashboard from '@/routes/Dashboard';
|
||||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||||
@@ -101,12 +102,12 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
className={(nav) => {
|
className={(nav) => {
|
||||||
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
||||||
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
||||||
|
|
||||||
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
// 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))
|
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
// For dashboard: only active if isDashboard is true
|
// For dashboard: only active if isDashboard is true
|
||||||
// For others: active if path starts with their path OR matches a child path
|
// For others: active if path starts with their path OR matches a child path
|
||||||
let activeByPath = false;
|
let activeByPath = false;
|
||||||
@@ -115,7 +116,7 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
|
|||||||
} else if (starts) {
|
} else if (starts) {
|
||||||
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedActive = nav.isActive || activeByPath;
|
const mergedActive = nav.isActive || activeByPath;
|
||||||
if (typeof className === 'function') {
|
if (typeof className === 'function') {
|
||||||
// Preserve caller pattern: className receives { isActive }
|
// Preserve caller pattern: className receives { isActive }
|
||||||
@@ -133,7 +134,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 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 active = "bg-secondary";
|
||||||
const { main } = useActiveSection();
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
// Icon mapping
|
// Icon mapping
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
'layout-dashboard': LayoutDashboard,
|
'layout-dashboard': LayoutDashboard,
|
||||||
@@ -145,10 +146,10 @@ function Sidebar() {
|
|||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<nav className="flex flex-col gap-1">
|
||||||
@@ -176,7 +177,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
const active = "bg-secondary";
|
const active = "bg-secondary";
|
||||||
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
||||||
const { main } = useActiveSection();
|
const { main } = useActiveSection();
|
||||||
|
|
||||||
// Icon mapping (same as Sidebar)
|
// Icon mapping (same as Sidebar)
|
||||||
const iconMap: Record<string, any> = {
|
const iconMap: Record<string, any> = {
|
||||||
'layout-dashboard': LayoutDashboard,
|
'layout-dashboard': LayoutDashboard,
|
||||||
@@ -188,10 +189,10 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
|||||||
'palette': Palette,
|
'palette': Palette,
|
||||||
'settings': SettingsIcon,
|
'settings': SettingsIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get navigation tree from backend
|
// Get navigation tree from backend
|
||||||
const navTree = (window as any).WNW_NAV_TREE || [];
|
const navTree = (window as any).WNW_NAV_TREE || [];
|
||||||
|
|
||||||
return (
|
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={`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">
|
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||||
@@ -257,6 +258,8 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
|||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
import MarketingIndex from '@/routes/Marketing';
|
||||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||||
|
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||||
|
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
@@ -332,31 +335,31 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
const lastScrollYRef = React.useRef(0);
|
const lastScrollYRef = React.useRef(0);
|
||||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||||
const [isDark, setIsDark] = React.useState(false);
|
const [isDark, setIsDark] = React.useState(false);
|
||||||
|
|
||||||
// Detect dark mode
|
// Detect dark mode
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const checkDarkMode = () => {
|
const checkDarkMode = () => {
|
||||||
const htmlEl = document.documentElement;
|
const htmlEl = document.documentElement;
|
||||||
setIsDark(htmlEl.classList.contains('dark'));
|
setIsDark(htmlEl.classList.contains('dark'));
|
||||||
};
|
};
|
||||||
|
|
||||||
checkDarkMode();
|
checkDarkMode();
|
||||||
|
|
||||||
// Watch for theme changes
|
// Watch for theme changes
|
||||||
const observer = new MutationObserver(checkDarkMode);
|
const observer = new MutationObserver(checkDarkMode);
|
||||||
observer.observe(document.documentElement, {
|
observer.observe(document.documentElement, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['class']
|
attributeFilter: ['class']
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Notify parent of visibility changes
|
// Notify parent of visibility changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
onVisibilityChange?.(isVisible);
|
onVisibilityChange?.(isVisible);
|
||||||
}, [isVisible, onVisibilityChange]);
|
}, [isVisible, onVisibilityChange]);
|
||||||
|
|
||||||
// Fetch store branding on mount
|
// Fetch store branding on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchBranding = async () => {
|
const fetchBranding = async () => {
|
||||||
@@ -374,7 +377,7 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
};
|
};
|
||||||
fetchBranding();
|
fetchBranding();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for store settings updates
|
// Listen for store settings updates
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleStoreUpdate = (event: CustomEvent) => {
|
const handleStoreUpdate = (event: CustomEvent) => {
|
||||||
@@ -382,25 +385,25 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark);
|
if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark);
|
||||||
if (event.detail?.store_name) setSiteTitle(event.detail.store_name);
|
if (event.detail?.store_name) setSiteTitle(event.detail.store_name);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||||
return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Hide/show header on scroll (mobile only)
|
// Hide/show header on scroll (mobile only)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef?.current;
|
const scrollContainer = scrollContainerRef?.current;
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const currentScrollY = scrollContainer.scrollTop;
|
const currentScrollY = scrollContainer.scrollTop;
|
||||||
|
|
||||||
// Only apply on mobile (check window width)
|
// Only apply on mobile (check window width)
|
||||||
if (window.innerWidth >= 768) {
|
if (window.innerWidth >= 768) {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
|
if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
|
||||||
// Scrolling down & past threshold
|
// Scrolling down & past threshold
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
@@ -408,17 +411,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
// Scrolling up
|
// Scrolling up
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastScrollYRef.current = currentScrollY;
|
lastScrollYRef.current = currentScrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', {
|
await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', {
|
||||||
@@ -430,15 +433,15 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
|
|||||||
console.error('Logout failed:', err);
|
console.error('Logout failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen)
|
// Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen)
|
||||||
if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) {
|
if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose logo based on theme
|
// Choose logo based on theme
|
||||||
const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo;
|
const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo;
|
||||||
|
|
||||||
return (
|
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'}`}>
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
@@ -494,11 +497,12 @@ function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
|
|||||||
// Centralized route controller so we don't duplicate <Routes> in each layout
|
// Centralized route controller so we don't duplicate <Routes> in each layout
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
|
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
||||||
@@ -560,7 +564,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||||
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||||
<Route path="/appearance/general" element={<AppearanceGeneral />} />
|
<Route path="/appearance/general" element={<AppearanceGeneral />} />
|
||||||
@@ -576,6 +580,8 @@ function AppRoutes() {
|
|||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||||
|
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||||
|
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||||
|
|
||||||
{/* Dynamic Addon Routes */}
|
{/* Dynamic Addon Routes */}
|
||||||
{addonRoutes.map((route: any) => (
|
{addonRoutes.map((route: any) => (
|
||||||
@@ -597,14 +603,14 @@ function Shell() {
|
|||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Check if standalone mode - force fullscreen and hide toggle
|
// Check if standalone mode - force fullscreen and hide toggle
|
||||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||||
const fullscreen = isStandalone ? true : on;
|
const fullscreen = isStandalone ? true : on;
|
||||||
|
|
||||||
// Check if current route is dashboard
|
// Check if current route is dashboard
|
||||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
||||||
|
|
||||||
// Check if current route is More page (no submenu needed)
|
// Check if current route is More page (no submenu needed)
|
||||||
const isMorePage = location.pathname === '/more';
|
const isMorePage = location.pathname === '/more';
|
||||||
|
|
||||||
@@ -740,7 +746,7 @@ export default function App() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
initializeWindowAPI();
|
initializeWindowAPI();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
throw new Error(`Unknown block type: ${type}`);
|
throw new Error(`Unknown block type: ${type}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
onChange([...blocks, newBlock]);
|
onChange([...blocks, newBlock]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,21 +91,23 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
const moveBlock = (id: string, direction: 'up' | 'down') => {
|
const moveBlock = (id: string, direction: 'up' | 'down') => {
|
||||||
const index = blocks.findIndex(b => b.id === id);
|
const index = blocks.findIndex(b => b.id === id);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
if (newIndex < 0 || newIndex >= blocks.length) return;
|
if (newIndex < 0 || newIndex >= blocks.length) return;
|
||||||
|
|
||||||
const newBlocks = [...blocks];
|
const newBlocks = [...blocks];
|
||||||
[newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex], newBlocks[index]];
|
[newBlocks[index], newBlocks[newIndex]] = [newBlocks[newIndex], newBlocks[index]];
|
||||||
onChange(newBlocks);
|
onChange(newBlocks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (block: EmailBlock) => {
|
const openEditDialog = (block: EmailBlock) => {
|
||||||
|
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
||||||
setEditingBlockId(block.id);
|
setEditingBlockId(block.id);
|
||||||
|
|
||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
// Convert markdown to HTML for rich text editor
|
// Convert markdown to HTML for rich text editor
|
||||||
const htmlContent = parseMarkdownBasics(block.content);
|
const htmlContent = parseMarkdownBasics(block.content);
|
||||||
|
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||||
setEditingContent(htmlContent);
|
setEditingContent(htmlContent);
|
||||||
setEditingCardType(block.cardType);
|
setEditingCardType(block.cardType);
|
||||||
} else if (block.type === 'button') {
|
} else if (block.type === 'button') {
|
||||||
@@ -121,16 +123,17 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
setEditingCustomMaxWidth(block.customMaxWidth);
|
setEditingCustomMaxWidth(block.customMaxWidth);
|
||||||
setEditingAlign(block.align);
|
setEditingAlign(block.align);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = () => {
|
const saveEdit = () => {
|
||||||
if (!editingBlockId) return;
|
if (!editingBlockId) return;
|
||||||
|
|
||||||
const newBlocks = blocks.map(block => {
|
const newBlocks = blocks.map(block => {
|
||||||
if (block.id !== editingBlockId) return block;
|
if (block.id !== editingBlockId) return block;
|
||||||
|
|
||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
// Convert HTML from rich text editor back to markdown for storage
|
// Convert HTML from rich text editor back to markdown for storage
|
||||||
const markdownContent = htmlToMarkdown(editingContent);
|
const markdownContent = htmlToMarkdown(editingContent);
|
||||||
@@ -154,10 +157,10 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
align: editingAlign,
|
align: editingAlign,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return block;
|
return block;
|
||||||
});
|
});
|
||||||
|
|
||||||
onChange(newBlocks);
|
onChange(newBlocks);
|
||||||
setEditDialogOpen(false);
|
setEditDialogOpen(false);
|
||||||
setEditingBlockId(null);
|
setEditingBlockId(null);
|
||||||
@@ -269,29 +272,23 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-2xl"
|
className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
// Check if WordPress media modal is currently open
|
// Only prevent closing if WordPress media modal is open
|
||||||
const wpMediaOpen = document.querySelector('.media-modal');
|
const wpMediaOpen = document.querySelector('.media-modal');
|
||||||
|
|
||||||
if (wpMediaOpen) {
|
if (wpMediaOpen) {
|
||||||
// If WP media is open, ALWAYS prevent dialog from closing
|
|
||||||
// regardless of where the click happened
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// Otherwise, allow the dialog to close normally via outside click
|
||||||
// If WP media is not open, prevent closing dialog for outside clicks
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
}}
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
// Allow escape to close WP media modal
|
// Only prevent escape if WP media modal is open
|
||||||
const wpMediaOpen = document.querySelector('.media-modal');
|
const wpMediaOpen = document.querySelector('.media-modal');
|
||||||
if (wpMediaOpen) {
|
if (wpMediaOpen) {
|
||||||
return;
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
// Otherwise, allow escape to close dialog
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -305,7 +302,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 px-6 py-4">
|
||||||
{editingBlock?.type === 'card' && (
|
{editingBlock?.type === 'card' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -359,7 +356,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
/>
|
/>
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||||
<code
|
<code
|
||||||
key={variable}
|
key={variable}
|
||||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||||
|
|||||||
@@ -56,27 +56,27 @@ export function blocksToMarkdown(blocks: EmailBlock[]): string {
|
|||||||
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
|
||||||
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonBlock = block as ButtonBlock;
|
const buttonBlock = block as ButtonBlock;
|
||||||
// Use new [button:style](url)Text[/button] syntax
|
// Use new [button:style](url)Text[/button] syntax
|
||||||
const style = buttonBlock.style || 'solid';
|
const style = buttonBlock.style || 'solid';
|
||||||
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'image': {
|
case 'image': {
|
||||||
const imageBlock = block as ImageBlock;
|
const imageBlock = block as ImageBlock;
|
||||||
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return '---';
|
return '---';
|
||||||
|
|
||||||
case 'spacer': {
|
case 'spacer': {
|
||||||
const spacerBlock = block as SpacerBlock;
|
const spacerBlock = block as SpacerBlock;
|
||||||
return `[spacer height="${spacerBlock.height}"]`;
|
return `[spacer height="${spacerBlock.height}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
return `[card]\n${block.content}\n[/card]`;
|
return `[card]\n${block.content}\n[/card]`;
|
||||||
}
|
}
|
||||||
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
||||||
const align = block.align || 'center';
|
const align = block.align || 'center';
|
||||||
@@ -118,13 +118,13 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
}
|
}
|
||||||
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
|
||||||
|
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
return `<div style="height: ${block.height}px;"></div>`;
|
return `<div style="height: ${block.height}px;"></div>`;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -137,39 +137,39 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
|||||||
export function htmlToBlocks(html: string): EmailBlock[] {
|
export function htmlToBlocks(html: string): EmailBlock[] {
|
||||||
const blocks: EmailBlock[] = [];
|
const blocks: EmailBlock[] = [];
|
||||||
let blockId = 0;
|
let blockId = 0;
|
||||||
|
|
||||||
// Match both [card] syntax and <div class="card"> HTML
|
// Match both [card] syntax and <div class="card"> HTML
|
||||||
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = cardRegex.exec(html)) !== null) {
|
while ((match = cardRegex.exec(html)) !== null) {
|
||||||
// Add content before card
|
// Add content before card
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
const beforeContent = html.substring(lastIndex, match.index).trim();
|
const beforeContent = html.substring(lastIndex, match.index).trim();
|
||||||
if (beforeContent) parts.push(beforeContent);
|
if (beforeContent) parts.push(beforeContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add card
|
// Add card
|
||||||
parts.push(match[0]);
|
parts.push(match[0]);
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining content
|
// Add remaining content
|
||||||
if (lastIndex < html.length) {
|
if (lastIndex < html.length) {
|
||||||
const remaining = html.substring(lastIndex).trim();
|
const remaining = html.substring(lastIndex).trim();
|
||||||
if (remaining) parts.push(remaining);
|
if (remaining) parts.push(remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each part
|
// Process each part
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const id = `block-${Date.now()}-${blockId++}`;
|
const id = `block-${Date.now()}-${blockId++}`;
|
||||||
|
|
||||||
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
// Check if it's a card - match [card:type], [card type="..."], and <div class="card">
|
||||||
let content = '';
|
let content = '';
|
||||||
let cardType = 'default';
|
let cardType = 'default';
|
||||||
|
|
||||||
// Try new [card:type] syntax first
|
// Try new [card:type] syntax first
|
||||||
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
|
||||||
if (cardMatch) {
|
if (cardMatch) {
|
||||||
@@ -185,7 +185,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
cardType = (typeMatch ? typeMatch[1] : 'default');
|
cardType = (typeMatch ? typeMatch[1] : 'default');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cardMatch) {
|
if (!cardMatch) {
|
||||||
// <div class="card"> HTML syntax
|
// <div class="card"> HTML syntax
|
||||||
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
|
||||||
@@ -194,7 +194,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
content = htmlCardMatch[2].trim();
|
content = htmlCardMatch[2].trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
// Convert HTML content to markdown for clean editing
|
// Convert HTML content to markdown for clean editing
|
||||||
// But only if it actually contains HTML tags
|
// But only if it actually contains HTML tags
|
||||||
@@ -208,14 +208,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a button - try new syntax first
|
// Check if it's a button - try new syntax first
|
||||||
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
const style = buttonMatch[1] as ButtonStyle;
|
const style = buttonMatch[1] as ButtonStyle;
|
||||||
const url = buttonMatch[2];
|
const url = buttonMatch[2];
|
||||||
const text = buttonMatch[3].trim();
|
const text = buttonMatch[3].trim();
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -227,14 +227,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try old [button url="..."] syntax
|
// Try old [button url="..."] syntax
|
||||||
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
const url = buttonMatch[1];
|
const url = buttonMatch[1];
|
||||||
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
const style = (buttonMatch[2] || 'solid') as ButtonStyle;
|
||||||
const text = buttonMatch[3].trim();
|
const text = buttonMatch[3].trim();
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -246,7 +246,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTML button syntax
|
// Check HTML button syntax
|
||||||
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
if (part.includes('class="button"') || part.includes('class="button-outline"')) {
|
||||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
||||||
@@ -286,13 +286,13 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a divider
|
// Check if it's a divider
|
||||||
if (part.includes('<hr')) {
|
if (part.includes('<hr')) {
|
||||||
blocks.push({ id, type: 'divider' });
|
blocks.push({ id, type: 'divider' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a spacer
|
// Check if it's a spacer
|
||||||
const spacerMatch = part.match(/height:\s*(\d+)px/);
|
const spacerMatch = part.match(/height:\s*(\d+)px/);
|
||||||
if (spacerMatch && part.includes('<div')) {
|
if (spacerMatch && part.includes('<div')) {
|
||||||
@@ -300,7 +300,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,30 +310,47 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
|||||||
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||||
const blocks: EmailBlock[] = [];
|
const blocks: EmailBlock[] = [];
|
||||||
let blockId = 0;
|
let blockId = 0;
|
||||||
|
|
||||||
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
|
||||||
let remaining = markdown;
|
let remaining = markdown;
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
while (remaining.length > 0) {
|
||||||
remaining = remaining.trim();
|
remaining = remaining.trim();
|
||||||
if (!remaining) break;
|
if (!remaining) break;
|
||||||
|
|
||||||
const id = `block-${Date.now()}-${blockId++}`;
|
const id = `block-${Date.now()}-${blockId++}`;
|
||||||
|
|
||||||
// Check for [card] blocks - match with proper boundaries
|
// Check for [card] blocks - NEW syntax [card:type]...[/card]
|
||||||
|
const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/);
|
||||||
|
if (newCardMatch) {
|
||||||
|
const cardType = newCardMatch[1] as CardType;
|
||||||
|
const content = newCardMatch[2].trim();
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
id,
|
||||||
|
type: 'card',
|
||||||
|
cardType,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining = remaining.substring(newCardMatch[0].length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for [card] blocks - OLD syntax [card type="..."]...[/card]
|
||||||
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
|
||||||
if (cardMatch) {
|
if (cardMatch) {
|
||||||
const attributes = cardMatch[1].trim();
|
const attributes = cardMatch[1].trim();
|
||||||
const content = cardMatch[2].trim();
|
const content = cardMatch[2].trim();
|
||||||
|
|
||||||
// Extract card type
|
// Extract card type
|
||||||
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
|
||||||
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
const cardType = (typeMatch?.[1] || 'default') as CardType;
|
||||||
|
|
||||||
// Extract background
|
// Extract background
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bg = bgMatch?.[1];
|
const bg = bgMatch?.[1];
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'card',
|
type: 'card',
|
||||||
@@ -341,13 +358,30 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
content,
|
content,
|
||||||
bg,
|
bg,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance past this card
|
// Advance past this card
|
||||||
remaining = remaining.substring(cardMatch[0].length);
|
remaining = remaining.substring(cardMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [button] blocks
|
// Check for [button] blocks - NEW syntax [button:style](url)Text[/button]
|
||||||
|
const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
|
||||||
|
if (newButtonMatch) {
|
||||||
|
blocks.push({
|
||||||
|
id,
|
||||||
|
type: 'button',
|
||||||
|
text: newButtonMatch[3].trim(),
|
||||||
|
link: newButtonMatch[2],
|
||||||
|
style: newButtonMatch[1] as ButtonStyle,
|
||||||
|
align: 'center',
|
||||||
|
widthMode: 'fit',
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining = remaining.substring(newButtonMatch[0].length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button]
|
||||||
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
|
||||||
if (buttonMatch) {
|
if (buttonMatch) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -359,11 +393,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
widthMode: 'fit',
|
widthMode: 'fit',
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(buttonMatch[0].length);
|
remaining = remaining.substring(buttonMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [image] blocks
|
// Check for [image] blocks
|
||||||
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
|
||||||
if (imageMatch) {
|
if (imageMatch) {
|
||||||
@@ -375,11 +409,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
widthMode: (imageMatch[3] || 'fit') as ContentWidth,
|
||||||
align: (imageMatch[4] || 'center') as ContentAlign,
|
align: (imageMatch[4] || 'center') as ContentAlign,
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(imageMatch[0].length);
|
remaining = remaining.substring(imageMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for [spacer] blocks
|
// Check for [spacer] blocks
|
||||||
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
|
||||||
if (spacerMatch) {
|
if (spacerMatch) {
|
||||||
@@ -388,25 +422,25 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
|||||||
type: 'spacer',
|
type: 'spacer',
|
||||||
height: parseInt(spacerMatch[1]),
|
height: parseInt(spacerMatch[1]),
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(spacerMatch[0].length);
|
remaining = remaining.substring(spacerMatch[0].length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for horizontal rule
|
// Check for horizontal rule
|
||||||
if (remaining.startsWith('---')) {
|
if (remaining.startsWith('---')) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
id,
|
id,
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
});
|
});
|
||||||
|
|
||||||
remaining = remaining.substring(3);
|
remaining = remaining.substring(3);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing matches, skip this character to avoid infinite loop
|
// If nothing matches, skip this character to avoid infinite loop
|
||||||
remaining = remaining.substring(1);
|
remaining = remaining.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,25 +30,43 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => {
|
||||||
<DialogPortal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<DialogOverlay />
|
const getPortalContainer = () => {
|
||||||
<DialogPrimitive.Content
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
ref={ref}
|
if (!appContainer) return document.body;
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||||
className
|
if (!portalRoot) {
|
||||||
)}
|
portalRoot = document.createElement('div');
|
||||||
{...props}
|
portalRoot.id = 'woonoow-dialog-portal';
|
||||||
>
|
appContainer.appendChild(portalRoot);
|
||||||
{children}
|
}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
return portalRoot;
|
||||||
<X className="h-4 w-4" />
|
};
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
return (
|
||||||
</DialogPrimitive.Content>
|
<DialogPortal container={getPortalContainer()}>
|
||||||
</DialogPortal>
|
<DialogOverlay />
|
||||||
))
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-[99999] flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
})
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
@@ -57,7 +75,7 @@ const DialogHeader = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left px-6 pt-6 pb-4 border-b",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,7 +89,7 @@ const DialogFooter = ({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t mt-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -106,6 +124,20 @@ const DialogDescription = React.forwardRef<
|
|||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const DialogBody = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto px-6 py-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogBody.displayName = "DialogBody"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
@@ -117,4 +149,5 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogBody,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from './button';
|
|||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
import { Label } from './label';
|
import { Label } from './label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogBody } from './dialog';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
@@ -45,7 +45,8 @@ export function RichTextEditor({
|
|||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
// StarterKit 3.10+ includes Link by default, disable since we configure separately
|
||||||
|
StarterKit.configure({ link: false }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
@@ -75,14 +76,6 @@ export function RichTextEditor({
|
|||||||
class:
|
class:
|
||||||
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
|
||||||
},
|
},
|
||||||
handleClick: (view, pos, event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.tagName === 'A' || target.closest('a')) {
|
|
||||||
event.preventDefault();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,10 +113,12 @@ export function RichTextEditor({
|
|||||||
const [buttonText, setButtonText] = useState('Click Here');
|
const [buttonText, setButtonText] = useState('Click Here');
|
||||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
||||||
|
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||||
|
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||||
|
|
||||||
const addImage = () => {
|
const addImage = () => {
|
||||||
openWPMediaImage((file) => {
|
openWPMediaImage((file) => {
|
||||||
editor.chain().focus().setImage({
|
editor.chain().focus().setImage({
|
||||||
src: file.url,
|
src: file.url,
|
||||||
alt: file.alt || file.title,
|
alt: file.alt || file.title,
|
||||||
title: file.title,
|
title: file.title,
|
||||||
@@ -135,12 +130,81 @@ export function RichTextEditor({
|
|||||||
setButtonText('Click Here');
|
setButtonText('Click Here');
|
||||||
setButtonHref('{order_url}');
|
setButtonHref('{order_url}');
|
||||||
setButtonStyle('solid');
|
setButtonStyle('solid');
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
setButtonDialogOpen(true);
|
setButtonDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle clicking on buttons in the editor to edit them
|
||||||
|
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (buttonEl && editor) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Get button attributes
|
||||||
|
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
|
||||||
|
const href = buttonEl.getAttribute('data-href') || '#';
|
||||||
|
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
|
||||||
|
|
||||||
|
// Find the position of this button node
|
||||||
|
const { state } = editor.view;
|
||||||
|
let foundPos: number | null = null;
|
||||||
|
|
||||||
|
state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'button' &&
|
||||||
|
node.attrs.text === text &&
|
||||||
|
node.attrs.href === href) {
|
||||||
|
foundPos = pos;
|
||||||
|
return false; // Stop iteration
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open dialog in edit mode
|
||||||
|
setButtonText(text);
|
||||||
|
setButtonHref(href);
|
||||||
|
setButtonStyle(style);
|
||||||
|
setIsEditingButton(true);
|
||||||
|
setEditingButtonPos(foundPos);
|
||||||
|
setButtonDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const insertButton = () => {
|
const insertButton = () => {
|
||||||
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
if (isEditingButton && editingButtonPos !== null && editor) {
|
||||||
|
// Delete old button and insert new one at same position
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||||
|
.insertContentAt(editingButtonPos, {
|
||||||
|
type: 'button',
|
||||||
|
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// Insert new button
|
||||||
|
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
|
||||||
|
}
|
||||||
setButtonDialogOpen(false);
|
setButtonDialogOpen(false);
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteButton = () => {
|
||||||
|
if (editingButtonPos !== null && editor) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
|
||||||
|
.run();
|
||||||
|
setButtonDialogOpen(false);
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActiveHeading = () => {
|
const getActiveHeading = () => {
|
||||||
@@ -292,97 +356,174 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<div className="overflow-y-auto max-h-[400px] min-h-[200px]">
|
<div onClick={handleEditorClick}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Variables Dropdown */}
|
{/* Variables - Collapsible and Categorized */}
|
||||||
{variables.length > 0 && (
|
{variables.length > 0 && (
|
||||||
<div className="border-t bg-muted/30 p-3">
|
<details className="border-t bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
|
||||||
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-[10px]">▶</span>
|
||||||
{__('Insert Variable:')}
|
{__('Insert Variable')}
|
||||||
</Label>
|
<span className="text-[10px] opacity-60">({variables.length})</span>
|
||||||
<Select onValueChange={(value) => insertVariable(value)}>
|
</summary>
|
||||||
<SelectTrigger id="variable-select" className="h-8 text-xs">
|
<div className="p-3 pt-0 space-y-3">
|
||||||
<SelectValue placeholder={__('Choose a variable...')} />
|
{/* Order Variables */}
|
||||||
</SelectTrigger>
|
{variables.some(v => v.startsWith('order')) && (
|
||||||
<SelectContent>
|
<div>
|
||||||
{variables.map((variable) => (
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
|
||||||
<SelectItem key={variable} value={variable} className="text-xs">
|
<div className="flex flex-wrap gap-1">
|
||||||
{`{${variable}}`}
|
{variables.filter(v => v.startsWith('order')).map((variable) => (
|
||||||
</SelectItem>
|
<button
|
||||||
))}
|
key={variable}
|
||||||
</SelectContent>
|
type="button"
|
||||||
</Select>
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Customer Variables */}
|
||||||
|
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Shipping/Payment Variables */}
|
||||||
|
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Store/Site Variables */}
|
||||||
|
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
||||||
|
<button
|
||||||
|
key={variable}
|
||||||
|
type="button"
|
||||||
|
onClick={() => insertVariable(variable)}
|
||||||
|
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Button Dialog */}
|
{/* Button Dialog */}
|
||||||
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
|
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
||||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
setButtonDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setIsEditingButton(false);
|
||||||
|
setEditingButtonPos(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Insert Button')}</DialogTitle>
|
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{__('Add a styled button to your content. Use variables for dynamic links.')}
|
{isEditingButton
|
||||||
|
? __('Edit the button properties below. Click on the button to save.')
|
||||||
|
: __('Add a styled button to your content. Use variables for dynamic links.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<DialogBody>
|
||||||
<div className="space-y-2">
|
<div className="space-y-4 !p-4">
|
||||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||||
id="btn-text"
|
<Input
|
||||||
value={buttonText}
|
id="btn-text"
|
||||||
onChange={(e) => setButtonText(e.target.value)}
|
value={buttonText}
|
||||||
placeholder={__('e.g., View Order')}
|
onChange={(e) => setButtonText(e.target.value)}
|
||||||
/>
|
placeholder={__('e.g., View Order')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
||||||
|
<Input
|
||||||
|
id="btn-href"
|
||||||
|
value={buttonHref}
|
||||||
|
onChange={(e) => setButtonHref(e.target.value)}
|
||||||
|
placeholder="{order_url}"
|
||||||
|
/>
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
|
||||||
|
<code
|
||||||
|
key={variable}
|
||||||
|
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||||
|
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
||||||
|
>
|
||||||
|
{`{${variable}}`}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||||
|
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||||
|
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogBody>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="btn-href">{__('Button Link')}</Label>
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
<Input
|
{isEditingButton && (
|
||||||
id="btn-href"
|
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
||||||
value={buttonHref}
|
{__('Delete')}
|
||||||
onChange={(e) => setButtonHref(e.target.value)}
|
</Button>
|
||||||
placeholder="{order_url}"
|
)}
|
||||||
/>
|
|
||||||
{variables.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{variables.filter(v => v.includes('_url')).map((variable) => (
|
|
||||||
<code
|
|
||||||
key={variable}
|
|
||||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
|
||||||
onClick={() => setButtonHref(buttonHref + `{${variable}}`)}
|
|
||||||
>
|
|
||||||
{`{${variable}}`}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
|
||||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
|
||||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
||||||
{__('Cancel')}
|
{__('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={insertButton}>
|
<Button onClick={insertButton}>
|
||||||
{__('Insert Button')}
|
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -69,33 +69,49 @@ SelectScrollDownButton.displayName =
|
|||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = "popper", ...props }, ref) => {
|
||||||
<SelectPrimitive.Portal>
|
// Get or create portal container inside the app for proper CSS scoping
|
||||||
<SelectPrimitive.Content
|
const getPortalContainer = () => {
|
||||||
ref={ref}
|
const appContainer = document.getElementById('woonoow-admin-app');
|
||||||
className={cn(
|
if (!appContainer) return document.body;
|
||||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
|
||||||
position === "popper" &&
|
let portalRoot = document.getElementById('woonoow-select-portal');
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
if (!portalRoot) {
|
||||||
className
|
portalRoot = document.createElement('div');
|
||||||
)}
|
portalRoot.id = 'woonoow-select-portal';
|
||||||
position={position}
|
appContainer.appendChild(portalRoot);
|
||||||
{...props}
|
}
|
||||||
>
|
return portalRoot;
|
||||||
<SelectScrollUpButton />
|
};
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal container={getPortalContainer()}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
<SelectScrollUpButton />
|
||||||
</SelectPrimitive.Viewport>
|
<SelectPrimitive.Viewport
|
||||||
<SelectScrollDownButton />
|
className={cn(
|
||||||
</SelectPrimitive.Content>
|
"p-1",
|
||||||
</SelectPrimitive.Portal>
|
position === "popper" &&
|
||||||
))
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
})
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
|
|||||||
@@ -37,54 +37,50 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
tag: 'a[data-button]',
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
|
||||||
|
style: node.getAttribute('data-style') || 'solid',
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button',
|
tag: 'a.button',
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('href') || '#',
|
||||||
|
style: 'solid',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'a.button-outline',
|
tag: 'a.button-outline',
|
||||||
|
getAttrs: (node: HTMLElement) => ({
|
||||||
|
text: node.textContent || 'Click Here',
|
||||||
|
href: node.getAttribute('href') || '#',
|
||||||
|
style: 'outline',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const { text, href, style } = HTMLAttributes;
|
const { text, href, style } = HTMLAttributes;
|
||||||
const className = style === 'outline' ? 'button-outline' : 'button';
|
|
||||||
|
|
||||||
const buttonStyle: Record<string, string> = style === 'solid'
|
|
||||||
? {
|
|
||||||
display: 'inline-block',
|
|
||||||
background: '#7f54b3',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '14px 28px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '600',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
display: 'inline-block',
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#7f54b3',
|
|
||||||
padding: '12px 26px',
|
|
||||||
border: '2px solid #7f54b3',
|
|
||||||
borderRadius: '6px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '600',
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Simple link styling - no fancy button appearance in editor
|
||||||
|
// The actual button styling happens in email rendering (EmailRenderer.php)
|
||||||
|
// In editor, just show as a styled link (differentiable from regular links)
|
||||||
return [
|
return [
|
||||||
'a',
|
'a',
|
||||||
mergeAttributes(this.options.HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, {
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: 'button-node',
|
||||||
style: Object.entries(buttonStyle)
|
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
|
||||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
|
|
||||||
.join('; '),
|
|
||||||
'data-button': '',
|
'data-button': '',
|
||||||
'data-text': text,
|
'data-text': text,
|
||||||
'data-href': href,
|
'data-href': href,
|
||||||
'data-style': style,
|
'data-style': style,
|
||||||
|
title: `Button: ${text} → ${href}`,
|
||||||
}),
|
}),
|
||||||
text,
|
text,
|
||||||
];
|
];
|
||||||
@@ -94,12 +90,12 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
|||||||
return {
|
return {
|
||||||
setButton:
|
setButton:
|
||||||
(options) =>
|
(options) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.insertContent({
|
return commands.insertContent({
|
||||||
type: this.name,
|
type: this.name,
|
||||||
attrs: options,
|
attrs: options,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
WordPress Admin Override Fixes
|
||||||
|
These rules use high specificity + !important
|
||||||
|
to override WordPress admin CSS conflicts
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Fix SVG icon styling - WordPress sets fill:currentColor on all SVGs */
|
||||||
|
#woonoow-admin-app svg {
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* But allow explicit fill-current class to work for filled icons */
|
||||||
|
#woonoow-admin-app svg.fill-current,
|
||||||
|
#woonoow-admin-app .fill-current svg,
|
||||||
|
#woonoow-admin-app [class*="fill-"] svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix radio button indicator - WordPress overrides circle fill */
|
||||||
|
#woonoow-admin-app [data-radix-radio-group-item] svg,
|
||||||
|
#woonoow-admin-app [role="radio"] svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix font-weight inheritance - prevent WordPress bold overrides */
|
||||||
|
#woonoow-admin-app text,
|
||||||
|
#woonoow-admin-app tspan {
|
||||||
|
font-weight: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset form element styling that WordPress overrides */
|
||||||
|
#woonoow-admin-app input[type="radio"],
|
||||||
|
#woonoow-admin-app input[type="checkbox"] {
|
||||||
|
appearance: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Command palette input: remove native borders/shadows to match shadcn */
|
/* Command palette input: remove native borders/shadows to match shadcn */
|
||||||
.command-palette-search {
|
.command-palette-search {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
|||||||
@@ -5,26 +5,52 @@
|
|||||||
|
|
||||||
export function htmlToMarkdown(html: string): string {
|
export function htmlToMarkdown(html: string): string {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
|
||||||
let markdown = html;
|
let markdown = html;
|
||||||
|
|
||||||
// Headings
|
// Headings
|
||||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||||
|
|
||||||
// Bold
|
// Bold
|
||||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||||
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
markdown = markdown.replace(/<b>(.*?)<\/b>/gi, '**$1**');
|
||||||
|
|
||||||
// Italic
|
// Italic
|
||||||
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
|
||||||
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
|
||||||
|
|
||||||
// Links
|
// TipTap buttons - detect by data-button attribute, BEFORE generic links
|
||||||
|
// Format: <a data-button data-style="solid" data-href="..." data-text="...">text</a>
|
||||||
|
// or: <a href="..." class="button..." data-button ...>text</a>
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*>(.*?)<\/a>/gi, (match, text) => {
|
||||||
|
// Extract style from data-style or class
|
||||||
|
let style = 'solid';
|
||||||
|
const styleMatch = match.match(/data-style=["'](\w+)["']/);
|
||||||
|
if (styleMatch) {
|
||||||
|
style = styleMatch[1];
|
||||||
|
} else if (match.includes('button-outline') || match.includes('outline')) {
|
||||||
|
style = 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract href from data-href or href attribute
|
||||||
|
let url = '#';
|
||||||
|
const dataHrefMatch = match.match(/data-href=["']([^"']+)["']/);
|
||||||
|
const hrefMatch = match.match(/href=["']([^"']+)["']/);
|
||||||
|
if (dataHrefMatch) {
|
||||||
|
url = dataHrefMatch[1];
|
||||||
|
} else if (hrefMatch) {
|
||||||
|
url = hrefMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[button:${style}](${url})${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regular links (not buttons)
|
||||||
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, content) => {
|
||||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||||
@@ -33,7 +59,7 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return `- ${text}`;
|
return `- ${text}`;
|
||||||
}).join('\n') + '\n\n';
|
}).join('\n') + '\n\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (match, content) => {
|
||||||
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
const items = content.match(/<li[^>]*>(.*?)<\/li>/gis) || [];
|
||||||
return items.map((item: string, index: number) => {
|
return items.map((item: string, index: number) => {
|
||||||
@@ -41,24 +67,24 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return `${index + 1}. ${text}`;
|
return `${index + 1}. ${text}`;
|
||||||
}).join('\n') + '\n\n';
|
}).join('\n') + '\n\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paragraphs - convert to double newlines
|
// Paragraphs - convert to double newlines
|
||||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
|
||||||
|
|
||||||
// Line breaks
|
// Line breaks
|
||||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
|
||||||
// Horizontal rules
|
// Horizontal rules
|
||||||
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n\n');
|
||||||
|
|
||||||
// Remove remaining HTML tags
|
// Remove remaining HTML tags
|
||||||
markdown = markdown.replace(/<[^>]+>/g, '');
|
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
// Clean up excessive newlines
|
// Clean up excessive newlines
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
// Trim
|
// Trim
|
||||||
markdown = markdown.trim();
|
markdown = markdown.trim();
|
||||||
|
|
||||||
return markdown;
|
return markdown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
const parsedContent = parseMarkdownBasics(content.trim());
|
const parsedContent = parseMarkdownBasics(content.trim());
|
||||||
return `<div class="${cardClass}">${parsedContent}</div>`;
|
return `<div class="${cardClass}">${parsedContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
||||||
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||||
const cardClass = type ? `card card-${type}` : 'card';
|
const cardClass = type ? `card card-${type}` : 'card';
|
||||||
@@ -98,13 +98,13 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
// Parse [button:style](url)Text[/button] (new syntax)
|
// Parse [button:style](url)Text[/button] (new syntax)
|
||||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse remaining markdown
|
// Parse remaining markdown
|
||||||
@@ -151,15 +151,20 @@ export function parseMarkdownBasics(text: string): string {
|
|||||||
|
|
||||||
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
||||||
// Allow whitespace and newlines between parts
|
// Allow whitespace and newlines between parts
|
||||||
|
// Include data-button attributes for TipTap recognition
|
||||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
const trimmedText = text.trim();
|
||||||
|
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||||
|
// Include data-button attributes for TipTap recognition
|
||||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonStyle = style || 'solid';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
const buttonClass = buttonStyle === 'outline' ? 'button-outline' : 'button';
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${buttonStyle}">${trimmedText}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Images (must come before links)
|
// Images (must come before links)
|
||||||
@@ -267,8 +272,33 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert buttons back to [button] syntax
|
// Convert buttons back to [button] syntax
|
||||||
|
// TipTap button format with data attributes: <a data-button data-href="..." data-style="..." data-text="...">text</a>
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-href="([^"]+)"[^>]*data-style="([^"]*)"[^>]*>([^<]+)<\/a>/gi, (match, url, style, text) => {
|
||||||
|
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alternate order: data-style before data-href
|
||||||
|
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-style="([^"]*)"[^>]*data-href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, (match, style, url, text) => {
|
||||||
|
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple data-button fallback (just has href and class)
|
||||||
|
markdown = markdown.replace(/<a[^>]*href="([^"]+)"[^>]*class="(button[^"]*)"[^>]*data-button[^>]*>([^<]+)<\/a>/gi, (match, url, className, text) => {
|
||||||
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons wrapped in p tags (from preview HTML): <p><a href="..." class="button...">text</a></p>
|
||||||
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
||||||
const style = className.includes('outline') ? ' style="outline"' : '';
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct button links without p wrapper
|
||||||
|
markdown = markdown.replace(/<a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a>/g, (match, url, className, text) => {
|
||||||
|
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
|
||||||
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface WordPressPage {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppearanceGeneral() {
|
export default function AppearanceGeneral() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
||||||
|
const [spaPage, setSpaPage] = useState(0);
|
||||||
|
const [availablePages, setAvailablePages] = useState<WordPressPage[]>([]);
|
||||||
const [toastPosition, setToastPosition] = useState('top-right');
|
const [toastPosition, setToastPosition] = useState('top-right');
|
||||||
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||||
@@ -40,11 +48,13 @@ export default function AppearanceGeneral() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Load appearance settings
|
||||||
const response = await api.get('/appearance/settings');
|
const response = await api.get('/appearance/settings');
|
||||||
const general = response.data?.general;
|
const general = response.data?.general;
|
||||||
|
|
||||||
if (general) {
|
if (general) {
|
||||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||||
|
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
||||||
if (general.toast_position) setToastPosition(general.toast_position);
|
if (general.toast_position) setToastPosition(general.toast_position);
|
||||||
if (general.typography) {
|
if (general.typography) {
|
||||||
setTypographyMode(general.typography.mode || 'predefined');
|
setTypographyMode(general.typography.mode || 'predefined');
|
||||||
@@ -63,8 +73,19 @@ 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) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
|
console.error('Error details:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -76,7 +97,8 @@ export default function AppearanceGeneral() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/appearance/general', {
|
await api.post('/appearance/general', {
|
||||||
spa_mode: spaMode,
|
spaMode,
|
||||||
|
spaPage,
|
||||||
toastPosition,
|
toastPosition,
|
||||||
typography: {
|
typography: {
|
||||||
mode: typographyMode,
|
mode: typographyMode,
|
||||||
@@ -113,7 +135,7 @@ export default function AppearanceGeneral() {
|
|||||||
Disabled
|
Disabled
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Use WordPress default pages (no SPA functionality)
|
SPA never loads (use WordPress default pages)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +147,7 @@ export default function AppearanceGeneral() {
|
|||||||
Checkout Only
|
Checkout Only
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
SPA for checkout flow only (cart, checkout, thank you)
|
SPA starts at cart page (cart → checkout → thank you → account)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,13 +159,53 @@ export default function AppearanceGeneral() {
|
|||||||
Full SPA
|
Full SPA
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Entire customer-facing site uses SPA (recommended)
|
SPA starts at shop page (shop → product → cart → checkout → account)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* SPA Page */}
|
||||||
|
<SettingsCard
|
||||||
|
title="SPA Page"
|
||||||
|
description="Select the page where the SPA will load (e.g., /store)"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
This page will render the full SPA to the body element with no theme interference.
|
||||||
|
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
||||||
|
<Select
|
||||||
|
value={spaPage.toString()}
|
||||||
|
onValueChange={(value) => setSpaPage(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="spa-page">
|
||||||
|
<SelectValue placeholder="Select a page..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">— None —</SelectItem>
|
||||||
|
{availablePages.map((page) => (
|
||||||
|
<SelectItem key={page.id} value={page.id.toString()}>
|
||||||
|
{page.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
<strong>Full SPA:</strong> Loads shop page initially<br />
|
||||||
|
<strong>Checkout Only:</strong> Loads cart page initially<br />
|
||||||
|
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
||||||
|
</p>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Toast Notifications */}
|
{/* Toast Notifications */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Toast Notifications"
|
title="Toast Notifications"
|
||||||
|
|||||||
400
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
400
admin-spa/src/routes/Marketing/Campaigns/Edit.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
TestTube,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignEdit() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = id === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
|
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
|
const [showSendConfirm, setShowSendConfirm] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Fetch campaign if editing
|
||||||
|
const { data: campaign, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaign', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get(`/campaigns/${id}`);
|
||||||
|
return response.data as Campaign;
|
||||||
|
},
|
||||||
|
enabled: !isNew && !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form when campaign loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (campaign) {
|
||||||
|
setTitle(campaign.title || '');
|
||||||
|
setSubject(campaign.subject || '');
|
||||||
|
setContent(campaign.content || '');
|
||||||
|
}
|
||||||
|
}, [campaign]);
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (data: { title: string; subject: string; content: string; status?: string }) => {
|
||||||
|
if (isNew) {
|
||||||
|
return api.post('/campaigns', data);
|
||||||
|
} else {
|
||||||
|
return api.put(`/campaigns/${id}`, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(isNew ? __('Campaign created') : __('Campaign saved'));
|
||||||
|
if (isNew && response?.data?.id) {
|
||||||
|
navigate(`/marketing/campaigns/${response.data.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to save campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview mutation
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// First save, then preview
|
||||||
|
let campaignId = id;
|
||||||
|
if (isNew || !id) {
|
||||||
|
const saveResponse = await api.post('/campaigns', { title, subject, content, status: 'draft' });
|
||||||
|
campaignId = saveResponse?.data?.id;
|
||||||
|
if (campaignId) {
|
||||||
|
navigate(`/marketing/campaigns/${campaignId}`, { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get(`/campaigns/${campaignId}/preview`);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setPreviewHtml(response?.html || response?.data?.html || '');
|
||||||
|
setShowPreview(true);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to generate preview'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test email mutation
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
// First save
|
||||||
|
if (!isNew && id) {
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
}
|
||||||
|
return api.post(`/campaigns/${id}/test`, { email });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Test email sent'));
|
||||||
|
setShowTestDialog(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to send test email'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send mutation
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// First save
|
||||||
|
await api.put(`/campaigns/${id}`, { title, subject, content });
|
||||||
|
return api.post(`/campaigns/${id}/send`);
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
|
||||||
|
toast.success(response?.message || __('Campaign sent successfully'));
|
||||||
|
setShowSendConfirm(false);
|
||||||
|
navigate('/marketing/campaigns');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.response?.data?.error || __('Failed to send campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error(__('Please enter a title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync({ title, subject, content, status: 'draft' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSend = !isNew && id && campaign?.status !== 'sent' && campaign?.status !== 'sending';
|
||||||
|
|
||||||
|
if (!isNew && isLoading) {
|
||||||
|
return (
|
||||||
|
<SettingsLayout title={__('Loading...')} description="">
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||||
|
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
||||||
|
>
|
||||||
|
{/* Back button */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{__('Back to Campaigns')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Campaign Details */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Campaign Details')}
|
||||||
|
description={__('Basic information about your campaign')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">{__('Campaign Title')}</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder={__('e.g., Holiday Sale Announcement')}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Internal name for this campaign (not shown to subscribers)')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subject">{__('Email Subject')}</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
placeholder={__('e.g., 🎄 Exclusive Holiday Deals Inside!')}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('The subject line subscribers will see in their inbox')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Campaign Content */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Campaign Content')}
|
||||||
|
description={__('Write your newsletter content. The design template is configured in Settings > Notifications.')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">{__('Email Content')}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
placeholder={__('Write your newsletter content here...\n\nYou can use:\n- {site_name} - Your store name\n- {current_date} - Today\'s date\n- {subscriber_email} - Subscriber\'s email')}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="min-h-[300px] font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Use HTML for rich formatting. The design wrapper will be applied from your campaign email template.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => previewMutation.mutate()}
|
||||||
|
disabled={previewMutation.isPending || !title.trim()}
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Preview')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowTestDialog(true)}
|
||||||
|
disabled={!id}
|
||||||
|
>
|
||||||
|
<TestTube className="mr-2 h-4 w-4" />
|
||||||
|
{__('Send Test')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !title.trim()}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Save Draft')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canSend && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowSendConfirm(true)}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send Now')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Dialog */}
|
||||||
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="border rounded-lg bg-white p-4">
|
||||||
|
<div
|
||||||
|
className="prose max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Test Email Dialog */}
|
||||||
|
<Dialog open={showTestDialog} onOpenChange={setShowTestDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||||
|
<Input
|
||||||
|
id="test-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setShowTestDialog(false)}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => testMutation.mutate(testEmail)}
|
||||||
|
disabled={!testEmail || testMutation.isPending}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send Test')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Send Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showSendConfirm} onOpenChange={setShowSendConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Send Campaign')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to send this campaign to all newsletter subscribers? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
disabled={sendMutation.isPending}
|
||||||
|
>
|
||||||
|
{sendMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{__('Send to All Subscribers')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
293
admin-spa/src/routes/Marketing/Campaigns/index.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
MoreHorizontal,
|
||||||
|
Copy
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||||
|
recipient_count: number;
|
||||||
|
sent_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
scheduled_at: string | null;
|
||||||
|
sent_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
|
||||||
|
scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||||
|
sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||||
|
sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||||
|
failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignsList() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaigns'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/campaigns');
|
||||||
|
return response.data as Campaign[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await api.del(`/campaigns/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(__('Campaign deleted'));
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to delete campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicateMutation = useMutation({
|
||||||
|
mutationFn: async (campaign: Campaign) => {
|
||||||
|
const response = await api.post('/campaigns', {
|
||||||
|
title: `${campaign.title} (Copy)`,
|
||||||
|
subject: campaign.subject,
|
||||||
|
content: '', // Would need to fetch full content
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast.success(__('Campaign duplicated'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to duplicate campaign'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaigns = data || [];
|
||||||
|
const filteredCampaigns = campaigns.filter((c) =>
|
||||||
|
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.subject?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Campaigns')}
|
||||||
|
description={__('Create and send email campaigns to your newsletter subscribers')}
|
||||||
|
>
|
||||||
|
<SettingsCard
|
||||||
|
title={__('All Campaigns')}
|
||||||
|
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 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" />
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search campaigns...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{__('New Campaign')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaigns Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('Loading campaigns...')}
|
||||||
|
</div>
|
||||||
|
) : filteredCampaigns.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
{searchQuery ? __('No campaigns found matching your search') : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||||
|
<p>{__('No campaigns yet')}</p>
|
||||||
|
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{__('Create your first campaign')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Title')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||||
|
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredCampaigns.map((campaign) => {
|
||||||
|
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={campaign.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{campaign.title}</div>
|
||||||
|
{campaign.subject && (
|
||||||
|
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{campaign.subject}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
{__(status.label)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{campaign.status === 'sent' ? (
|
||||||
|
<span>
|
||||||
|
{campaign.sent_count}/{campaign.recipient_count}
|
||||||
|
{campaign.failed_count > 0 && (
|
||||||
|
<span className="text-red-500 ml-1">
|
||||||
|
({campaign.failed_count} failed)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
|
{campaign.sent_at
|
||||||
|
? formatDate(campaign.sent_at)
|
||||||
|
: campaign.scheduled_at
|
||||||
|
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
||||||
|
: formatDate(campaign.created_at)
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/marketing/campaigns/${campaign.id}`)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{__('Edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{__('Duplicate')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteId(campaign.id)}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Delete Campaign')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to delete this campaign? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{__('Delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,63 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
|
import { Mail, Send, Tag } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface MarketingCard {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: MarketingCard[] = [
|
||||||
|
{
|
||||||
|
title: __('Newsletter'),
|
||||||
|
description: __('Manage subscribers and email templates'),
|
||||||
|
icon: Mail,
|
||||||
|
to: '/marketing/newsletter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: __('Campaigns'),
|
||||||
|
description: __('Create and send email campaigns'),
|
||||||
|
icon: Send,
|
||||||
|
to: '/marketing/campaigns',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: __('Coupons'),
|
||||||
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
|
icon: Tag,
|
||||||
|
to: '/marketing/coupons',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Marketing() {
|
export default function Marketing() {
|
||||||
return <Navigate to="/marketing/newsletter" replace />;
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Marketing')}
|
||||||
|
description={__('Newsletter, campaigns, and promotions')}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<button
|
||||||
|
key={card.to}
|
||||||
|
onClick={() => navigate(card.to)}
|
||||||
|
className="flex items-start gap-4 p-6 rounded-lg border bg-card hover:bg-accent transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||||
|
<card.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{card.title}</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{card.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink } from 'lucide-react';
|
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { useApp } from '@/contexts/AppContext';
|
import { useApp } from '@/contexts/AppContext';
|
||||||
@@ -16,10 +16,10 @@ interface MenuItem {
|
|||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: <Tag className="w-5 h-5" />,
|
icon: <Megaphone className="w-5 h-5" />,
|
||||||
label: __('Coupons'),
|
label: __('Marketing'),
|
||||||
description: __('Manage discount codes and promotions'),
|
description: __('Newsletter, coupons, and promotions'),
|
||||||
to: '/coupons'
|
to: '/marketing'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Palette className="w-5 h-5" />,
|
icon: <Palette className="w-5 h-5" />,
|
||||||
@@ -40,7 +40,7 @@ export default function MorePage() {
|
|||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
const { isStandalone, exitFullscreen } = useApp();
|
const { isStandalone, exitFullscreen } = useApp();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader(__('More'));
|
setPageHeader(__('More'));
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
@@ -56,7 +56,7 @@ export default function MorePage() {
|
|||||||
// Clear auth and redirect to login
|
// Clear auth and redirect to login
|
||||||
window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
||||||
};
|
};
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: 'light', icon: <Sun className="w-5 h-5" />, label: __('Light') },
|
{ value: 'light', icon: <Sun className="w-5 h-5" />, label: __('Light') },
|
||||||
{ value: 'dark', icon: <Moon className="w-5 h-5" />, label: __('Dark') },
|
{ value: 'dark', icon: <Moon className="w-5 h-5" />, label: __('Dark') },
|
||||||
@@ -78,7 +78,7 @@ export default function MorePage() {
|
|||||||
<button
|
<button
|
||||||
key={item.to}
|
key={item.to}
|
||||||
onClick={() => navigate(item.to)}
|
onClick={() => navigate(item.to)}
|
||||||
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
className="w-full flex items-center gap-4 py-4 hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@@ -102,11 +102,10 @@ export default function MorePage() {
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
||||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
|
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${theme === option.value
|
||||||
theme === option.value
|
? 'border-primary bg-primary/10'
|
||||||
? 'border-primary bg-primary/10'
|
: 'border-border hover:border-primary/50'
|
||||||
: 'border-border hover:border-primary/50'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
<span className="text-xs font-medium">{option.label}</span>
|
<span className="text-xs font-medium">{option.label}</span>
|
||||||
@@ -127,7 +126,7 @@ export default function MorePage() {
|
|||||||
{__('Go to WP Admin')}
|
{__('Go to WP Admin')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStandalone ? (
|
{isStandalone ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export default function ProductEdit() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
formRef={formRef}
|
formRef={formRef}
|
||||||
hideSubmitButton={true}
|
hideSubmitButton={true}
|
||||||
|
productId={product.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
||||||
|
|||||||
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Copy, Check, ExternalLink } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DirectCartLinksProps {
|
||||||
|
productId: number;
|
||||||
|
productType: 'simple' | 'variable';
|
||||||
|
variations?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
attributes: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectCartLinks({ productId, productType, variations = [] }: DirectCartLinksProps) {
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const siteUrl = window.location.origin;
|
||||||
|
const spaPagePath = '/store'; // This should ideally come from settings
|
||||||
|
|
||||||
|
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('add-to-cart', productId.toString());
|
||||||
|
if (variationId) {
|
||||||
|
params.set('variation_id', variationId.toString());
|
||||||
|
}
|
||||||
|
if (quantity > 1) {
|
||||||
|
params.set('quantity', quantity.toString());
|
||||||
|
}
|
||||||
|
params.set('redirect', redirect);
|
||||||
|
|
||||||
|
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (link: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopiedLink(link);
|
||||||
|
toast.success(`${label} link copied!`);
|
||||||
|
setTimeout(() => setCopiedLink(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to copy link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkRow = ({
|
||||||
|
label,
|
||||||
|
link,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
description?: string;
|
||||||
|
}) => {
|
||||||
|
const isCopied = copiedLink === link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">{label}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(link, label)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => window.open(link, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={link}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Direct-to-Cart Links</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate copyable links that add this product to cart and redirect to cart or checkout page.
|
||||||
|
Perfect for landing pages, email campaigns, and social media.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Quantity Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-quantity">Default Quantity</Label>
|
||||||
|
<Input
|
||||||
|
id="link-quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set quantity to 1 to exclude from URL (cleaner links)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple Product Links */}
|
||||||
|
{productType === 'simple' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h4 className="font-medium">Simple Product Links</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Add to Cart"
|
||||||
|
link={generateLink(undefined, 'cart')}
|
||||||
|
description="Adds product to cart and shows cart page"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Direct to Checkout"
|
||||||
|
link={generateLink(undefined, 'checkout')}
|
||||||
|
description="Adds product to cart and goes directly to checkout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variable Product Links */}
|
||||||
|
{productType === 'variable' && variations.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h4 className="font-medium">Variable Product Links</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{variations.length} variation(s) - Select a variation to generate links
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{variations.map((variation, index) => (
|
||||||
|
<details key={variation.id} className="group border rounded-lg">
|
||||||
|
<summary className="cursor-pointer p-3 hover:bg-muted/50 flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium text-sm">{variation.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(ID: {variation.id})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 transition-transform group-open:rotate-180"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="p-4 pt-0 space-y-3 border-t">
|
||||||
|
<LinkRow
|
||||||
|
label="Add to Cart"
|
||||||
|
link={generateLink(variation.id, 'cart')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkRow
|
||||||
|
label="Direct to Checkout"
|
||||||
|
link={generateLink(variation.id, 'checkout')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL Parameters Reference */}
|
||||||
|
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||||
|
<h4 className="font-medium text-sm mb-2">URL Parameters Reference</h4>
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">add-to-cart</code> - Product ID (required)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">variation_id</code> - Variation ID (for variable products)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">quantity</code> - Quantity (default: 1)</div>
|
||||||
|
<div><code className="bg-background px-1 py-0.5 rounded">redirect</code> - Destination: <code>cart</code> or <code>checkout</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
formRef?: React.RefObject<HTMLFormElement>;
|
formRef?: React.RefObject<HTMLFormElement>;
|
||||||
hideSubmitButton?: boolean;
|
hideSubmitButton?: boolean;
|
||||||
|
productId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProductFormTabbed({
|
export function ProductFormTabbed({
|
||||||
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
|
|||||||
className,
|
className,
|
||||||
formRef,
|
formRef,
|
||||||
hideSubmitButton = false,
|
hideSubmitButton = false,
|
||||||
|
productId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState(initial?.name || '');
|
const [name, setName] = useState(initial?.name || '');
|
||||||
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
|
|||||||
variations={variations}
|
variations={variations}
|
||||||
setVariations={setVariations}
|
setVariations={setVariations}
|
||||||
regularPrice={regularPrice}
|
regularPrice={regularPrice}
|
||||||
|
productId={productId}
|
||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react';
|
import { Plus, X, Layers, Image as ImageIcon, Copy, Check, ExternalLink } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
import { openWPMediaImage } from '@/lib/wp-media';
|
import { openWPMediaImage } from '@/lib/wp-media';
|
||||||
@@ -30,6 +30,7 @@ type VariationsTabProps = {
|
|||||||
variations: ProductVariant[];
|
variations: ProductVariant[];
|
||||||
setVariations: (value: ProductVariant[]) => void;
|
setVariations: (value: ProductVariant[]) => void;
|
||||||
regularPrice: string;
|
regularPrice: string;
|
||||||
|
productId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VariationsTab({
|
export function VariationsTab({
|
||||||
@@ -38,8 +39,33 @@ export function VariationsTab({
|
|||||||
variations,
|
variations,
|
||||||
setVariations,
|
setVariations,
|
||||||
regularPrice,
|
regularPrice,
|
||||||
|
productId,
|
||||||
}: VariationsTabProps) {
|
}: VariationsTabProps) {
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const siteUrl = window.location.origin;
|
||||||
|
const spaPagePath = '/store';
|
||||||
|
|
||||||
|
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
|
||||||
|
if (!productId) return '';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('add-to-cart', productId.toString());
|
||||||
|
params.set('variation_id', variationId.toString());
|
||||||
|
params.set('redirect', redirect);
|
||||||
|
return `${siteUrl}${spaPagePath}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (link: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopiedLink(link);
|
||||||
|
toast.success(`${label} link copied!`);
|
||||||
|
setTimeout(() => setCopiedLink(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to copy link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addAttribute = () => {
|
const addAttribute = () => {
|
||||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||||
@@ -305,6 +331,45 @@ export function VariationsTab({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Cart Links */}
|
||||||
|
{productId && variation.id && (
|
||||||
|
<div className="mt-4 pt-4 border-t space-y-2">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
{__('Direct-to-Cart Links')}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(generateLink(variation.id!, 'cart'), 'Cart')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateLink(variation.id!, 'cart') ? (
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{__('Copy Cart Link')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToClipboard(generateLink(variation.id!, 'checkout'), 'Checkout')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copiedLink === generateLink(variation.id!, 'checkout') ? (
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{__('Copy Checkout Link')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
255
admin-spa/src/routes/ResetPassword.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Lock } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const key = searchParams.get('key') || '';
|
||||||
|
const login = searchParams.get('login') || '';
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isValidating, setIsValidating] = useState(true);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Validate the reset key on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const validateKey = async () => {
|
||||||
|
if (!key || !login) {
|
||||||
|
setError(__('Invalid password reset link. Please request a new one.'));
|
||||||
|
setIsValidating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/validate-reset-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, login }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.valid) {
|
||||||
|
setIsValid(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || __('This password reset link has expired or is invalid. Please request a new one.'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(__('Unable to validate reset link. Please try again later.'));
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateKey();
|
||||||
|
}, [key, login]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError(__('Passwords do not match'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError(__('Password must be at least 8 characters long'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, login, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || __('Failed to reset password. Please try again.'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(__('An error occurred. Please try again later.'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password strength indicator
|
||||||
|
const getPasswordStrength = (pwd: string) => {
|
||||||
|
if (pwd.length === 0) return { label: '', color: '' };
|
||||||
|
if (pwd.length < 8) return { label: __('Too short'), color: 'text-red-500' };
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
if (pwd.length >= 8) strength++;
|
||||||
|
if (pwd.length >= 12) strength++;
|
||||||
|
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
|
||||||
|
if (/\d/.test(pwd)) strength++;
|
||||||
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
|
||||||
|
|
||||||
|
if (strength <= 2) return { label: __('Weak'), color: 'text-orange-500' };
|
||||||
|
if (strength <= 3) return { label: __('Medium'), color: 'text-yellow-500' };
|
||||||
|
return { label: __('Strong'), color: 'text-green-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(password);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isValidating) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||||
|
<p className="text-muted-foreground">{__('Validating reset link...')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{__('Password Reset Successful')}</h2>
|
||||||
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
|
{__('Your password has been updated. You can now log in with your new password.')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/login')}>
|
||||||
|
{__('Go to Login')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (invalid key)
|
||||||
|
if (!isValid && error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{__('Invalid Reset Link')}</h2>
|
||||||
|
<p className="text-muted-foreground text-center mb-6">{error}</p>
|
||||||
|
<Button variant="outline" onClick={() => window.location.href = window.WNW_CONFIG?.siteUrl + '/my-account/lost-password/'}>
|
||||||
|
{__('Request New Reset Link')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Lock className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl text-center">{__('Reset Your Password')}</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
{__('Enter your new password below')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{__('New Password')}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={__('Enter new password')}
|
||||||
|
required
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{password && (
|
||||||
|
<p className={`text-sm ${passwordStrength.color}`}>
|
||||||
|
{__('Strength')}: {passwordStrength.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">{__('Confirm Password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder={__('Confirm new password')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{confirmPassword && password !== confirmPassword && (
|
||||||
|
<p className="text-sm text-red-500">{__('Passwords do not match')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{__('Resetting...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
__('Reset Password')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|||||||
interface CustomerSettings {
|
interface CustomerSettings {
|
||||||
auto_register_members: boolean;
|
auto_register_members: boolean;
|
||||||
multiple_addresses_enabled: boolean;
|
multiple_addresses_enabled: boolean;
|
||||||
wishlist_enabled: boolean;
|
|
||||||
vip_min_spent: number;
|
vip_min_spent: number;
|
||||||
vip_min_orders: number;
|
vip_min_orders: number;
|
||||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||||
@@ -25,7 +24,6 @@ export default function CustomersSettings() {
|
|||||||
const [settings, setSettings] = useState<CustomerSettings>({
|
const [settings, setSettings] = useState<CustomerSettings>({
|
||||||
auto_register_members: false,
|
auto_register_members: false,
|
||||||
multiple_addresses_enabled: true,
|
multiple_addresses_enabled: true,
|
||||||
wishlist_enabled: true,
|
|
||||||
vip_min_spent: 1000,
|
vip_min_spent: 1000,
|
||||||
vip_min_orders: 10,
|
vip_min_orders: 10,
|
||||||
vip_timeframe: 'all',
|
vip_timeframe: 'all',
|
||||||
@@ -131,7 +129,7 @@ export default function CustomersSettings() {
|
|||||||
checked={settings.auto_register_members}
|
checked={settings.auto_register_members}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, auto_register_members: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
id="multiple_addresses_enabled"
|
id="multiple_addresses_enabled"
|
||||||
label={__('Enable multiple saved addresses')}
|
label={__('Enable multiple saved addresses')}
|
||||||
@@ -139,14 +137,8 @@ export default function CustomersSettings() {
|
|||||||
checked={settings.multiple_addresses_enabled}
|
checked={settings.multiple_addresses_enabled}
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
|
||||||
id="wishlist_enabled"
|
|
||||||
label={__('Enable wishlist')}
|
|
||||||
description={__('Allow customers to save products to their wishlist for later purchase. Customers can add products to wishlist from product cards and manage them in their account.')}
|
|
||||||
checked={settings.wishlist_enabled}
|
|
||||||
onCheckedChange={(checked) => setSettings({ ...settings, wishlist_enabled: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { markdownToHtml } from '@/lib/markdown-utils';
|
|||||||
export default function EditTemplate() {
|
export default function EditTemplate() {
|
||||||
// Mobile responsive check
|
// Mobile responsive check
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
checkMobile();
|
checkMobile();
|
||||||
@@ -28,63 +28,15 @@ export default function EditTemplate() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const eventId = searchParams.get('event');
|
const eventId = searchParams.get('event');
|
||||||
const channelId = searchParams.get('channel');
|
const channelId = searchParams.get('channel');
|
||||||
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
|
||||||
|
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
|
||||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||||
const [activeTab, setActiveTab] = useState('preview');
|
const [activeTab, setActiveTab] = useState('preview');
|
||||||
|
|
||||||
// All available template variables
|
|
||||||
const availableVariables = [
|
|
||||||
// Order variables
|
|
||||||
'order_number',
|
|
||||||
'order_id',
|
|
||||||
'order_date',
|
|
||||||
'order_total',
|
|
||||||
'order_subtotal',
|
|
||||||
'order_tax',
|
|
||||||
'order_shipping',
|
|
||||||
'order_discount',
|
|
||||||
'order_status',
|
|
||||||
'order_url',
|
|
||||||
'order_items_table',
|
|
||||||
'completion_date',
|
|
||||||
'estimated_delivery',
|
|
||||||
// Customer variables
|
|
||||||
'customer_name',
|
|
||||||
'customer_first_name',
|
|
||||||
'customer_last_name',
|
|
||||||
'customer_email',
|
|
||||||
'customer_phone',
|
|
||||||
'billing_address',
|
|
||||||
'shipping_address',
|
|
||||||
// Payment variables
|
|
||||||
'payment_method',
|
|
||||||
'payment_status',
|
|
||||||
'payment_date',
|
|
||||||
'transaction_id',
|
|
||||||
'payment_retry_url',
|
|
||||||
// Shipping/Tracking variables
|
|
||||||
'tracking_number',
|
|
||||||
'tracking_url',
|
|
||||||
'shipping_carrier',
|
|
||||||
'shipping_method',
|
|
||||||
// URL variables
|
|
||||||
'review_url',
|
|
||||||
'shop_url',
|
|
||||||
'my_account_url',
|
|
||||||
// Store variables
|
|
||||||
'site_name',
|
|
||||||
'site_title',
|
|
||||||
'store_name',
|
|
||||||
'store_url',
|
|
||||||
'support_email',
|
|
||||||
'current_year',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fetch email customization settings
|
// Fetch email customization settings
|
||||||
const { data: emailSettings } = useQuery({
|
const { data: emailSettings } = useQuery({
|
||||||
@@ -101,20 +53,20 @@ export default function EditTemplate() {
|
|||||||
console.log('API Response:', response);
|
console.log('API Response:', response);
|
||||||
console.log('API Response.data:', response.data);
|
console.log('API Response.data:', response.data);
|
||||||
console.log('API Response type:', typeof response);
|
console.log('API Response type:', typeof response);
|
||||||
|
|
||||||
// The api.get might already unwrap response.data
|
// The api.get might already unwrap response.data
|
||||||
// Return the response directly if it has the template fields
|
// Return the response directly if it has the template fields
|
||||||
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
||||||
console.log('Returning response directly:', response);
|
console.log('Returning response directly:', response);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise return response.data
|
// Otherwise return response.data
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
console.log('Returning response.data:', response.data);
|
console.log('Returning response.data:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
enabled: !!eventId && !!channelId,
|
enabled: !!eventId && !!channelId,
|
||||||
@@ -123,11 +75,11 @@ export default function EditTemplate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template) {
|
if (template) {
|
||||||
setSubject(template.subject || '');
|
setSubject(template.subject || '');
|
||||||
|
|
||||||
// Always treat body as markdown (source of truth)
|
// Always treat body as markdown (source of truth)
|
||||||
const markdown = template.body || '';
|
const markdown = template.body || '';
|
||||||
setMarkdownContent(markdown);
|
setMarkdownContent(markdown);
|
||||||
|
|
||||||
// Convert to blocks for visual mode
|
// Convert to blocks for visual mode
|
||||||
const initialBlocks = markdownToBlocks(markdown);
|
const initialBlocks = markdownToBlocks(markdown);
|
||||||
setBlocks(initialBlocks);
|
setBlocks(initialBlocks);
|
||||||
@@ -151,7 +103,7 @@ export default function EditTemplate() {
|
|||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||||
@@ -168,7 +120,7 @@ export default function EditTemplate() {
|
|||||||
const markdown = blocksToMarkdown(newBlocks);
|
const markdown = blocksToMarkdown(newBlocks);
|
||||||
setMarkdownContent(markdown); // Update markdown (source of truth)
|
setMarkdownContent(markdown); // Update markdown (source of truth)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Markdown mode: Update markdown → Blocks (for visual sync)
|
// Markdown mode: Update markdown → Blocks (for visual sync)
|
||||||
const handleMarkdownChange = (newMarkdown: string) => {
|
const handleMarkdownChange = (newMarkdown: string) => {
|
||||||
setMarkdownContent(newMarkdown); // Update source of truth
|
setMarkdownContent(newMarkdown); // Update source of truth
|
||||||
@@ -176,9 +128,11 @@ export default function EditTemplate() {
|
|||||||
setBlocks(newBlocks); // Keep blocks in sync
|
setBlocks(newBlocks); // Keep blocks in sync
|
||||||
};
|
};
|
||||||
|
|
||||||
// Variable keys for the rich text editor dropdown
|
// Variable keys for the rich text editor dropdown - from API (contextual per event)
|
||||||
const variableKeys = availableVariables;
|
const variableKeys = template?.available_variables
|
||||||
|
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Parse [card] tags and [button] shortcodes for preview
|
// Parse [card] tags and [button] shortcodes for preview
|
||||||
const parseCardsForPreview = (content: string) => {
|
const parseCardsForPreview = (content: string) => {
|
||||||
// Parse card blocks - new [card:type] syntax
|
// Parse card blocks - new [card:type] syntax
|
||||||
@@ -187,7 +141,7 @@ export default function EditTemplate() {
|
|||||||
const htmlContent = markdownToHtml(cardContent.trim());
|
const htmlContent = markdownToHtml(cardContent.trim());
|
||||||
return `<div class="${cardClass}">${htmlContent}</div>`;
|
return `<div class="${cardClass}">${htmlContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
|
||||||
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
|
||||||
let cardClass = 'card';
|
let cardClass = 'card';
|
||||||
@@ -195,27 +149,27 @@ export default function EditTemplate() {
|
|||||||
if (typeMatch) {
|
if (typeMatch) {
|
||||||
cardClass += ` card-${typeMatch[1]}`;
|
cardClass += ` card-${typeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||||
|
|
||||||
// Convert markdown inside card to HTML
|
// Convert markdown inside card to HTML
|
||||||
const htmlContent = markdownToHtml(cardContent.trim());
|
const htmlContent = markdownToHtml(cardContent.trim());
|
||||||
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
|
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
|
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
|
||||||
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
|
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
|
||||||
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,19 +177,19 @@ export default function EditTemplate() {
|
|||||||
const generatePreviewHTML = () => {
|
const generatePreviewHTML = () => {
|
||||||
// Convert markdown to HTML for preview
|
// Convert markdown to HTML for preview
|
||||||
let previewBody = parseCardsForPreview(markdownContent);
|
let previewBody = parseCardsForPreview(markdownContent);
|
||||||
|
|
||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
store_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
previewBody = previewBody.replace(regex, value);
|
previewBody = previewBody.replace(regex, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace dynamic variables with sample data (not just highlighting)
|
// Replace dynamic variables with sample data (not just highlighting)
|
||||||
const sampleData: { [key: string]: string } = {
|
const sampleData: { [key: string]: string } = {
|
||||||
order_number: '12345',
|
order_number: '12345',
|
||||||
@@ -310,24 +264,30 @@ export default function EditTemplate() {
|
|||||||
store_url: '#',
|
store_url: '#',
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
support_email: 'support@example.com',
|
support_email: 'support@example.com',
|
||||||
|
// Account-related URLs and variables
|
||||||
|
login_url: '#',
|
||||||
|
reset_link: '#',
|
||||||
|
reset_key: 'abc123xyz',
|
||||||
|
user_login: 'johndoe',
|
||||||
|
user_email: 'john@example.com',
|
||||||
|
user_temp_password: '••••••••',
|
||||||
|
customer_first_name: 'John',
|
||||||
|
customer_last_name: 'Doe',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(sampleData).forEach((key) => {
|
Object.keys(sampleData).forEach((key) => {
|
||||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
previewBody = previewBody.replace(regex, sampleData[key]);
|
previewBody = previewBody.replace(regex, sampleData[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight variables that don't have sample data
|
// Highlight variables that don't have sample data
|
||||||
availableVariables.forEach(key => {
|
// Use plain text [variable] instead of HTML spans to avoid breaking href attributes
|
||||||
|
variableKeys.forEach((key: string) => {
|
||||||
if (!storeVariables[key] && !sampleData[key]) {
|
if (!storeVariables[key] && !sampleData[key]) {
|
||||||
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
|
||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card] tags
|
|
||||||
previewBody = parseCardsForPreview(previewBody);
|
|
||||||
|
|
||||||
// Get email settings for preview
|
// Get email settings for preview
|
||||||
const settings = emailSettings || {};
|
const settings = emailSettings || {};
|
||||||
const primaryColor = settings.primary_color || '#7f54b3';
|
const primaryColor = settings.primary_color || '#7f54b3';
|
||||||
@@ -342,10 +302,10 @@ export default function EditTemplate() {
|
|||||||
const headerText = settings.header_text || 'My WordPress Store';
|
const headerText = settings.header_text || 'My WordPress Store';
|
||||||
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
|
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
|
||||||
const socialLinks = settings.social_links || [];
|
const socialLinks = settings.social_links || [];
|
||||||
|
|
||||||
// Replace {current_year} in footer
|
// Replace {current_year} in footer
|
||||||
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
|
||||||
|
|
||||||
// Generate social icons HTML with PNG images
|
// Generate social icons HTML with PNG images
|
||||||
const pluginUrl =
|
const pluginUrl =
|
||||||
(window as any).woonoowData?.pluginUrl ||
|
(window as any).woonoowData?.pluginUrl ||
|
||||||
@@ -360,7 +320,7 @@ export default function EditTemplate() {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -380,14 +340,13 @@ export default function EditTemplate() {
|
|||||||
.header { padding: 20px 16px; }
|
.header { padding: 20px 16px; }
|
||||||
.footer { padding: 20px 16px; }
|
.footer { padding: 20px 16px; }
|
||||||
}
|
}
|
||||||
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-success { background-color: #f0fdf4; }
|
||||||
.card-success * { color: ${heroTextColor} !important; }
|
|
||||||
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||||
.card-highlight * { color: ${heroTextColor} !important; }
|
.card-highlight * { color: ${heroTextColor} !important; }
|
||||||
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||||
.card-hero * { color: ${heroTextColor} !important; }
|
.card-hero * { color: ${heroTextColor} !important; }
|
||||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
.card-info { background-color: #f0f7ff; }
|
||||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
.card-warning { background-color: #fff8e1; }
|
||||||
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
||||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
@@ -416,7 +375,7 @@ export default function EditTemplate() {
|
|||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get social icon emoji
|
// Helper function to get social icon emoji
|
||||||
const getSocialIcon = (platform: string) => {
|
const getSocialIcon = (platform: string) => {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
@@ -492,91 +451,91 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 space-y-6">
|
<CardContent className="pt-6 space-y-6">
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="subject"
|
id="subject"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
placeholder={__('Enter notification subject')}
|
placeholder={__('Enter notification subject')}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{channelId === 'email'
|
{channelId === 'email'
|
||||||
? __('Email subject line')
|
? __('Email subject line')
|
||||||
: __('Push notification title')}
|
: __('Push notification title')}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Three-tab system: Preview | Visual | Markdown */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{__('Message Body')}</Label>
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
||||||
|
<TabsList className="grid grid-cols-3">
|
||||||
|
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{__('Preview')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
{__('Visual')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
{__('Markdown')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Preview Tab */}
|
||||||
<div className="space-y-4">
|
{activeTab === 'preview' && (
|
||||||
{/* Three-tab system: Preview | Visual | Markdown */}
|
<div className="border rounded-md overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<iframe
|
||||||
<Label>{__('Message Body')}</Label>
|
srcDoc={generatePreviewHTML()}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
|
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||||
<TabsList className="grid grid-cols-3">
|
title={__('Email Preview')}
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
|
/>
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
{__('Preview')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
{__('Visual')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
|
|
||||||
<FileText className="h-3 w-3" />
|
|
||||||
{__('Markdown')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Preview Tab */}
|
|
||||||
{activeTab === 'preview' && (
|
{/* Visual Tab */}
|
||||||
<div className="border rounded-md overflow-hidden">
|
{activeTab === 'visual' && (
|
||||||
<iframe
|
<div>
|
||||||
srcDoc={generatePreviewHTML()}
|
<EmailBuilder
|
||||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
blocks={blocks}
|
||||||
title={__('Email Preview')}
|
onChange={handleBlocksChange}
|
||||||
/>
|
variables={variableKeys}
|
||||||
</div>
|
/>
|
||||||
)}
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
||||||
{/* Visual Tab */}
|
</p>
|
||||||
{activeTab === 'visual' && (
|
</div>
|
||||||
<div>
|
)}
|
||||||
<EmailBuilder
|
|
||||||
blocks={blocks}
|
{/* Markdown Tab */}
|
||||||
onChange={handleBlocksChange}
|
{activeTab === 'markdown' && (
|
||||||
variables={variableKeys}
|
<div className="space-y-2">
|
||||||
/>
|
<CodeEditor
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
value={markdownContent}
|
||||||
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
|
onChange={handleMarkdownChange}
|
||||||
</p>
|
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||||
</div>
|
supportMarkdown={true}
|
||||||
)}
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
{/* Markdown Tab */}
|
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
||||||
{activeTab === 'markdown' && (
|
</p>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-muted-foreground">
|
||||||
<CodeEditor
|
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
||||||
value={markdownContent}
|
</p>
|
||||||
onChange={handleMarkdownChange}
|
</div>
|
||||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
)}
|
||||||
supportMarkdown={true}
|
</div>
|
||||||
/>
|
</CardContent>
|
||||||
<p className="text-xs text-muted-foreground">
|
</Card>
|
||||||
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{__('All changes are automatically synced between Visual and Markdown modes.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ export default function TemplateEditor({
|
|||||||
|
|
||||||
// Get variable keys for the rich text editor
|
// Get variable keys for the rich text editor
|
||||||
const variableKeys = Object.keys(variables);
|
const variableKeys = Object.keys(variables);
|
||||||
|
|
||||||
// Parse [card] tags for preview
|
// Parse [card] tags for preview
|
||||||
const parseCardsForPreview = (content: string) => {
|
const parseCardsForPreview = (content: string) => {
|
||||||
// Match [card ...] ... [/card] patterns
|
// Match [card ...] ... [/card] patterns
|
||||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||||
|
|
||||||
return content.replace(cardRegex, (match, attributes, cardContent) => {
|
return content.replace(cardRegex, (match, attributes, cardContent) => {
|
||||||
// Parse attributes
|
// Parse attributes
|
||||||
let cardClass = 'card';
|
let cardClass = 'card';
|
||||||
@@ -109,10 +109,10 @@ export default function TemplateEditor({
|
|||||||
if (typeMatch) {
|
if (typeMatch) {
|
||||||
cardClass += ` card-${typeMatch[1]}`;
|
cardClass += ` card-${typeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
|
||||||
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
|
||||||
|
|
||||||
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
|
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -120,18 +120,18 @@ export default function TemplateEditor({
|
|||||||
// Generate preview HTML
|
// Generate preview HTML
|
||||||
const generatePreviewHTML = () => {
|
const generatePreviewHTML = () => {
|
||||||
let previewBody = body;
|
let previewBody = body;
|
||||||
|
|
||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
store_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(storeVariables).forEach(([key, value]) => {
|
Object.entries(storeVariables).forEach(([key, value]) => {
|
||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight dynamic variables (non-store variables)
|
// Highlight dynamic variables (non-store variables)
|
||||||
Object.keys(variables).forEach(key => {
|
Object.keys(variables).forEach(key => {
|
||||||
if (!storeVariables[key]) {
|
if (!storeVariables[key]) {
|
||||||
@@ -139,10 +139,10 @@ export default function TemplateEditor({
|
|||||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse [card] tags
|
// Parse [card] tags
|
||||||
previewBody = parseCardsForPreview(previewBody);
|
previewBody = parseCardsForPreview(previewBody);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -153,11 +153,11 @@ export default function TemplateEditor({
|
|||||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||||
.card-gutter { padding: 0 16px; }
|
.card-gutter { padding: 0 16px; }
|
||||||
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
|
||||||
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
|
.card-success { background-color: #f0fdf4; }
|
||||||
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||||
.card-highlight * { color: #fff !important; }
|
.card-highlight * { color: #fff !important; }
|
||||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
.card-info { background-color: #f0f7ff; }
|
||||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
.card-warning { background-color: #fff8e1; }
|
||||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||||
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: ["./src/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
|
||||||
theme: {
|
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: '1rem'
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: 'hsl(var(--border))',
|
|
||||||
input: 'hsl(var(--input))',
|
|
||||||
ring: 'hsl(var(--ring))',
|
|
||||||
background: 'hsl(var(--background))',
|
|
||||||
foreground: 'hsl(var(--foreground))',
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: 'hsl(var(--card))',
|
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
|
||||||
},
|
|
||||||
chart: {
|
|
||||||
'1': 'hsl(var(--chart-1))',
|
|
||||||
'2': 'hsl(var(--chart-2))',
|
|
||||||
'3': 'hsl(var(--chart-3))',
|
|
||||||
'4': 'hsl(var(--chart-4))',
|
|
||||||
'5': 'hsl(var(--chart-5))'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")]
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
|
important: '#woonoow-admin-app',
|
||||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
||||||
theme: {
|
theme: {
|
||||||
container: { center: true, padding: "1rem" },
|
container: { center: true, padding: "1rem" },
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ echo "Copying SPA build files..."
|
|||||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist
|
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist
|
||||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist
|
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist
|
||||||
|
|
||||||
# Customer SPA - only app.js and app.css
|
# Customer SPA - app.js, app.css, and fonts
|
||||||
cp customer-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
cp customer-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||||
cp customer-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
cp customer-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||||
|
if [ -d "customer-spa/dist/fonts" ]; then
|
||||||
|
cp -r customer-spa/dist/fonts ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
|
||||||
|
echo "✓ Copied customer-spa fonts"
|
||||||
|
fi
|
||||||
|
|
||||||
# Admin SPA - only app.js and app.css (no dynamic imports for now)
|
# Admin SPA - app.js and app.css
|
||||||
cp admin-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
cp admin-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
||||||
cp admin-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
cp admin-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
|
||||||
|
|
||||||
|
|||||||
98
check-shop-page.php
Normal file
98
check-shop-page.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Diagnostic script to check Shop page configuration
|
||||||
|
* Upload this to your WordPress root and access via browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Load WordPress
|
||||||
|
require_once(__DIR__ . '/../../../wp-load.php');
|
||||||
|
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
die('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<h1>WooNooW Shop Page Diagnostic</h1>';
|
||||||
|
|
||||||
|
// 1. Check WooCommerce Shop Page ID
|
||||||
|
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||||
|
echo '<h2>1. WooCommerce Shop Page Setting</h2>';
|
||||||
|
echo '<p>Shop Page ID: ' . ($shop_page_id ? $shop_page_id : 'NOT SET') . '</p>';
|
||||||
|
|
||||||
|
if ($shop_page_id) {
|
||||||
|
$shop_page = get_post($shop_page_id);
|
||||||
|
if ($shop_page) {
|
||||||
|
echo '<p>Shop Page Title: ' . esc_html($shop_page->post_title) . '</p>';
|
||||||
|
echo '<p>Shop Page Status: ' . esc_html($shop_page->post_status) . '</p>';
|
||||||
|
echo '<p>Shop Page URL: ' . get_permalink($shop_page_id) . '</p>';
|
||||||
|
echo '<h3>Shop Page Content:</h3>';
|
||||||
|
echo '<pre>' . esc_html($shop_page->post_content) . '</pre>';
|
||||||
|
|
||||||
|
// Check for shortcode
|
||||||
|
if (has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||||
|
echo '<p style="color: green;">✓ Has [woonoow_shop] shortcode</p>';
|
||||||
|
} else {
|
||||||
|
echo '<p style="color: red;">✗ Missing [woonoow_shop] shortcode</p>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo '<p style="color: red;">ERROR: Shop page not found!</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find all pages with woonoow shortcodes
|
||||||
|
echo '<h2>2. Pages with WooNooW Shortcodes</h2>';
|
||||||
|
$pages_with_shortcodes = get_posts([
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
's' => 'woonoow_',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($pages_with_shortcodes)) {
|
||||||
|
echo '<p style="color: orange;">No pages found with woonoow_ shortcodes</p>';
|
||||||
|
} else {
|
||||||
|
echo '<ul>';
|
||||||
|
foreach ($pages_with_shortcodes as $page) {
|
||||||
|
echo '<li>';
|
||||||
|
echo '<strong>' . esc_html($page->post_title) . '</strong> (ID: ' . $page->ID . ')<br>';
|
||||||
|
echo 'URL: ' . get_permalink($page->ID) . '<br>';
|
||||||
|
echo 'Content: <pre>' . esc_html(substr($page->post_content, 0, 200)) . '</pre>';
|
||||||
|
echo '</li>';
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check Customer SPA Settings
|
||||||
|
echo '<h2>3. Customer SPA Settings</h2>';
|
||||||
|
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
|
echo '<pre>' . print_r($spa_settings, true) . '</pre>';
|
||||||
|
|
||||||
|
// 4. Check if pages were created by installer
|
||||||
|
echo '<h2>4. WooNooW Page Options</h2>';
|
||||||
|
$woonoow_pages = [
|
||||||
|
'shop' => get_option('woonoow_shop_page_id'),
|
||||||
|
'cart' => get_option('woonoow_cart_page_id'),
|
||||||
|
'checkout' => get_option('woonoow_checkout_page_id'),
|
||||||
|
'account' => get_option('woonoow_account_page_id'),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($woonoow_pages as $key => $page_id) {
|
||||||
|
echo '<p>' . ucfirst($key) . ' Page ID: ' . ($page_id ? $page_id : 'NOT SET');
|
||||||
|
if ($page_id) {
|
||||||
|
$page = get_post($page_id);
|
||||||
|
if ($page) {
|
||||||
|
echo ' - ' . esc_html($page->post_title) . ' (' . $page->post_status . ')';
|
||||||
|
} else {
|
||||||
|
echo ' - <span style="color: red;">PAGE NOT FOUND</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<hr>';
|
||||||
|
echo '<h2>Recommended Actions:</h2>';
|
||||||
|
echo '<ol>';
|
||||||
|
echo '<li>If Shop page doesn\'t have [woonoow_shop] shortcode, add it to the page content</li>';
|
||||||
|
echo '<li>If Shop page ID doesn\'t match WooCommerce setting, update WooCommerce > Settings > Products > Shop Page</li>';
|
||||||
|
echo '<li>If SPA mode is "disabled", it will only load on pages with shortcodes</li>';
|
||||||
|
echo '<li>If SPA mode is "full", it will load on all WooCommerce pages</li>';
|
||||||
|
echo '</ol>';
|
||||||
20
composer.lock
generated
Normal file
20
composer.lock
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
|
||||||
|
"packages": [],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.9.0"
|
||||||
|
}
|
||||||
2
customer-spa/package-lock.json
generated
2
customer-spa/package-lock.json
generated
@@ -14,7 +14,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import Checkout from './pages/Checkout';
|
|||||||
import ThankYou from './pages/ThankYou';
|
import ThankYou from './pages/ThankYou';
|
||||||
import Account from './pages/Account';
|
import Account from './pages/Account';
|
||||||
import Wishlist from './pages/Wishlist';
|
import Wishlist from './pages/Wishlist';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
|
||||||
// Create QueryClient instance
|
// Create QueryClient instance
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -30,7 +33,7 @@ const queryClient = new QueryClient({
|
|||||||
// Get theme config from window (injected by PHP)
|
// Get theme config from window (injected by PHP)
|
||||||
const getThemeConfig = () => {
|
const getThemeConfig = () => {
|
||||||
const config = (window as any).woonoowCustomer?.theme;
|
const config = (window as any).woonoowCustomer?.theme;
|
||||||
|
|
||||||
// Default config if not provided
|
// Default config if not provided
|
||||||
return config || {
|
return config || {
|
||||||
mode: 'full',
|
mode: 'full',
|
||||||
@@ -51,39 +54,66 @@ const getAppearanceSettings = () => {
|
|||||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get initial route from data attribute (set by PHP based on SPA mode)
|
||||||
|
const getInitialRoute = () => {
|
||||||
|
const appEl = document.getElementById('woonoow-customer-app');
|
||||||
|
const initialRoute = appEl?.getAttribute('data-initial-route');
|
||||||
|
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
|
||||||
|
console.log('[WooNooW Customer] App element:', appEl);
|
||||||
|
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
|
||||||
|
return initialRoute || '/shop'; // Default to shop if not specified
|
||||||
|
};
|
||||||
|
|
||||||
|
// Router wrapper component that uses hooks requiring Router context
|
||||||
|
function AppRoutes() {
|
||||||
|
const initialRoute = getInitialRoute();
|
||||||
|
console.log('[WooNooW Customer] Using initial route:', initialRoute);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout>
|
||||||
|
<Routes>
|
||||||
|
{/* Root route redirects to initial route based on SPA mode */}
|
||||||
|
<Route path="/" element={<Navigate to={initialRoute} replace />} />
|
||||||
|
|
||||||
|
{/* Shop Routes */}
|
||||||
|
<Route path="/shop" element={<Shop />} />
|
||||||
|
<Route path="/product/:slug" element={<Product />} />
|
||||||
|
|
||||||
|
{/* Cart & Checkout */}
|
||||||
|
<Route path="/cart" element={<Cart />} />
|
||||||
|
<Route path="/checkout" element={<Checkout />} />
|
||||||
|
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||||
|
|
||||||
|
{/* Wishlist - Public route accessible to guests */}
|
||||||
|
<Route path="/wishlist" element={<Wishlist />} />
|
||||||
|
|
||||||
|
{/* Login & Auth */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
|
||||||
|
{/* My Account */}
|
||||||
|
<Route path="/my-account/*" element={<Account />} />
|
||||||
|
|
||||||
|
{/* Fallback to initial route */}
|
||||||
|
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const themeConfig = getThemeConfig();
|
const themeConfig = getThemeConfig();
|
||||||
const appearanceSettings = getAppearanceSettings();
|
const appearanceSettings = getAppearanceSettings();
|
||||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider config={themeConfig}>
|
<ThemeProvider config={themeConfig}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<BaseLayout>
|
<AppRoutes />
|
||||||
<Routes>
|
|
||||||
{/* Shop Routes */}
|
|
||||||
<Route path="/" element={<Shop />} />
|
|
||||||
<Route path="/shop" element={<Shop />} />
|
|
||||||
<Route path="/product/:slug" element={<Product />} />
|
|
||||||
|
|
||||||
{/* Cart & Checkout */}
|
|
||||||
<Route path="/cart" element={<Cart />} />
|
|
||||||
<Route path="/checkout" element={<Checkout />} />
|
|
||||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
|
||||||
|
|
||||||
{/* Wishlist - Public route accessible to guests */}
|
|
||||||
<Route path="/wishlist" element={<Wishlist />} />
|
|
||||||
|
|
||||||
{/* My Account */}
|
|
||||||
<Route path="/my-account/*" element={<Account />} />
|
|
||||||
|
|
||||||
{/* Fallback */}
|
|
||||||
<Route path="*" element={<Navigate to="/shop" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</BaseLayout>
|
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
|
|
||||||
{/* Toast notifications - position from settings */}
|
{/* Toast notifications - position from settings */}
|
||||||
<Toaster position={toastPosition} richColors />
|
<Toaster position={toastPosition} richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
139
customer-spa/src/components/ui/alert-dialog.tsx
Normal file
139
customer-spa/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
24
customer-spa/src/components/ui/input.tsx
Normal file
24
customer-spa/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
20
customer-spa/src/components/ui/label.tsx
Normal file
20
customer-spa/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
128
customer-spa/src/hooks/useAddToCartFromUrl.ts
Normal file
128
customer-spa/src/hooks/useAddToCartFromUrl.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle add-to-cart from URL parameters
|
||||||
|
* Supports both simple and variable products
|
||||||
|
*
|
||||||
|
* URL formats:
|
||||||
|
* - Simple product: ?add-to-cart=123
|
||||||
|
* - Variable product: ?add-to-cart=123&variation_id=456
|
||||||
|
* - With quantity: ?add-to-cart=123&quantity=2
|
||||||
|
* - Direct to checkout: ?add-to-cart=123&redirect=checkout
|
||||||
|
* - Stay on cart (default): ?add-to-cart=123&redirect=cart
|
||||||
|
*/
|
||||||
|
export function useAddToCartFromUrl() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { setCart } = useCartStore();
|
||||||
|
const processedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check hash route for add-to-cart parameters
|
||||||
|
const hash = window.location.hash;
|
||||||
|
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
||||||
|
const productId = hashParams.get('add-to-cart');
|
||||||
|
|
||||||
|
if (!productId) return;
|
||||||
|
|
||||||
|
const variationId = hashParams.get('variation_id');
|
||||||
|
const quantity = parseInt(hashParams.get('quantity') || '1', 10);
|
||||||
|
const redirect = hashParams.get('redirect') || 'cart';
|
||||||
|
|
||||||
|
// Create unique key for this add-to-cart request
|
||||||
|
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
|
||||||
|
|
||||||
|
// Skip if already processed
|
||||||
|
if (processedRef.current.has(requestKey)) {
|
||||||
|
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WooNooW] Add to cart from URL:', {
|
||||||
|
productId,
|
||||||
|
variationId,
|
||||||
|
quantity,
|
||||||
|
redirect,
|
||||||
|
fullUrl: window.location.href,
|
||||||
|
requestKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
processedRef.current.add(requestKey);
|
||||||
|
|
||||||
|
addToCart(productId, variationId, quantity)
|
||||||
|
.then((cartData) => {
|
||||||
|
// Update cart store with fresh data from API
|
||||||
|
if (cartData) {
|
||||||
|
setCart(cartData);
|
||||||
|
console.log('[WooNooW] Cart updated with fresh data:', cartData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove URL parameters after adding to cart
|
||||||
|
const currentPath = window.location.hash.split('?')[0];
|
||||||
|
window.location.hash = currentPath;
|
||||||
|
|
||||||
|
// Navigate based on redirect parameter
|
||||||
|
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
|
||||||
|
if (!location.pathname.includes(targetPage)) {
|
||||||
|
console.log(`[WooNooW] Navigating to ${targetPage}`);
|
||||||
|
navigate(targetPage);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[WooNooW] Failed to add product to cart:', error);
|
||||||
|
toast.error('Failed to add product to cart');
|
||||||
|
// Remove from processed set on error so it can be retried
|
||||||
|
processedRef.current.delete(requestKey);
|
||||||
|
});
|
||||||
|
}, [location.hash, navigate, setCart]); // Include all dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToCart(
|
||||||
|
productId: string,
|
||||||
|
variationId: string | null,
|
||||||
|
quantity: number
|
||||||
|
): Promise<any> {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
const body: any = {
|
||||||
|
product_id: parseInt(productId, 10),
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variationId) {
|
||||||
|
body.variation_id = parseInt(variationId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WooNooW] Adding to cart:', body);
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to add to cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[WooNooW] Product added to cart:', data);
|
||||||
|
|
||||||
|
// API returns {message, cart_item_key, cart} on success
|
||||||
|
if (data.cart_item_key && data.cart) {
|
||||||
|
toast.success(data.message || 'Product added to cart');
|
||||||
|
return data.cart; // Return cart data to update store
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to add to cart');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ interface BaseLayoutProps {
|
|||||||
*/
|
*/
|
||||||
export function BaseLayout({ children }: BaseLayoutProps) {
|
export function BaseLayout({ children }: BaseLayoutProps) {
|
||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
|
|
||||||
// Map header styles to layouts
|
// Map header styles to layouts
|
||||||
// classic -> ClassicLayout, centered -> ModernLayout, minimal -> LaunchLayout, split -> BoutiqueLayout
|
// classic -> ClassicLayout, centered -> ModernLayout, minimal -> LaunchLayout, split -> BoutiqueLayout
|
||||||
switch (headerSettings.style) {
|
switch (headerSettings.style) {
|
||||||
@@ -53,10 +53,10 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
const footerSettings = useFooterSettings();
|
const footerSettings = useFooterSettings();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const heightClass = headerSettings.height === 'compact' ? 'h-16' : headerSettings.height === 'tall' ? 'h-24' : 'h-20';
|
const heightClass = headerSettings.height === 'compact' ? 'h-16' : headerSettings.height === 'tall' ? 'h-24' : 'h-20';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||||
|
|
||||||
const footerColsClass: Record<string, string> = {
|
const footerColsClass: Record<string, string> = {
|
||||||
'1': 'grid-cols-1',
|
'1': 'grid-cols-1',
|
||||||
'2': 'grid-cols-1 md:grid-cols-2',
|
'2': 'grid-cols-1 md:grid-cols-2',
|
||||||
@@ -64,7 +64,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
'4': 'grid-cols-1 md:grid-cols-4',
|
'4': 'grid-cols-1 md:grid-cols-4',
|
||||||
};
|
};
|
||||||
const footerGridClass = footerColsClass[footerSettings.columns] || 'grid-cols-1 md:grid-cols-4';
|
const footerGridClass = footerColsClass[footerSettings.columns] || 'grid-cols-1 md:grid-cols-4';
|
||||||
|
|
||||||
const headerContent = (
|
const headerContent = (
|
||||||
<>
|
<>
|
||||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
@@ -75,31 +75,31 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||||
<Link to="/shop" className="flex items-center gap-3 group">
|
<Link to="/shop" className="flex items-center gap-3 group">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
alt={storeName}
|
alt={storeName}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: headerSettings.logo_width,
|
width: headerSettings.logo_width,
|
||||||
height: headerSettings.logo_height,
|
height: headerSettings.logo_height,
|
||||||
maxWidth: '100%'
|
maxWidth: '100%'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||||
<span className="text-white font-bold text-xl">W</span>
|
<span className="text-white font-bold text-xl">W</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||||
{storeName}
|
{storeName}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
{headerSettings.elements.navigation && (
|
{headerSettings.elements.navigation && (
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
@@ -108,60 +108,60 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions - Hidden on mobile when using bottom-nav */}
|
{/* Actions - Hidden on mobile when using bottom-nav */}
|
||||||
{hasActions && (
|
{hasActions && (
|
||||||
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Search className="h-5 w-5 text-gray-600" />
|
<Search className="h-5 w-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||||
<Link to="/my-account" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/my-account" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
<span className="hidden lg:block">Account</span>
|
<span className="hidden lg:block">Account</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href="/wp-login.php" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/login" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
<span className="hidden lg:block">Account</span>
|
<span className="hidden lg:block">Account</span>
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Wishlist */}
|
{/* Wishlist */}
|
||||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||||
<Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<Heart className="h-5 w-5" />
|
<Heart className="h-5 w-5" />
|
||||||
<span className="hidden lg:block">Wishlist</span>
|
<span className="hidden lg:block">Wishlist</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cart */}
|
{/* Cart */}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
||||||
{itemCount}
|
{itemCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden lg:block">
|
<span className="hidden lg:block">
|
||||||
Cart ({itemCount})
|
Cart ({itemCount})
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||||
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
|
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
|
||||||
<button
|
<button
|
||||||
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
@@ -171,7 +171,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu - Hamburger Dropdown */}
|
{/* Mobile Menu - Hamburger Dropdown */}
|
||||||
{headerSettings.mobile_menu === 'hamburger' && mobileMenuOpen && (
|
{headerSettings.mobile_menu === 'hamburger' && mobileMenuOpen && (
|
||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
@@ -184,7 +184,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu - Slide-in Drawer */}
|
{/* Mobile Menu - Slide-in Drawer */}
|
||||||
{headerSettings.mobile_menu === 'slide-in' && mobileMenuOpen && (
|
{headerSettings.mobile_menu === 'slide-in' && mobileMenuOpen && (
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +210,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const footerContent = (
|
const footerContent = (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Menu - Bottom Navigation */}
|
{/* Mobile Menu - Bottom Navigation */}
|
||||||
@@ -222,7 +222,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<span>Shop</span>
|
<span>Shop</span>
|
||||||
</Link>
|
</Link>
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
||||||
>
|
>
|
||||||
@@ -248,16 +248,16 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<span>Account</span>
|
<span>Account</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href="/wp-login.php" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
|
<Link to="/login" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
<span>Login</span>
|
<span>Login</span>
|
||||||
</a>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="classic-footer bg-gray-100 border-t mt-auto">
|
<footer className="classic-footer bg-gray-100 border-t mt-auto">
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
<div className={`grid ${footerGridClass} gap-8`}>
|
<div className={`grid ${footerGridClass} gap-8`}>
|
||||||
@@ -272,59 +272,59 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((section: any) => (
|
.map((section: any) => (
|
||||||
<div key={section.id}>
|
<div key={section.id}>
|
||||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Contact Section */}
|
||||||
{section.type === 'contact' && (
|
{section.type === 'contact' && (
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
||||||
<p>Email: {footerSettings.contact_data.email}</p>
|
<p>Email: {footerSettings.contact_data.email}</p>
|
||||||
)}
|
)}
|
||||||
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
||||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||||
)}
|
)}
|
||||||
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
||||||
<p>{footerSettings.contact_data.address}</p>
|
<p>{footerSettings.contact_data.address}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Menu Section */}
|
{/* Menu Section */}
|
||||||
{section.type === 'menu' && (
|
{section.type === 'menu' && (
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li><Link to="/shop" className="text-gray-600 hover:text-primary no-underline">Shop</Link></li>
|
<li><Link to="/shop" className="text-gray-600 hover:text-primary no-underline">Shop</Link></li>
|
||||||
<li><a href="/about" className="text-gray-600 hover:text-primary no-underline">About</a></li>
|
<li><a href="/about" className="text-gray-600 hover:text-primary no-underline">About</a></li>
|
||||||
<li><a href="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
<li><a href="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Social Section */}
|
{/* Social Section */}
|
||||||
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{footerSettings.social_links.map((link: any) => (
|
{footerSettings.social_links.map((link: any) => (
|
||||||
<li key={link.id}>
|
<li key={link.id}>
|
||||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||||
{link.platform}
|
{link.platform}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Newsletter Section */}
|
{/* Newsletter Section */}
|
||||||
{section.type === 'newsletter' && (
|
{section.type === 'newsletter' && (
|
||||||
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom HTML Section */}
|
{/* Custom HTML Section */}
|
||||||
{section.type === 'custom' && (
|
{section.type === 'custom' && (
|
||||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Icons */}
|
{/* Payment Icons */}
|
||||||
{footerSettings.elements.payment && (
|
{footerSettings.elements.payment && (
|
||||||
<div className="mt-8 pt-8 border-t">
|
<div className="mt-8 pt-8 border-t">
|
||||||
@@ -336,7 +336,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* Copyright */}
|
||||||
{footerSettings.elements.copyright && (
|
{footerSettings.elements.copyright && (
|
||||||
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
|
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
|
||||||
@@ -347,7 +347,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutWrapper header={headerContent} footer={footerContent}>
|
<LayoutWrapper header={headerContent} footer={footerContent}>
|
||||||
{children}
|
{children}
|
||||||
@@ -369,10 +369,10 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const paddingClass = headerSettings.height === 'compact' ? 'py-4' : headerSettings.height === 'tall' ? 'py-8' : 'py-6';
|
const paddingClass = headerSettings.height === 'compact' ? 'py-4' : headerSettings.height === 'tall' ? 'py-8' : 'py-6';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modern-layout min-h-screen flex flex-col">
|
<div className="modern-layout min-h-screen flex flex-col">
|
||||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
@@ -383,12 +383,12 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<Link to="/shop" className="mb-4">
|
<Link to="/shop" className="mb-4">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
alt={storeName}
|
alt={storeName}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: headerSettings.logo_width,
|
width: headerSettings.logo_width,
|
||||||
height: headerSettings.logo_height,
|
height: headerSettings.logo_height,
|
||||||
maxWidth: '100%'
|
maxWidth: '100%'
|
||||||
}}
|
}}
|
||||||
@@ -398,7 +398,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation & Actions - Centered */}
|
{/* Navigation & Actions - Centered */}
|
||||||
{(headerSettings.elements.navigation || hasActions) && (
|
{(headerSettings.elements.navigation || hasActions) && (
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
@@ -410,7 +410,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -423,9 +423,9 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href="/wp-login.php" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/login" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
</a>
|
</Link>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||||
@@ -440,16 +440,16 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden mt-4 flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="md:hidden mt-4 flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
@@ -464,11 +464,11 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="modern-main flex-1">
|
<main className="modern-main flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="modern-footer bg-white border-t mt-auto">
|
<footer className="modern-footer bg-white border-t mt-auto">
|
||||||
<div className="container mx-auto px-4 py-12 text-center">
|
<div className="container mx-auto px-4 py-12 text-center">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -504,10 +504,10 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
const { settings: wishlistSettings } = useModuleSettings('wishlist');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const heightClass = headerSettings.height === 'compact' ? 'h-20' : headerSettings.height === 'tall' ? 'h-28' : 'h-24';
|
const heightClass = headerSettings.height === 'compact' ? 'h-20' : headerSettings.height === 'tall' ? 'h-28' : 'h-24';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
||||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
@@ -516,28 +516,28 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className={`flex items-center justify-between ${heightClass}`}>
|
<div className={`flex items-center justify-between ${heightClass}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
|
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Link to="/shop">
|
<Link to="/shop">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
alt={storeName}
|
alt={storeName}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: headerSettings.logo_width,
|
width: headerSettings.logo_width,
|
||||||
height: headerSettings.logo_height,
|
height: headerSettings.logo_height,
|
||||||
maxWidth: '100%'
|
maxWidth: '100%'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex justify-end">
|
<div className="flex-1 flex justify-end">
|
||||||
{(headerSettings.elements.navigation || hasActions) && (
|
{(headerSettings.elements.navigation || hasActions) && (
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
@@ -545,7 +545,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors"
|
className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -557,9 +557,9 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href="/wp-login.php" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/login" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||||
<Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
@@ -573,9 +573,9 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<button
|
||||||
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
@@ -583,7 +583,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div className="md:hidden border-t py-4">
|
<div className="md:hidden border-t py-4">
|
||||||
@@ -596,11 +596,11 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="boutique-main flex-1">
|
<main className="boutique-main flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="boutique-footer bg-gray-50 border-t mt-auto">
|
<footer className="boutique-footer bg-gray-50 border-t mt-auto">
|
||||||
<div className="container mx-auto px-4 py-16 text-center">
|
<div className="container mx-auto px-4 py-16 text-center">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -628,10 +628,10 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
* WooNooW only takes over from checkout onwards
|
* WooNooW only takes over from checkout onwards
|
||||||
*/
|
*/
|
||||||
function LaunchLayout({ children }: BaseLayoutProps) {
|
function LaunchLayout({ children }: BaseLayoutProps) {
|
||||||
const isCheckoutFlow = window.location.pathname.includes('/checkout') ||
|
const isCheckoutFlow = window.location.pathname.includes('/checkout') ||
|
||||||
window.location.pathname.includes('/my-account') ||
|
window.location.pathname.includes('/my-account') ||
|
||||||
window.location.pathname.includes('/order-received');
|
window.location.pathname.includes('/order-received');
|
||||||
|
|
||||||
if (!isCheckoutFlow) {
|
if (!isCheckoutFlow) {
|
||||||
// For non-checkout pages, use minimal layout
|
// For non-checkout pages, use minimal layout
|
||||||
return (
|
return (
|
||||||
@@ -640,14 +640,14 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For checkout flow: minimal header, no footer
|
// For checkout flow: minimal header, no footer
|
||||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||||
const headerSettings = useHeaderSettings();
|
const headerSettings = useHeaderSettings();
|
||||||
|
|
||||||
const heightClass = headerSettings.height === 'compact' ? 'h-12' : headerSettings.height === 'tall' ? 'h-20' : 'h-16';
|
const heightClass = headerSettings.height === 'compact' ? 'h-12' : headerSettings.height === 'tall' ? 'h-20' : 'h-16';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="launch-layout min-h-screen flex flex-col bg-gray-50">
|
<div className="launch-layout min-h-screen flex flex-col bg-gray-50">
|
||||||
<header className="launch-header bg-white border-b">
|
<header className="launch-header bg-white border-b">
|
||||||
@@ -656,12 +656,12 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<Link to="/shop">
|
<Link to="/shop">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
src={storeLogo}
|
src={storeLogo}
|
||||||
alt={storeName}
|
alt={storeName}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
style={{
|
style={{
|
||||||
width: headerSettings.logo_width,
|
width: headerSettings.logo_width,
|
||||||
height: headerSettings.logo_height,
|
height: headerSettings.logo_height,
|
||||||
maxWidth: '100%'
|
maxWidth: '100%'
|
||||||
}}
|
}}
|
||||||
@@ -674,13 +674,13 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="launch-main flex-1 py-8">
|
<main className="launch-main flex-1 py-8">
|
||||||
<div className="container mx-auto px-4 max-w-2xl">
|
<div className="container mx-auto px-4 max-w-2xl">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Minimal footer for checkout */}
|
{/* Minimal footer for checkout */}
|
||||||
<footer className="launch-footer bg-white border-t py-4">
|
<footer className="launch-footer bg-white border-t py-4">
|
||||||
<div className="container mx-auto px-4 text-center text-sm text-gray-600">
|
<div className="container mx-auto px-4 text-center text-sm text-gray-600">
|
||||||
|
|||||||
111
customer-spa/src/lib/cart/api.ts
Normal file
111
customer-spa/src/lib/cart/api.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Cart } from './store';
|
||||||
|
|
||||||
|
const getApiConfig = () => {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
return { apiRoot, nonce };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cart item quantity via API
|
||||||
|
*/
|
||||||
|
export async function updateCartItemQuantity(
|
||||||
|
cartItemKey: string,
|
||||||
|
quantity: number
|
||||||
|
): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
cart_item_key: cartItemKey,
|
||||||
|
quantity,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to update cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove item from cart via API
|
||||||
|
*/
|
||||||
|
export async function removeCartItem(cartItemKey: string): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/remove`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
cart_item_key: cartItemKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to remove item');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear entire cart via API
|
||||||
|
*/
|
||||||
|
export async function clearCartAPI(): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/clear`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to clear cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current cart from API
|
||||||
|
*/
|
||||||
|
export async function fetchCart(): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
|
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface AccountLayoutProps {
|
interface AccountLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,7 +23,8 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
const { isEnabled } = useModules();
|
const { isEnabled } = useModules();
|
||||||
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
const allMenuItems = [
|
const allMenuItems = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||||
@@ -21,14 +33,33 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out wishlist if module disabled or settings disabled
|
// Filter out wishlist if module disabled or settings disabled
|
||||||
const menuItems = allMenuItems.filter(item =>
|
const menuItems = allMenuItems.filter(item =>
|
||||||
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
window.location.href = '/wp-login.php?action=logout';
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
await fetch(`${apiRoot}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full page reload to clear cookies and refresh state
|
||||||
|
window.location.href = window.location.origin + '/store/';
|
||||||
|
} catch (error) {
|
||||||
|
// Even on error, try to redirect and let server handle session
|
||||||
|
window.location.href = window.location.origin + '/store/';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
@@ -38,6 +69,38 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
return location.pathname.startsWith(path);
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logout Button with AlertDialog
|
||||||
|
const LogoutButton = () => (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{isLoggingOut ? 'Logging out...' : 'Logout'}</span>
|
||||||
|
</button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Log out?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to log out of your account? You'll need to sign in again to access your orders and account details.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
Log Out
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
|
||||||
// Sidebar Navigation
|
// Sidebar Navigation
|
||||||
const SidebarNav = () => (
|
const SidebarNav = () => (
|
||||||
<aside className="bg-white rounded-lg border p-4">
|
<aside className="bg-white rounded-lg border p-4">
|
||||||
@@ -52,7 +115,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{menuItems.map((item) => {
|
{menuItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -60,25 +123,18 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${isActive(item.path)
|
||||||
isActive(item.path)
|
? 'bg-primary text-primary-foreground'
|
||||||
? 'bg-primary text-primary-foreground'
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5" />
|
<Icon className="w-5 h-5" />
|
||||||
<span className="font-medium">{item.label}</span>
|
<span className="font-medium">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<button
|
<LogoutButton />
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5" />
|
|
||||||
<span className="font-medium">Logout</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
@@ -93,11 +149,10 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${
|
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${isActive(item.path)
|
||||||
isActive(item.path)
|
? 'border-primary text-primary font-medium'
|
||||||
? 'border-primary text-primary font-medium'
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5" />
|
<Icon className="w-5 h-5" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
@@ -113,7 +168,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
{/* Mobile: Tab Navigation */}
|
{/* Mobile: Tab Navigation */}
|
||||||
<TabNav />
|
<TabNav />
|
||||||
|
|
||||||
{/* Desktop: Sidebar + Content */}
|
{/* Desktop: Sidebar + Content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
<div className="hidden lg:block lg:col-span-1">
|
<div className="hidden lg:block lg:col-span-1">
|
||||||
@@ -128,3 +183,4 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { AccountLayout } from './components/AccountLayout';
|
import { AccountLayout } from './components/AccountLayout';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
@@ -12,11 +12,12 @@ import AccountDetails from './AccountDetails';
|
|||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
if (!user?.isLoggedIn) {
|
if (!user?.isLoggedIn) {
|
||||||
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
|
const currentPath = location.pathname;
|
||||||
return null;
|
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -13,37 +14,96 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react';
|
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Cart() {
|
export default function Cart() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
const { cart, setCart } = useCartStore();
|
||||||
const { layout, elements } = useCartSettings();
|
const { layout, elements } = useCartSettings();
|
||||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch cart from server on mount to sync with WooCommerce
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCart = async () => {
|
||||||
|
try {
|
||||||
|
const serverCart = await fetchCart();
|
||||||
|
setCart(serverCart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cart:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCart();
|
||||||
|
}, [setCart]);
|
||||||
|
|
||||||
// Calculate total from items
|
// Calculate total from items
|
||||||
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||||
|
|
||||||
const handleUpdateQuantity = (key: string, newQuantity: number) => {
|
const handleUpdateQuantity = async (key: string, newQuantity: number) => {
|
||||||
if (newQuantity < 1) {
|
if (newQuantity < 1) {
|
||||||
handleRemoveItem(key);
|
handleRemoveItem(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateQuantity(key, newQuantity);
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const updatedCart = await updateCartItemQuantity(key, newQuantity);
|
||||||
|
setCart(updatedCart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update quantity:', error);
|
||||||
|
toast.error('Failed to update quantity');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveItem = (key: string) => {
|
const handleRemoveItem = async (key: string) => {
|
||||||
removeItem(key);
|
setIsUpdating(true);
|
||||||
toast.success('Item removed from cart');
|
try {
|
||||||
|
const updatedCart = await removeCartItem(key);
|
||||||
|
setCart(updatedCart);
|
||||||
|
toast.success('Item removed from cart');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove item:', error);
|
||||||
|
toast.error('Failed to remove item');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCart = () => {
|
const handleClearCart = async () => {
|
||||||
clearCart();
|
setIsUpdating(true);
|
||||||
setShowClearDialog(false);
|
try {
|
||||||
toast.success('Cart cleared');
|
const updatedCart = await clearCartAPI();
|
||||||
|
setCart(updatedCart);
|
||||||
|
setShowClearDialog(false);
|
||||||
|
toast.success('Cart cleared');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear cart:', error);
|
||||||
|
toast.error('Failed to clear cart');
|
||||||
|
setShowClearDialog(false);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading state while fetching cart
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Loader2 className="mx-auto h-16 w-16 text-gray-400 mb-4 animate-spin" />
|
||||||
|
<p className="text-gray-600">Loading cart...</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (cart.items.length === 0) {
|
if (cart.items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function Checkout() {
|
|||||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||||
const [orderNotes, setOrderNotes] = useState('');
|
const [orderNotes, setOrderNotes] = useState('');
|
||||||
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
|
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
|
||||||
|
|
||||||
// Saved addresses
|
// Saved addresses
|
||||||
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
|
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
|
||||||
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
|
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
|
||||||
@@ -92,15 +92,15 @@ export default function Checkout() {
|
|||||||
setLoadingAddresses(false);
|
setLoadingAddresses(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const addresses = await api.get<SavedAddress[]>('/account/addresses');
|
const addresses = await api.get<SavedAddress[]>('/account/addresses');
|
||||||
setSavedAddresses(addresses);
|
setSavedAddresses(addresses);
|
||||||
|
|
||||||
// Auto-select default addresses
|
// Auto-select default addresses
|
||||||
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
|
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
|
||||||
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
|
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
|
||||||
|
|
||||||
if (defaultBilling) {
|
if (defaultBilling) {
|
||||||
setSelectedBillingAddressId(defaultBilling.id);
|
setSelectedBillingAddressId(defaultBilling.id);
|
||||||
fillBillingFromAddress(defaultBilling);
|
fillBillingFromAddress(defaultBilling);
|
||||||
@@ -117,10 +117,10 @@ export default function Checkout() {
|
|||||||
setLoadingAddresses(false);
|
setLoadingAddresses(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAddresses();
|
loadAddresses();
|
||||||
}, [user, isVirtualOnly]);
|
}, [user, isVirtualOnly]);
|
||||||
|
|
||||||
// Helper functions to fill forms from saved addresses
|
// Helper functions to fill forms from saved addresses
|
||||||
const fillBillingFromAddress = (address: SavedAddress) => {
|
const fillBillingFromAddress = (address: SavedAddress) => {
|
||||||
setBillingData({
|
setBillingData({
|
||||||
@@ -135,7 +135,7 @@ export default function Checkout() {
|
|||||||
country: address.country,
|
country: address.country,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fillShippingFromAddress = (address: SavedAddress) => {
|
const fillShippingFromAddress = (address: SavedAddress) => {
|
||||||
setShippingData({
|
setShippingData({
|
||||||
firstName: address.first_name,
|
firstName: address.first_name,
|
||||||
@@ -147,13 +147,13 @@ export default function Checkout() {
|
|||||||
country: address.country,
|
country: address.country,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectBillingAddress = (address: SavedAddress) => {
|
const handleSelectBillingAddress = (address: SavedAddress) => {
|
||||||
setSelectedBillingAddressId(address.id);
|
setSelectedBillingAddressId(address.id);
|
||||||
fillBillingFromAddress(address);
|
fillBillingFromAddress(address);
|
||||||
setShowBillingForm(false); // Hide form when address is selected
|
setShowBillingForm(false); // Hide form when address is selected
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectShippingAddress = (address: SavedAddress) => {
|
const handleSelectShippingAddress = (address: SavedAddress) => {
|
||||||
setSelectedShippingAddressId(address.id);
|
setSelectedShippingAddressId(address.id);
|
||||||
fillShippingFromAddress(address);
|
fillShippingFromAddress(address);
|
||||||
@@ -235,15 +235,18 @@ export default function Checkout() {
|
|||||||
// Submit order
|
// Submit order
|
||||||
const response = await apiClient.post('/checkout/submit', orderData);
|
const response = await apiClient.post('/checkout/submit', orderData);
|
||||||
const data = (response as any).data || response;
|
const data = (response as any).data || response;
|
||||||
|
|
||||||
if (data.ok && data.order_id) {
|
if (data.ok && data.order_id) {
|
||||||
// Clear cart
|
// Clear cart - use store method directly
|
||||||
cart.items.forEach(item => {
|
useCartStore.getState().clearCart();
|
||||||
useCartStore.getState().removeItem(item.key);
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success('Order placed successfully!');
|
toast.success('Order placed successfully!');
|
||||||
navigate(`/order-received/${data.order_id}`);
|
|
||||||
|
// Use full page reload instead of SPA routing
|
||||||
|
// This ensures auto-registered users get their auth cookies properly set
|
||||||
|
const thankYouUrl = `${window.location.origin}/store/#/order-received/${data.order_id}?key=${data.order_key}`;
|
||||||
|
window.location.href = thankYouUrl;
|
||||||
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'Failed to create order');
|
throw new Error(data.error || 'Failed to create order');
|
||||||
}
|
}
|
||||||
@@ -307,7 +310,7 @@ export default function Checkout() {
|
|||||||
Change Address
|
Change Address
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedBillingAddressId ? (
|
{selectedBillingAddressId ? (
|
||||||
(() => {
|
(() => {
|
||||||
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
|
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
|
||||||
@@ -344,7 +347,7 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Billing Address Modal */}
|
{/* Billing Address Modal */}
|
||||||
<AddressSelector
|
<AddressSelector
|
||||||
isOpen={showBillingModal}
|
isOpen={showBillingModal}
|
||||||
@@ -354,204 +357,19 @@ export default function Checkout() {
|
|||||||
onSelectAddress={handleSelectBillingAddress}
|
onSelectAddress={handleSelectBillingAddress}
|
||||||
type="billing"
|
type="billing"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
|
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
|
||||||
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
|
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
|
||||||
<div className="bg-white border rounded-lg p-6">
|
<div className="bg-white border rounded-lg p-6">
|
||||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.firstName}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.lastName}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={billingData.email}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
value={billingData.phone}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address fields - only for physical products */}
|
|
||||||
{!isVirtualOnly && (
|
|
||||||
<>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.address}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">City *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.city}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.state}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.postcode}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={billingData.country}
|
|
||||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ship to Different Address - only for physical products */}
|
|
||||||
{!isVirtualOnly && (
|
|
||||||
<div className="bg-white border rounded-lg p-6">
|
|
||||||
<label className="flex items-center gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={shipToDifferentAddress}
|
|
||||||
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
<span className="font-medium">Ship to a different address?</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{shipToDifferentAddress && (
|
|
||||||
<>
|
|
||||||
{/* Selected Shipping Address Summary */}
|
|
||||||
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
Shipping Address
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowShippingModal(true)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
Change Address
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedShippingAddressId ? (
|
|
||||||
(() => {
|
|
||||||
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
|
|
||||||
return selected ? (
|
|
||||||
<div>
|
|
||||||
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<p className="font-semibold">{selected.label}</p>
|
|
||||||
{selected.is_default && (
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
|
|
||||||
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
|
|
||||||
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
|
|
||||||
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
|
|
||||||
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
|
|
||||||
<p className="text-sm text-gray-600">{selected.country}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowShippingForm(true)}
|
|
||||||
className="mt-3 text-primary hover:text-primary"
|
|
||||||
>
|
|
||||||
Use a different address
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-500 text-sm">No address selected</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shipping Address Modal */}
|
|
||||||
<AddressSelector
|
|
||||||
isOpen={showShippingModal}
|
|
||||||
onClose={() => setShowShippingModal(false)}
|
|
||||||
addresses={savedAddresses}
|
|
||||||
selectedAddressId={selectedShippingAddressId}
|
|
||||||
onSelectAddress={handleSelectShippingAddress}
|
|
||||||
type="shipping"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
|
|
||||||
{(!selectedShippingAddressId || showShippingForm) && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={shippingData.firstName}
|
value={billingData.firstName}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,66 +378,251 @@ export default function Checkout() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={shippingData.lastName}
|
value={billingData.lastName}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
required
|
required
|
||||||
value={shippingData.address}
|
value={billingData.email}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-2">City *</label>
|
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="tel"
|
||||||
required
|
required
|
||||||
value={shippingData.city}
|
value={billingData.phone}
|
||||||
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={shippingData.state}
|
|
||||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={shippingData.postcode}
|
|
||||||
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={shippingData.country}
|
|
||||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
|
||||||
className="w-full border rounded-lg px-4 py-2"
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address fields - only for physical products */}
|
||||||
|
{!isVirtualOnly && (
|
||||||
|
<>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={billingData.address}
|
||||||
|
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={billingData.city}
|
||||||
|
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={billingData.state}
|
||||||
|
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={billingData.postcode}
|
||||||
|
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={billingData.country}
|
||||||
|
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ship to Different Address - only for physical products */}
|
||||||
|
{!isVirtualOnly && (
|
||||||
|
<div className="bg-white border rounded-lg p-6">
|
||||||
|
<label className="flex items-center gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shipToDifferentAddress}
|
||||||
|
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">Ship to a different address?</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{shipToDifferentAddress && (
|
||||||
|
<>
|
||||||
|
{/* Selected Shipping Address Summary */}
|
||||||
|
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Shipping Address
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowShippingModal(true)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
Change Address
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedShippingAddressId ? (
|
||||||
|
(() => {
|
||||||
|
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
|
||||||
|
return selected ? (
|
||||||
|
<div>
|
||||||
|
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<p className="font-semibold">{selected.label}</p>
|
||||||
|
{selected.is_default && (
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
|
||||||
|
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
|
||||||
|
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
|
||||||
|
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
|
||||||
|
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
|
||||||
|
<p className="text-sm text-gray-600">{selected.country}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowShippingForm(true)}
|
||||||
|
className="mt-3 text-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
Use a different address
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">No address selected</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shipping Address Modal */}
|
||||||
|
<AddressSelector
|
||||||
|
isOpen={showShippingModal}
|
||||||
|
onClose={() => setShowShippingModal(false)}
|
||||||
|
addresses={savedAddresses}
|
||||||
|
selectedAddressId={selectedShippingAddressId}
|
||||||
|
onSelectAddress={handleSelectShippingAddress}
|
||||||
|
type="shipping"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
|
||||||
|
{(!selectedShippingAddressId || showShippingForm) && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.firstName}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.lastName}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.address}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.city}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.state}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.postcode}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={shippingData.country}
|
||||||
|
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||||
|
className="w-full border rounded-lg px-4 py-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Order Notes */}
|
{/* Order Notes */}
|
||||||
@@ -722,30 +725,30 @@ export default function Checkout() {
|
|||||||
{/* Hide COD for virtual-only products */}
|
{/* Hide COD for virtual-only products */}
|
||||||
{!isVirtualOnly && (
|
{!isVirtualOnly && (
|
||||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="payment"
|
name="payment"
|
||||||
value="cod"
|
value="cod"
|
||||||
checked={paymentMethod === 'cod'}
|
checked={paymentMethod === 'cod'}
|
||||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>Cash on Delivery</span>
|
<span>Cash on Delivery</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="payment"
|
name="payment"
|
||||||
value="bacs"
|
value="bacs"
|
||||||
checked={paymentMethod === 'bacs'}
|
checked={paymentMethod === 'bacs'}
|
||||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>Bank Transfer</span>
|
<span>Bank Transfer</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Icons */}
|
{/* Payment Icons */}
|
||||||
{elements.payment_icons && (
|
{elements.payment_icons && (
|
||||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t">
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t">
|
||||||
|
|||||||
161
customer-spa/src/pages/ForgotPassword/index.tsx
Normal file
161
customer-spa/src/pages/ForgotPassword/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import Container from '@/components/Layout/Container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { KeyRound, ArrowLeft, Mail, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setIsSuccess(true);
|
||||||
|
toast.success('Password reset email sent!');
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to send reset email');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check Your Email</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
We've sent a password reset link to <strong>{email}</strong>.
|
||||||
|
Please check your inbox and click the link to reset your password.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link to="/login">
|
||||||
|
<Button className="w-full">
|
||||||
|
Return to Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsSuccess(false);
|
||||||
|
setEmail('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-gray-600 hover:text-primary"
|
||||||
|
>
|
||||||
|
Try a different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<KeyRound className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Forgot Password?</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Enter your email and we'll send you a link to reset your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
Remember your password?{' '}
|
||||||
|
<Link to="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
customer-spa/src/pages/Login/index.tsx
Normal file
202
customer-spa/src/pages/Login/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import Container from '@/components/Layout/Container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { LogIn, Eye, EyeOff, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirectTo = searchParams.get('redirect') || '/my-account';
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/auth/customer-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Update window config with new nonce and user data
|
||||||
|
if ((window as any).woonoowCustomer) {
|
||||||
|
(window as any).woonoowCustomer.nonce = data.nonce;
|
||||||
|
(window as any).woonoowCustomer.user = {
|
||||||
|
isLoggedIn: true,
|
||||||
|
id: data.user.id,
|
||||||
|
name: data.user.name,
|
||||||
|
email: data.user.email,
|
||||||
|
firstName: data.user.first_name,
|
||||||
|
lastName: data.user.last_name,
|
||||||
|
avatar: data.user.avatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge guest wishlist to account
|
||||||
|
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const guestProductIds = JSON.parse(stored) as number[];
|
||||||
|
if (guestProductIds.length > 0) {
|
||||||
|
// Merge each product to account wishlist
|
||||||
|
const newNonce = data.nonce;
|
||||||
|
for (const productId of guestProductIds) {
|
||||||
|
try {
|
||||||
|
await fetch(`${apiRoot}/account/wishlist`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': newNonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ product_id: productId }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Skip if product already in wishlist or other error
|
||||||
|
console.debug('Wishlist merge skipped for product:', productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear guest wishlist after merge
|
||||||
|
localStorage.removeItem(GUEST_WISHLIST_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to merge guest wishlist:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Login successful!');
|
||||||
|
|
||||||
|
// Set the target URL with hash route, then force reload
|
||||||
|
// The hash change alone doesn't reload the page, so cookies won't be refreshed
|
||||||
|
const targetUrl = window.location.origin + '/store/#' + redirectTo;
|
||||||
|
window.location.href = targetUrl;
|
||||||
|
// Force page reload to refresh cookies and server-side state
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
to="/shop"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Continue shopping
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<LogIn className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Welcome Back</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Email or Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your email or username"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer links */}
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="hover:text-primary"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
customer-spa/src/pages/ResetPassword/index.tsx
Normal file
300
customer-spa/src/pages/ResetPassword/index.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import Container from '@/components/Layout/Container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { KeyRound, ArrowLeft, Eye, EyeOff, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const key = searchParams.get('key') || '';
|
||||||
|
const login = searchParams.get('login') || '';
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isValidating, setIsValidating] = useState(true);
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Validate the reset key on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const validateKey = async () => {
|
||||||
|
if (!key || !login) {
|
||||||
|
setError('Invalid password reset link. Please request a new one.');
|
||||||
|
setIsValidating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/auth/validate-reset-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ key, login }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.valid) {
|
||||||
|
setIsValid(true);
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'This password reset link has expired or is invalid.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Unable to validate reset link. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validateKey();
|
||||||
|
}, [key, login]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters long');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
|
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ key, login, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setIsSuccess(true);
|
||||||
|
toast.success('Password reset successfully!');
|
||||||
|
} else {
|
||||||
|
setError(data.message || 'Failed to reset password. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password strength indicator
|
||||||
|
const getPasswordStrength = (pwd: string) => {
|
||||||
|
if (pwd.length === 0) return { label: '', color: '', width: '0%' };
|
||||||
|
if (pwd.length < 8) return { label: 'Too short', color: 'bg-red-500', width: '25%' };
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
if (pwd.length >= 8) strength++;
|
||||||
|
if (pwd.length >= 12) strength++;
|
||||||
|
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
|
||||||
|
if (/\d/.test(pwd)) strength++;
|
||||||
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
|
||||||
|
|
||||||
|
if (strength <= 2) return { label: 'Weak', color: 'bg-orange-500', width: '50%' };
|
||||||
|
if (strength <= 3) return { label: 'Medium', color: 'bg-yellow-500', width: '75%' };
|
||||||
|
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(password);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isValidating) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">Validating reset link...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Password Reset!</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Your password has been successfully updated. You can now log in with your new password.
|
||||||
|
</p>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button className="w-full">Sign In</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (invalid key)
|
||||||
|
if (!isValid && error) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Invalid Reset Link</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
|
<Link to="/forgot-password">
|
||||||
|
<Button className="w-full">Request New Link</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<KeyRound className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reset Your Password</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Enter your new password below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{password && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-1 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${passwordStrength.color}`}
|
||||||
|
style={{ width: passwordStrength.width }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Strength: <span className="font-medium">{passwordStrength.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{confirmPassword && password !== confirmPassword && (
|
||||||
|
<p className="text-xs text-red-500">Passwords do not match</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading || password !== confirmPassword}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
Remember your password?{' '}
|
||||||
|
<Link to="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { CheckCircle, ShoppingBag, Package, Truck } from 'lucide-react';
|
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
export default function ThankYou() {
|
export default function ThankYou() {
|
||||||
const { orderId } = useParams<{ orderId: string }>();
|
const { orderId } = useParams<{ orderId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const orderKey = searchParams.get('key');
|
||||||
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||||
const [order, setOrder] = useState<any>(null);
|
const [order, setOrder] = useState<any>(null);
|
||||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOrderData = async () => {
|
const fetchOrderData = async () => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const orderData = await apiClient.get(`/orders/${orderId}`) as any;
|
// Use public order endpoint with key validation
|
||||||
|
const keyParam = orderKey ? `?key=${orderKey}` : '';
|
||||||
|
const orderData = await apiClient.get(`/checkout/order/${orderId}${keyParam}`) as any;
|
||||||
setOrder(orderData);
|
setOrder(orderData);
|
||||||
|
|
||||||
// Fetch related products from first order item
|
// Fetch related products from first order item
|
||||||
@@ -30,15 +36,16 @@ export default function ThankYou() {
|
|||||||
setRelatedProducts(productData.related_products.slice(0, 4));
|
setRelatedProducts(productData.related_products.slice(0, 4));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch order data:', error);
|
console.error('Failed to fetch order data:', err);
|
||||||
|
setError(err.message || 'Failed to load order');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchOrderData();
|
fetchOrderData();
|
||||||
}, [orderId]);
|
}, [orderId, orderKey]);
|
||||||
|
|
||||||
if (loading || settingsLoading || !order) {
|
if (loading || settingsLoading || !order) {
|
||||||
return (
|
return (
|
||||||
@@ -68,55 +75,171 @@ export default function ThankYou() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor }}>
|
<div style={{ backgroundColor }}>
|
||||||
<Container>
|
<Container>
|
||||||
<div className="py-12 max-w-2xl mx-auto">
|
<div className="py-12 max-w-2xl mx-auto">
|
||||||
{/* Receipt Container */}
|
{/* Receipt Container */}
|
||||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||||
{/* Receipt Header */}
|
{/* Receipt Header */}
|
||||||
<div className="border-b-2 border-dashed border-gray-400 p-8 text-center">
|
<div className="border-b-2 border-dashed border-gray-400 p-8 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
||||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold mb-2">PAYMENT RECEIPT</h1>
|
|
||||||
<p className="text-gray-600">Order #{order.number}</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{new Date().toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Message */}
|
|
||||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
|
||||||
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Items */}
|
|
||||||
{elements.order_details && (
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="border-b-2 border-gray-900 pb-2 mb-4">
|
|
||||||
<div className="flex justify-between text-sm font-bold">
|
|
||||||
<span>ITEM</span>
|
|
||||||
<span>AMOUNT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">PAYMENT RECEIPT</h1>
|
||||||
|
<p className="text-gray-600">Order #{order.number}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{new Date().toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Custom Message */}
|
||||||
{order.items.map((item: any) => (
|
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
||||||
<div key={item.id}>
|
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
||||||
<div className="flex justify-between">
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{item.name}</div>
|
{/* Order Items */}
|
||||||
<div className="text-sm text-gray-600">Qty: {item.qty}</div>
|
{elements.order_details && (
|
||||||
</div>
|
<div className="p-8">
|
||||||
<div className="text-right font-mono">
|
<div className="border-b-2 border-gray-900 pb-2 mb-4">
|
||||||
{formatPrice(item.total)}
|
<div className="flex justify-between text-sm font-bold">
|
||||||
|
<span>ITEM</span>
|
||||||
|
<span>AMOUNT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{order.items.map((item: any) => (
|
||||||
|
<div key={item.id}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">Qty: {item.qty}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right font-mono">
|
||||||
|
{formatPrice(item.total)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>SUBTOTAL:</span>
|
||||||
|
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>SHIPPING:</span>
|
||||||
|
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parseFloat(order.tax_total || 0) > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>TAX:</span>
|
||||||
|
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||||
|
<span>TOTAL:</span>
|
||||||
|
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment & Status Info */}
|
||||||
|
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Payment Method:</span>
|
||||||
|
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Status:</span>
|
||||||
|
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
||||||
|
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{order.billing?.first_name} {order.billing?.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600">{order.billing?.email}</div>
|
||||||
|
{order.billing?.phone && (
|
||||||
|
<div className="text-gray-600">{order.billing.phone}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Receipt Footer */}
|
||||||
|
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{order.status === 'pending'
|
||||||
|
? 'Awaiting payment confirmation'
|
||||||
|
: 'Thank you for your business!'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
{elements.continue_shopping_button && (
|
||||||
|
<Link to="/shop">
|
||||||
|
<Button size="lg" className="gap-2">
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<Link to="/my-account">
|
||||||
|
<Button size="lg" variant="outline" className="gap-2">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Go to Account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link to="/login">
|
||||||
|
<Button size="lg" variant="outline" className="gap-2">
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
Login / Create Account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Related Products */}
|
||||||
|
{elements.related_products && relatedProducts.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{relatedProducts.map((product: any) => (
|
||||||
|
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||||
|
{product.image ? (
|
||||||
|
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Package className="w-12 h-12 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||||
|
{formatPrice(parseFloat(product.price || 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,11 +298,11 @@ export default function ThankYou() {
|
|||||||
{/* Receipt Footer */}
|
{/* Receipt Footer */}
|
||||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
{order.status === 'pending'
|
{order.status === 'pending'
|
||||||
? 'Awaiting payment confirmation'
|
? 'Awaiting payment confirmation'
|
||||||
: 'Thank you for your business!'}
|
: 'Thank you for your business!'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{elements.continue_shopping_button && (
|
{elements.continue_shopping_button && (
|
||||||
<Link to="/shop">
|
<Link to="/shop">
|
||||||
<Button size="lg" className="gap-2">
|
<Button size="lg" className="gap-2">
|
||||||
@@ -191,105 +314,6 @@ export default function ThankYou() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products */}
|
|
||||||
{elements.related_products && relatedProducts.length > 0 && (
|
|
||||||
<div className="mt-12">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{relatedProducts.map((product: any) => (
|
|
||||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
|
||||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
|
||||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
|
||||||
{product.image ? (
|
|
||||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<Package className="w-12 h-12 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-3">
|
|
||||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
|
||||||
{formatPrice(parseFloat(product.price || 0))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Totals */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>SUBTOTAL:</span>
|
|
||||||
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
|
||||||
</div>
|
|
||||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>SHIPPING:</span>
|
|
||||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parseFloat(order.tax_total || 0) > 0 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>TAX:</span>
|
|
||||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
|
||||||
<span>TOTAL:</span>
|
|
||||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment & Status Info */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Payment Method:</span>
|
|
||||||
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Status:</span>
|
|
||||||
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Info */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
|
||||||
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium">
|
|
||||||
{order.billing?.first_name} {order.billing?.last_name}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-600">{order.billing?.email}</div>
|
|
||||||
{order.billing?.phone && (
|
|
||||||
<div className="text-gray-600">{order.billing.phone}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Receipt Footer */}
|
|
||||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
{order.status === 'pending'
|
|
||||||
? 'Awaiting payment confirmation'
|
|
||||||
: 'Thank you for your business!'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{elements.continue_shopping_button && (
|
|
||||||
<Link to="/shop">
|
|
||||||
<Button size="lg" className="gap-2">
|
|
||||||
<ShoppingBag className="w-5 h-5" />
|
|
||||||
Continue Shopping
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Related Products */}
|
||||||
{elements.related_products && relatedProducts.length > 0 && (
|
{elements.related_products && relatedProducts.length > 0 && (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
@@ -328,144 +352,159 @@ export default function ThankYou() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor }}>
|
<div style={{ backgroundColor }}>
|
||||||
<Container>
|
<Container>
|
||||||
<div className="py-12 max-w-3xl mx-auto">
|
<div className="py-12 max-w-3xl mx-auto">
|
||||||
{/* Success Header */}
|
{/* Success Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Order Confirmed!</h1>
|
||||||
|
<p className="text-gray-600">Order #{order.number}</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Order Confirmed!</h1>
|
|
||||||
<p className="text-gray-600">Order #{order.number}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Message */}
|
{/* Custom Message */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||||
<p className="text-gray-800 text-center">{customMessage}</p>
|
<p className="text-gray-800 text-center">{customMessage}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Details */}
|
|
||||||
{elements.order_details && (
|
|
||||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
|
||||||
<h2 className="text-xl font-bold mb-4">Order Details</h2>
|
|
||||||
|
|
||||||
{/* Order Items */}
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
{order.items.map((item: any) => (
|
|
||||||
<div key={item.id} className="flex items-center gap-4 pb-4 border-b last:border-0">
|
|
||||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
||||||
{item.image && typeof item.image === 'string' ? (
|
|
||||||
<img src={item.image} alt={item.name} className="w-full !h-full object-cover rounded-lg" />
|
|
||||||
) : (
|
|
||||||
<Package className="w-8 h-8 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-medium text-gray-900">{item.name}</h3>
|
|
||||||
<p className="text-sm text-gray-500">Quantity: {item.qty}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-medium text-gray-900">{formatPrice(item.total)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Summary */}
|
|
||||||
<div className="border-t pt-4 space-y-2">
|
|
||||||
<div className="flex justify-between text-gray-600">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
|
||||||
</div>
|
|
||||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
|
||||||
<div className="flex justify-between text-gray-600">
|
|
||||||
<span>Shipping</span>
|
|
||||||
<span>{formatPrice(parseFloat(order.shipping_total))}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parseFloat(order.tax_total || 0) > 0 && (
|
|
||||||
<div className="flex justify-between text-gray-600">
|
|
||||||
<span>Tax</span>
|
|
||||||
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
|
||||||
<span>Total</span>
|
|
||||||
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer Info */}
|
|
||||||
<div className="mt-6 pt-6 border-t">
|
|
||||||
<h3 className="font-medium text-gray-900 mb-3">Customer Information</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 mb-1">Email</p>
|
|
||||||
<p className="text-gray-900">{order.billing?.email || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-500 mb-1">Phone</p>
|
|
||||||
<p className="text-gray-900">{order.billing?.phone || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Status */}
|
|
||||||
<div className="mt-6 pt-6 border-t">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Truck className="w-5 h-5 text-blue-600" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Order Status: {getStatusLabel(order.status)}</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{order.status === 'pending' ? 'Awaiting payment confirmation' : "We'll send you shipping updates via email"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Continue Shopping Button */}
|
{/* Order Details */}
|
||||||
{elements.continue_shopping_button && (
|
{elements.order_details && (
|
||||||
<div className="text-center">
|
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||||
<Link to="/shop">
|
<h2 className="text-xl font-bold mb-4">Order Details</h2>
|
||||||
<Button size="lg" className="gap-2">
|
|
||||||
<ShoppingBag className="w-5 h-5" />
|
|
||||||
Continue Shopping
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Order Items */}
|
||||||
{elements.related_products && relatedProducts.length > 0 && (
|
<div className="space-y-4 mb-6">
|
||||||
<div className="mt-12">
|
{order.items.map((item: any) => (
|
||||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
<div key={item.id} className="flex items-center gap-4 pb-4 border-b last:border-0">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
{relatedProducts.map((product: any) => (
|
{item.image && typeof item.image === 'string' ? (
|
||||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
<img src={item.image} alt={item.name} className="w-full !h-full object-cover rounded-lg" />
|
||||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
|
||||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
|
||||||
{product.image ? (
|
|
||||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
|
||||||
) : (
|
) : (
|
||||||
<Package className="w-12 h-12 text-gray-400" />
|
<Package className="w-8 h-8 text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
<h3 className="font-medium text-gray-900">{item.name}</h3>
|
||||||
{product.name}
|
<p className="text-sm text-gray-500">Quantity: {item.qty}</p>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
<div className="text-right">
|
||||||
{formatPrice(parseFloat(product.price || 0))}
|
<p className="font-medium text-gray-900">{formatPrice(item.total)}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{/* Order Summary */}
|
||||||
|
<div className="border-t pt-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||||
|
</div>
|
||||||
|
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Shipping</span>
|
||||||
|
<span>{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parseFloat(order.tax_total || 0) > 0 && (
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Tax</span>
|
||||||
|
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Info */}
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-3">Customer Information</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 mb-1">Email</p>
|
||||||
|
<p className="text-gray-900">{order.billing?.email || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 mb-1">Phone</p>
|
||||||
|
<p className="text-gray-900">{order.billing?.phone || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Status */}
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Truck className="w-5 h-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Order Status: {getStatusLabel(order.status)}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{order.status === 'pending' ? 'Awaiting payment confirmation' : "We'll send you shipping updates via email"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="text-center flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
{elements.continue_shopping_button && (
|
||||||
|
<Link to="/shop">
|
||||||
|
<Button size="lg" className="gap-2">
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<Link to="/my-account">
|
||||||
|
<Button size="lg" variant="outline" className="gap-2">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
Go to Account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link to="/login">
|
||||||
|
<Button size="lg" variant="outline" className="gap-2">
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
Login / Create Account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Related Products */}
|
||||||
|
{elements.related_products && relatedProducts.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{relatedProducts.map((product: any) => (
|
||||||
|
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||||
|
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
|
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||||
|
{product.image ? (
|
||||||
|
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Package className="w-12 h-12 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||||
|
{formatPrice(parseFloat(product.price || 0))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Trash2, ShoppingCart, Heart } from 'lucide-react';
|
import { Trash2, ShoppingCart, Heart } from 'lucide-react';
|
||||||
import { useWishlist } from '@/hooks/useWishlist';
|
import { useWishlist } from '@/hooks/useWishlist';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
@@ -88,7 +88,7 @@ export default function Wishlist() {
|
|||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">My Wishlist</h1>
|
<h1 className="text-3xl font-bold mb-6">My Wishlist</h1>
|
||||||
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||||
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
<h2 className="text-xl font-semibold mb-2">Your wishlist is empty</h2>
|
||||||
@@ -118,8 +118,8 @@ export default function Wishlist() {
|
|||||||
{!isLoggedIn && hasGuestItems && (
|
{!isLoggedIn && hasGuestItems && (
|
||||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
<strong>Guest Wishlist:</strong> You have {guestProducts.length} items saved locally.
|
<strong>Guest Wishlist:</strong> You have {guestProducts.length} items saved locally.
|
||||||
<a href="/wp-login.php" className="underline ml-1">Login</a> to sync your wishlist to your account.
|
<Link to="/login" className="underline ml-1">Login</Link> to sync your wishlist to your account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Get all WordPress pages for page selector
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/pages/list', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_pages_list'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function check_permission() {
|
public static function check_permission() {
|
||||||
@@ -82,6 +89,7 @@ class AppearanceController {
|
|||||||
|
|
||||||
$general_data = [
|
$general_data = [
|
||||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||||
|
'spa_page' => absint($request->get_param('spaPage') ?? 0),
|
||||||
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
|
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
||||||
@@ -371,6 +379,30 @@ class AppearanceController {
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of WordPress pages for page selector
|
||||||
|
*/
|
||||||
|
public static function get_pages_list(WP_REST_Request $request) {
|
||||||
|
$pages = get_pages([
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'sort_column' => 'post_title',
|
||||||
|
'sort_order' => 'ASC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pages_list = array_map(function($page) {
|
||||||
|
return [
|
||||||
|
'id' => $page->ID,
|
||||||
|
'title' => $page->post_title,
|
||||||
|
'slug' => $page->post_name,
|
||||||
|
];
|
||||||
|
}, $pages);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $pages_list,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default settings structure
|
* Get default settings structure
|
||||||
*/
|
*/
|
||||||
@@ -378,6 +410,7 @@ class AppearanceController {
|
|||||||
return [
|
return [
|
||||||
'general' => [
|
'general' => [
|
||||||
'spa_mode' => 'full',
|
'spa_mode' => 'full',
|
||||||
|
'spa_page' => 0,
|
||||||
'toast_position' => 'top-right',
|
'toast_position' => 'top-right',
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => 'predefined',
|
'mode' => 'predefined',
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ use WooNooW\Compat\AddonRegistry;
|
|||||||
use WooNooW\Compat\RouteRegistry;
|
use WooNooW\Compat\RouteRegistry;
|
||||||
use WooNooW\Compat\NavigationRegistry;
|
use WooNooW\Compat\NavigationRegistry;
|
||||||
|
|
||||||
class Assets {
|
class Assets
|
||||||
public static function init() {
|
{
|
||||||
|
public static function init()
|
||||||
|
{
|
||||||
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
|
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function enqueue($hook) {
|
public static function enqueue($hook)
|
||||||
|
{
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hook !== 'toplevel_page_woonoow') {
|
if ($hook !== 'toplevel_page_woonoow') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -26,12 +29,12 @@ class Assets {
|
|||||||
|
|
||||||
// Decide dev vs prod
|
// Decide dev vs prod
|
||||||
$is_dev = self::is_dev_mode();
|
$is_dev = self::is_dev_mode();
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($is_dev) {
|
if ($is_dev) {
|
||||||
self::enqueue_dev();
|
self::enqueue_dev();
|
||||||
} else {
|
} else {
|
||||||
@@ -42,49 +45,50 @@ class Assets {
|
|||||||
/** ----------------------------------------
|
/** ----------------------------------------
|
||||||
* DEV MODE (Vite dev server)
|
* DEV MODE (Vite dev server)
|
||||||
* -------------------------------------- */
|
* -------------------------------------- */
|
||||||
private static function enqueue_dev(): void {
|
private static function enqueue_dev(): void
|
||||||
|
{
|
||||||
$dev_url = self::dev_server_url(); // e.g. http://localhost:5173
|
$dev_url = self::dev_server_url(); // e.g. http://localhost:5173
|
||||||
|
|
||||||
// 1) Create a small handle to attach config (window.WNW_API)
|
// 1) Create a small handle to attach config (window.WNW_API)
|
||||||
$handle = 'wnw-admin-dev-config';
|
$handle = 'wnw-admin-dev-config';
|
||||||
wp_register_script($handle, '', [], null, true);
|
wp_register_script($handle, '', [], null, true);
|
||||||
wp_enqueue_script($handle);
|
wp_enqueue_script($handle);
|
||||||
|
|
||||||
// Attach runtime config (before module loader runs)
|
// Attach runtime config (before module loader runs)
|
||||||
// If you prefer, keep using self::localize_runtime($handle)
|
// If you prefer, keep using self::localize_runtime($handle)
|
||||||
wp_localize_script($handle, 'WNW_API', [
|
wp_localize_script($handle, 'WNW_API', [
|
||||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'isDev' => true,
|
'isDev' => true,
|
||||||
'devServer' => $dev_url,
|
'devServer' => $dev_url,
|
||||||
'adminScreen' => 'woonoow',
|
'adminScreen' => 'woonoow',
|
||||||
'adminUrl' => admin_url('admin.php'),
|
'adminUrl' => admin_url('admin.php'),
|
||||||
]);
|
]);
|
||||||
wp_add_inline_script($handle, 'window.WNW_API = window.WNW_API || WNW_API;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_API = window.WNW_API || WNW_API;', 'after');
|
||||||
|
|
||||||
// WNW_CONFIG for compatibility with standalone mode code
|
// WNW_CONFIG for compatibility with standalone mode code
|
||||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'standaloneMode' => false,
|
'standaloneMode' => false,
|
||||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||||
'isAuthenticated' => is_user_logged_in(),
|
'isAuthenticated' => is_user_logged_in(),
|
||||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||||
]);
|
]);
|
||||||
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
||||||
|
|
||||||
// WordPress REST API settings (for media upload compatibility)
|
// WordPress REST API settings (for media upload compatibility)
|
||||||
wp_localize_script($handle, 'wpApiSettings', [
|
wp_localize_script($handle, 'wpApiSettings', [
|
||||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
]);
|
]);
|
||||||
wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after');
|
wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after');
|
||||||
|
|
||||||
// Also expose compact global for convenience
|
// Also expose compact global for convenience
|
||||||
wp_localize_script($handle, 'wnw', [
|
wp_localize_script($handle, 'wnw', [
|
||||||
'isDev' => true,
|
'isDev' => true,
|
||||||
'devServer' => $dev_url,
|
'devServer' => $dev_url,
|
||||||
'adminUrl' => admin_url('admin.php'),
|
'adminUrl' => admin_url('admin.php'),
|
||||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||||
]);
|
]);
|
||||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||||
@@ -97,37 +101,37 @@ class Assets {
|
|||||||
$menus_snapshot = class_exists(MenuProvider::class) ? MenuProvider::get_snapshot() : [];
|
$menus_snapshot = class_exists(MenuProvider::class) ? MenuProvider::get_snapshot() : [];
|
||||||
wp_localize_script($handle, 'WNW_WC_MENUS', ['items' => $menus_snapshot]);
|
wp_localize_script($handle, 'WNW_WC_MENUS', ['items' => $menus_snapshot]);
|
||||||
wp_add_inline_script($handle, 'window.WNW_WC_MENUS = window.WNW_WC_MENUS || WNW_WC_MENUS;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_WC_MENUS = window.WNW_WC_MENUS || WNW_WC_MENUS;', 'after');
|
||||||
|
|
||||||
// Addon system data
|
// Addon system data
|
||||||
wp_localize_script($handle, 'WNW_ADDONS', AddonRegistry::get_frontend_registry());
|
wp_localize_script($handle, 'WNW_ADDONS', AddonRegistry::get_frontend_registry());
|
||||||
wp_add_inline_script($handle, 'window.WNW_ADDONS = window.WNW_ADDONS || WNW_ADDONS;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_ADDONS = window.WNW_ADDONS || WNW_ADDONS;', 'after');
|
||||||
|
|
||||||
wp_localize_script($handle, 'WNW_ADDON_ROUTES', RouteRegistry::get_frontend_routes());
|
wp_localize_script($handle, 'WNW_ADDON_ROUTES', RouteRegistry::get_frontend_routes());
|
||||||
wp_add_inline_script($handle, 'window.WNW_ADDON_ROUTES = window.WNW_ADDON_ROUTES || WNW_ADDON_ROUTES;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_ADDON_ROUTES = window.WNW_ADDON_ROUTES || WNW_ADDON_ROUTES;', 'after');
|
||||||
|
|
||||||
wp_localize_script($handle, 'WNW_NAV_TREE', NavigationRegistry::get_frontend_nav_tree());
|
wp_localize_script($handle, 'WNW_NAV_TREE', NavigationRegistry::get_frontend_nav_tree());
|
||||||
wp_add_inline_script($handle, 'window.WNW_NAV_TREE = window.WNW_NAV_TREE || WNW_NAV_TREE;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_NAV_TREE = window.WNW_NAV_TREE || WNW_NAV_TREE;', 'after');
|
||||||
|
|
||||||
// Temporary compat aliases for old WNM_*
|
// Temporary compat aliases for old WNM_*
|
||||||
wp_add_inline_script($handle, 'window.WNM_API = window.WNM_API || window.WNW_API;', 'after');
|
wp_add_inline_script($handle, 'window.WNM_API = window.WNM_API || window.WNW_API;', 'after');
|
||||||
wp_add_inline_script($handle, 'window.WNM_WC_MENUS = window.WNM_WC_MENUS || window.WNW_WC_MENUS;', 'after');
|
wp_add_inline_script($handle, 'window.WNM_WC_MENUS = window.WNM_WC_MENUS || window.WNW_WC_MENUS;', 'after');
|
||||||
|
|
||||||
// 2) Print a real module tag in the footer to load Vite client + app
|
// 2) Print a real module tag in the footer to load Vite client + app
|
||||||
add_action('admin_print_footer_scripts', function () use ($dev_url) {
|
add_action('admin_print_footer_scripts', function () use ($dev_url) {
|
||||||
// 1) React Refresh preamble (required by @vitejs/plugin-react)
|
// 1) React Refresh preamble (required by @vitejs/plugin-react)
|
||||||
?>
|
?>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import RefreshRuntime from "<?php echo esc_url( $dev_url ); ?>/@react-refresh";
|
import RefreshRuntime from "<?php echo esc_url($dev_url); ?>/@react-refresh";
|
||||||
RefreshRuntime.injectIntoGlobalHook(window);
|
RefreshRuntime.injectIntoGlobalHook(window);
|
||||||
window.$RefreshReg$ = () => {};
|
window.$RefreshReg$ = () => { };
|
||||||
window.$RefreshSig$ = () => (type) => type;
|
window.$RefreshSig$ = () => (type) => type;
|
||||||
window.__vite_plugin_react_preamble_installed__ = true;
|
window.__vite_plugin_react_preamble_installed__ = true;
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
// 2) Vite client (HMR)
|
// 2) Vite client (HMR)
|
||||||
printf('<script type="module" src="%s/@vite/client"></script>' . "\n", esc_url($dev_url));
|
printf('<script type="module" src="%s/@vite/client"></script>' . "\n", esc_url($dev_url));
|
||||||
|
|
||||||
// 3) Your app entry
|
// 3) Your app entry
|
||||||
printf('<script type="module">import "%s/src/main.tsx";</script>' . "\n", esc_url($dev_url));
|
printf('<script type="module">import "%s/src/main.tsx";</script>' . "\n", esc_url($dev_url));
|
||||||
}, 1);
|
}, 1);
|
||||||
@@ -136,17 +140,18 @@ class Assets {
|
|||||||
/** ----------------------------------------
|
/** ----------------------------------------
|
||||||
* PROD MODE (built assets in admin-spa/dist)
|
* PROD MODE (built assets in admin-spa/dist)
|
||||||
* -------------------------------------- */
|
* -------------------------------------- */
|
||||||
private static function enqueue_prod(): void {
|
private static function enqueue_prod(): void
|
||||||
|
{
|
||||||
// Get plugin root directory (2 levels up from includes/Admin/)
|
// Get plugin root directory (2 levels up from includes/Admin/)
|
||||||
$plugin_dir = dirname(dirname(__DIR__));
|
$plugin_dir = dirname(dirname(__DIR__));
|
||||||
$dist_dir = $plugin_dir . '/admin-spa/dist/';
|
$dist_dir = $plugin_dir . '/admin-spa/dist/';
|
||||||
$base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php');
|
$base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php');
|
||||||
|
|
||||||
$css = 'app.css';
|
$css = 'app.css';
|
||||||
$js = 'app.js';
|
$js = 'app.js';
|
||||||
|
|
||||||
$ver_css = file_exists($dist_dir . $css) ? (string) filemtime($dist_dir . $css) : self::asset_version();
|
$ver_css = file_exists($dist_dir . $css) ? (string) filemtime($dist_dir . $css) : self::asset_version();
|
||||||
$ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version();
|
$ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version();
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
@@ -159,71 +164,60 @@ class Assets {
|
|||||||
|
|
||||||
if (file_exists($dist_dir . $css)) {
|
if (file_exists($dist_dir . $css)) {
|
||||||
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
|
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
|
||||||
|
// Note: Icon fixes are now in index.css with proper specificity
|
||||||
// Fix icon rendering in WP-Admin (prevent WordPress admin styles from overriding)
|
|
||||||
$icon_fix_css = '
|
|
||||||
/* Fix Lucide icons in WP-Admin - force outlined style */
|
|
||||||
#woonoow-admin-app svg {
|
|
||||||
fill: none !important;
|
|
||||||
stroke: currentColor !important;
|
|
||||||
stroke-width: 2 !important;
|
|
||||||
stroke-linecap: round !important;
|
|
||||||
stroke-linejoin: round !important;
|
|
||||||
}
|
|
||||||
';
|
|
||||||
wp_add_inline_style('wnw-admin', $icon_fix_css);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_exists($dist_dir . $js)) {
|
if (file_exists($dist_dir . $js)) {
|
||||||
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
|
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
|
||||||
|
|
||||||
// Add type="module" attribute for Vite build
|
// Add type="module" attribute for Vite build
|
||||||
add_filter('script_loader_tag', function($tag, $handle, $src) {
|
add_filter('script_loader_tag', function ($tag, $handle, $src) {
|
||||||
if ($handle === 'wnw-admin') {
|
if ($handle === 'wnw-admin') {
|
||||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||||
}
|
}
|
||||||
return $tag;
|
return $tag;
|
||||||
}, 10, 3);
|
}, 10, 3);
|
||||||
|
|
||||||
self::localize_runtime('wnw-admin');
|
self::localize_runtime('wnw-admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attach runtime config to a handle */
|
/** Attach runtime config to a handle */
|
||||||
private static function localize_runtime(string $handle): void {
|
private static function localize_runtime(string $handle): void
|
||||||
|
{
|
||||||
wp_localize_script($handle, 'WNW_API', [
|
wp_localize_script($handle, 'WNW_API', [
|
||||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'isDev' => self::is_dev_mode(),
|
'isDev' => self::is_dev_mode(),
|
||||||
'devServer' => self::dev_server_url(),
|
'devServer' => self::dev_server_url(),
|
||||||
'adminScreen' => 'woonoow',
|
'adminScreen' => 'woonoow',
|
||||||
'adminUrl' => admin_url('admin.php'),
|
'adminUrl' => admin_url('admin.php'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// WNW_CONFIG for compatibility with standalone mode code
|
// WNW_CONFIG for compatibility with standalone mode code
|
||||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'standaloneMode' => false,
|
'standaloneMode' => false,
|
||||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||||
'isAuthenticated' => is_user_logged_in(),
|
'isAuthenticated' => is_user_logged_in(),
|
||||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// WordPress REST API settings (for media upload compatibility)
|
// WordPress REST API settings (for media upload compatibility)
|
||||||
wp_localize_script($handle, 'wpApiSettings', [
|
wp_localize_script($handle, 'wpApiSettings', [
|
||||||
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
'root' => untrailingslashit(esc_url_raw(rest_url())),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
wp_localize_script($handle, 'WNW_STORE', self::store_runtime());
|
wp_localize_script($handle, 'WNW_STORE', self::store_runtime());
|
||||||
wp_add_inline_script($handle, 'window.WNW_STORE = window.WNW_STORE || WNW_STORE;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_STORE = window.WNW_STORE || WNW_STORE;', 'after');
|
||||||
|
|
||||||
// Compact global (prod)
|
// Compact global (prod)
|
||||||
wp_localize_script($handle, 'wnw', [
|
wp_localize_script($handle, 'wnw', [
|
||||||
'isDev' => (bool) self::is_dev_mode(),
|
'isDev' => (bool) self::is_dev_mode(),
|
||||||
'devServer' => (string) self::dev_server_url(),
|
'devServer' => (string) self::dev_server_url(),
|
||||||
'adminUrl' => admin_url('admin.php'),
|
'adminUrl' => admin_url('admin.php'),
|
||||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||||
]);
|
]);
|
||||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||||
@@ -232,39 +226,40 @@ class Assets {
|
|||||||
$menus_snapshot = class_exists(MenuProvider::class) ? MenuProvider::get_snapshot() : [];
|
$menus_snapshot = class_exists(MenuProvider::class) ? MenuProvider::get_snapshot() : [];
|
||||||
wp_localize_script($handle, 'WNW_WC_MENUS', ['items' => $menus_snapshot]);
|
wp_localize_script($handle, 'WNW_WC_MENUS', ['items' => $menus_snapshot]);
|
||||||
wp_add_inline_script($handle, 'window.WNW_WC_MENUS = window.WNW_WC_MENUS || WNW_WC_MENUS;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_WC_MENUS = window.WNW_WC_MENUS || WNW_WC_MENUS;', 'after');
|
||||||
|
|
||||||
// Addon system data (prod)
|
// Addon system data (prod)
|
||||||
wp_localize_script($handle, 'WNW_ADDONS', AddonRegistry::get_frontend_registry());
|
wp_localize_script($handle, 'WNW_ADDONS', AddonRegistry::get_frontend_registry());
|
||||||
wp_add_inline_script($handle, 'window.WNW_ADDONS = window.WNW_ADDONS || WNW_ADDONS;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_ADDONS = window.WNW_ADDONS || WNW_ADDONS;', 'after');
|
||||||
|
|
||||||
wp_localize_script($handle, 'WNW_ADDON_ROUTES', RouteRegistry::get_frontend_routes());
|
wp_localize_script($handle, 'WNW_ADDON_ROUTES', RouteRegistry::get_frontend_routes());
|
||||||
wp_add_inline_script($handle, 'window.WNW_ADDON_ROUTES = window.WNW_ADDON_ROUTES || WNW_ADDON_ROUTES;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_ADDON_ROUTES = window.WNW_ADDON_ROUTES || WNW_ADDON_ROUTES;', 'after');
|
||||||
|
|
||||||
wp_localize_script($handle, 'WNW_NAV_TREE', NavigationRegistry::get_frontend_nav_tree());
|
wp_localize_script($handle, 'WNW_NAV_TREE', NavigationRegistry::get_frontend_nav_tree());
|
||||||
wp_add_inline_script($handle, 'window.WNW_NAV_TREE = window.WNW_NAV_TREE || WNW_NAV_TREE;', 'after');
|
wp_add_inline_script($handle, 'window.WNW_NAV_TREE = window.WNW_NAV_TREE || WNW_NAV_TREE;', 'after');
|
||||||
|
|
||||||
// Temporary compat aliases for old WNM_*
|
// Temporary compat aliases for old WNM_*
|
||||||
wp_add_inline_script($handle, 'window.WNM_API = window.WNM_API || window.WNW_API;', 'after');
|
wp_add_inline_script($handle, 'window.WNM_API = window.WNM_API || window.WNW_API;', 'after');
|
||||||
wp_add_inline_script($handle, 'window.WNM_WC_MENUS = window.WNM_WC_MENUS || window.WNW_WC_MENUS;', 'after');
|
wp_add_inline_script($handle, 'window.WNM_WC_MENUS = window.WNM_WC_MENUS || window.WNW_WC_MENUS;', 'after');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runtime store meta for frontend (currency, decimals, separators, position). */
|
/** Runtime store meta for frontend (currency, decimals, separators, position). */
|
||||||
private static function store_runtime(): array {
|
private static function store_runtime(): array
|
||||||
|
{
|
||||||
// WooCommerce helpers may not exist in some contexts; guard with defaults
|
// WooCommerce helpers may not exist in some contexts; guard with defaults
|
||||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||||
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
||||||
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
|
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
|
||||||
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
|
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
|
||||||
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
|
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
|
||||||
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'currency' => $currency,
|
'currency' => $currency,
|
||||||
'currency_symbol' => $currency_sym,
|
'currency_symbol' => $currency_sym,
|
||||||
'decimals' => (int) $decimals,
|
'decimals' => (int) $decimals,
|
||||||
'thousand_sep' => (string) $thousand_sep,
|
'thousand_sep' => (string) $thousand_sep,
|
||||||
'decimal_sep' => (string) $decimal_sep,
|
'decimal_sep' => (string) $decimal_sep,
|
||||||
'currency_pos' => (string) $currency_pos,
|
'currency_pos' => (string) $currency_pos,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +270,10 @@ class Assets {
|
|||||||
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
|
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
|
||||||
* in Local by Flywheel or other local dev environments.
|
* in Local by Flywheel or other local dev environments.
|
||||||
*/
|
*/
|
||||||
private static function is_dev_mode(): bool {
|
private static function is_dev_mode(): bool
|
||||||
|
{
|
||||||
// Only enable dev mode if explicitly set via constant
|
// Only enable dev mode if explicitly set via constant
|
||||||
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
|
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: force dev/prod mode for WooNooW admin assets.
|
* Filter: force dev/prod mode for WooNooW admin assets.
|
||||||
@@ -287,34 +283,36 @@ class Assets {
|
|||||||
* Only use it during development.
|
* Only use it during development.
|
||||||
*/
|
*/
|
||||||
$filtered = apply_filters('woonoow/admin_is_dev', $const_dev);
|
$filtered = apply_filters('woonoow/admin_is_dev', $const_dev);
|
||||||
|
|
||||||
// Debug logging (only if WP_DEBUG is enabled)
|
// Debug logging (only if WP_DEBUG is enabled)
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
||||||
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
|
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bool) $filtered;
|
return (bool) $filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dev server URL (filterable) */
|
/** Dev server URL (filterable) */
|
||||||
private static function dev_server_url(): string {
|
private static function dev_server_url(): string
|
||||||
|
{
|
||||||
// Auto-detect based on current host (for Local by Flywheel compatibility)
|
// Auto-detect based on current host (for Local by Flywheel compatibility)
|
||||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
$protocol = is_ssl() ? 'https' : 'http';
|
$protocol = is_ssl() ? 'https' : 'http';
|
||||||
|
|
||||||
// If using *.local domain (Local by Flywheel), use HTTPS
|
// If using *.local domain (Local by Flywheel), use HTTPS
|
||||||
if (strpos($host, '.local') !== false) {
|
if (strpos($host, '.local') !== false) {
|
||||||
$protocol = 'https';
|
$protocol = 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
$default = $protocol . '://' . $host . ':5173';
|
$default = $protocol . '://' . $host . ':5173';
|
||||||
|
|
||||||
/** Filter: change dev server URL if needed */
|
/** Filter: change dev server URL if needed */
|
||||||
return (string) apply_filters('woonoow/admin_dev_server', $default);
|
return (string) apply_filters('woonoow/admin_dev_server', $default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Basic asset versioning */
|
/** Basic asset versioning */
|
||||||
private static function asset_version(): string {
|
private static function asset_version(): string
|
||||||
|
{
|
||||||
// Bump when releasing; in dev we don't cache-bust
|
// Bump when releasing; in dev we don't cache-bust
|
||||||
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,58 @@ class AuthController {
|
|||||||
], 200 );
|
], 200 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer login endpoint (no admin permission required)
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request Request object
|
||||||
|
* @return WP_REST_Response Response object
|
||||||
|
*/
|
||||||
|
public static function customer_login( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$username = sanitize_text_field( $request->get_param( 'username' ) );
|
||||||
|
$password = $request->get_param( 'password' );
|
||||||
|
|
||||||
|
if ( empty( $username ) || empty( $password ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Username and password are required', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
$user = wp_authenticate( $username, $password );
|
||||||
|
|
||||||
|
if ( is_wp_error( $user ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Invalid username or password', 'woonoow' ),
|
||||||
|
], 401 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old cookies and set new ones
|
||||||
|
wp_clear_auth_cookie();
|
||||||
|
wp_set_current_user( $user->ID );
|
||||||
|
wp_set_auth_cookie( $user->ID, true );
|
||||||
|
|
||||||
|
// Trigger login action
|
||||||
|
do_action( 'wp_login', $user->user_login, $user );
|
||||||
|
|
||||||
|
// Get customer data
|
||||||
|
$customer_data = [
|
||||||
|
'id' => $user->ID,
|
||||||
|
'name' => $user->display_name,
|
||||||
|
'email' => $user->user_email,
|
||||||
|
'first_name' => get_user_meta( $user->ID, 'first_name', true ),
|
||||||
|
'last_name' => get_user_meta( $user->ID, 'last_name', true ),
|
||||||
|
'avatar' => get_avatar_url( $user->ID ),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => true,
|
||||||
|
'user' => $customer_data,
|
||||||
|
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
|
], 200 );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout endpoint
|
* Logout endpoint
|
||||||
*
|
*
|
||||||
@@ -134,4 +186,144 @@ class AuthController {
|
|||||||
],
|
],
|
||||||
], 200 );
|
], 200 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgot password endpoint - sends password reset email
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request Request object
|
||||||
|
* @return WP_REST_Response Response object
|
||||||
|
*/
|
||||||
|
public static function forgot_password( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$email = sanitize_email( $request->get_param( 'email' ) );
|
||||||
|
|
||||||
|
if ( empty( $email ) || ! is_email( $email ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Please enter a valid email address', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
$user = get_user_by( 'email', $email );
|
||||||
|
|
||||||
|
if ( ! $user ) {
|
||||||
|
// For security, don't reveal if email exists or not
|
||||||
|
// But still return success to prevent email enumeration attacks
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'If an account exists with this email, you will receive a password reset link.', 'woonoow' ),
|
||||||
|
], 200 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use WordPress's built-in password reset functionality
|
||||||
|
$result = retrieve_password( $user->user_login );
|
||||||
|
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Failed to send password reset email. Please try again.', 'woonoow' ),
|
||||||
|
], 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Password reset email sent! Please check your inbox.', 'woonoow' ),
|
||||||
|
], 200 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate password reset key
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request Request object
|
||||||
|
* @return WP_REST_Response Response object
|
||||||
|
*/
|
||||||
|
public static function validate_reset_key( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$key = sanitize_text_field( $request->get_param( 'key' ) );
|
||||||
|
$login = sanitize_text_field( $request->get_param( 'login' ) );
|
||||||
|
|
||||||
|
if ( empty( $key ) || empty( $login ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'valid' => false,
|
||||||
|
'message' => __( 'Invalid password reset link', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the reset key
|
||||||
|
$user = check_password_reset_key( $key, $login );
|
||||||
|
|
||||||
|
if ( is_wp_error( $user ) ) {
|
||||||
|
$error_code = $user->get_error_code();
|
||||||
|
$message = __( 'This password reset link has expired or is invalid.', 'woonoow' );
|
||||||
|
|
||||||
|
if ( $error_code === 'invalid_key' ) {
|
||||||
|
$message = __( 'This password reset link is invalid.', 'woonoow' );
|
||||||
|
} elseif ( $error_code === 'expired_key' ) {
|
||||||
|
$message = __( 'This password reset link has expired. Please request a new one.', 'woonoow' );
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'valid' => false,
|
||||||
|
'message' => $message,
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'valid' => true,
|
||||||
|
'user' => [
|
||||||
|
'login' => $user->user_login,
|
||||||
|
'email' => $user->user_email,
|
||||||
|
],
|
||||||
|
], 200 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password with key
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request Request object
|
||||||
|
* @return WP_REST_Response Response object
|
||||||
|
*/
|
||||||
|
public static function reset_password( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$key = sanitize_text_field( $request->get_param( 'key' ) );
|
||||||
|
$login = sanitize_text_field( $request->get_param( 'login' ) );
|
||||||
|
$password = $request->get_param( 'password' );
|
||||||
|
|
||||||
|
if ( empty( $key ) || empty( $login ) || empty( $password ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Missing required fields', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if ( strlen( $password ) < 8 ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'Password must be at least 8 characters long', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the reset key
|
||||||
|
$user = check_password_reset_key( $key, $login );
|
||||||
|
|
||||||
|
if ( is_wp_error( $user ) ) {
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => false,
|
||||||
|
'message' => __( 'This password reset link has expired or is invalid. Please request a new one.', 'woonoow' ),
|
||||||
|
], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the password
|
||||||
|
reset_password( $user, $password );
|
||||||
|
|
||||||
|
// Delete the password reset key so it can't be reused
|
||||||
|
delete_user_meta( $user->ID, 'default_password_nag' );
|
||||||
|
|
||||||
|
// Trigger password changed action
|
||||||
|
do_action( 'password_reset', $user, $password );
|
||||||
|
|
||||||
|
return new WP_REST_Response( [
|
||||||
|
'success' => true,
|
||||||
|
'message' => __( 'Password reset successfully. You can now log in with your new password.', 'woonoow' ),
|
||||||
|
], 200 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
320
includes/Api/CampaignsController.php
Normal file
320
includes/Api/CampaignsController.php
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Campaigns REST Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for newsletter campaigns
|
||||||
|
*
|
||||||
|
* @package WooNooW\API
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\API;
|
||||||
|
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_Error;
|
||||||
|
use WooNooW\Core\Campaigns\CampaignManager;
|
||||||
|
|
||||||
|
class CampaignsController {
|
||||||
|
|
||||||
|
const API_NAMESPACE = 'woonoow/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register REST routes
|
||||||
|
*/
|
||||||
|
public static function register_routes() {
|
||||||
|
// List campaigns
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_campaigns'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'create_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get single campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
|
'methods' => 'PUT',
|
||||||
|
'callback' => [__CLASS__, 'update_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
|
'methods' => 'DELETE',
|
||||||
|
'callback' => [__CLASS__, 'delete_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'send_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'send_test_email'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Preview campaign
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'preview_campaign'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check admin permission
|
||||||
|
*/
|
||||||
|
public static function check_admin_permission() {
|
||||||
|
return current_user_can('manage_options');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all campaigns
|
||||||
|
*/
|
||||||
|
public static function get_campaigns(WP_REST_Request $request) {
|
||||||
|
$campaigns = CampaignManager::get_all();
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $campaigns,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create campaign
|
||||||
|
*/
|
||||||
|
public static function create_campaign(WP_REST_Request $request) {
|
||||||
|
$data = [
|
||||||
|
'title' => $request->get_param('title'),
|
||||||
|
'subject' => $request->get_param('subject'),
|
||||||
|
'content' => $request->get_param('content'),
|
||||||
|
'status' => $request->get_param('status') ?: 'draft',
|
||||||
|
'scheduled_at' => $request->get_param('scheduled_at'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$campaign_id = CampaignManager::create($data);
|
||||||
|
|
||||||
|
if (is_wp_error($campaign_id)) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $campaign_id->get_error_message(),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $campaign,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single campaign
|
||||||
|
*/
|
||||||
|
public static function get_campaign(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
|
if (!$campaign) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => __('Campaign not found', 'woonoow'),
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $campaign,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update campaign
|
||||||
|
*/
|
||||||
|
public static function update_campaign(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
if ($request->has_param('title')) {
|
||||||
|
$data['title'] = $request->get_param('title');
|
||||||
|
}
|
||||||
|
if ($request->has_param('subject')) {
|
||||||
|
$data['subject'] = $request->get_param('subject');
|
||||||
|
}
|
||||||
|
if ($request->has_param('content')) {
|
||||||
|
$data['content'] = $request->get_param('content');
|
||||||
|
}
|
||||||
|
if ($request->has_param('status')) {
|
||||||
|
$data['status'] = $request->get_param('status');
|
||||||
|
}
|
||||||
|
if ($request->has_param('scheduled_at')) {
|
||||||
|
$data['scheduled_at'] = $request->get_param('scheduled_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = CampaignManager::update($campaign_id, $data);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result->get_error_message(),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $campaign,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete campaign
|
||||||
|
*/
|
||||||
|
public static function delete_campaign(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
|
$result = CampaignManager::delete($campaign_id);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => __('Failed to delete campaign', 'woonoow'),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => __('Campaign deleted', 'woonoow'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send campaign
|
||||||
|
*/
|
||||||
|
public static function send_campaign(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
|
$result = CampaignManager::send($campaign_id);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error'],
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf(
|
||||||
|
__('Campaign sent to %d recipients (%d failed)', 'woonoow'),
|
||||||
|
$result['sent'],
|
||||||
|
$result['failed']
|
||||||
|
),
|
||||||
|
'sent' => $result['sent'],
|
||||||
|
'failed' => $result['failed'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email
|
||||||
|
*/
|
||||||
|
public static function send_test_email(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
$email = sanitize_email($request->get_param('email'));
|
||||||
|
|
||||||
|
if (!is_email($email)) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => __('Invalid email address', 'woonoow'),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = CampaignManager::send_test($campaign_id, $email);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => __('Failed to send test email', 'woonoow'),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview campaign
|
||||||
|
*/
|
||||||
|
public static function preview_campaign(WP_REST_Request $request) {
|
||||||
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
|
if (!$campaign) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'error' => __('Campaign not found', 'woonoow'),
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use reflection to call private render method or make it public
|
||||||
|
// For now, return a simple preview
|
||||||
|
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||||
|
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||||
|
|
||||||
|
$content = $campaign['content'];
|
||||||
|
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||||
|
|
||||||
|
if ($template) {
|
||||||
|
$content = str_replace('{content}', $campaign['content'], $template['body']);
|
||||||
|
$content = str_replace('{campaign_title}', $campaign['title'], $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
$site_name = get_bloginfo('name');
|
||||||
|
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
|
||||||
|
$content = str_replace('{site_url}', home_url(), $content);
|
||||||
|
$content = str_replace('{subscriber_email}', 'subscriber@example.com', $content);
|
||||||
|
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
|
||||||
|
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
|
||||||
|
$content = str_replace('{current_year}', date('Y'), $content);
|
||||||
|
|
||||||
|
// Render with design template
|
||||||
|
$design_path = $renderer->get_design_template();
|
||||||
|
if (file_exists($design_path)) {
|
||||||
|
$content = $renderer->render_html($design_path, $content, $subject, [
|
||||||
|
'site_name' => $site_name,
|
||||||
|
'site_url' => home_url(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,18 @@ class CheckoutController {
|
|||||||
'callback' => [ new self(), 'get_fields' ],
|
'callback' => [ new self(), 'get_fields' ],
|
||||||
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
|
||||||
]);
|
]);
|
||||||
|
// Public order view endpoint for thank you page
|
||||||
|
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [ new self(), 'get_order' ],
|
||||||
|
'permission_callback' => '__return_true', // Public, validated via order_key
|
||||||
|
'args' => [
|
||||||
|
'key' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +145,69 @@ class CheckoutController {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public order view endpoint for thank you page
|
||||||
|
* Validates access via order_key (for guests) or logged-in customer ID
|
||||||
|
* GET /checkout/order/{id}?key=wc_order_xxx
|
||||||
|
*/
|
||||||
|
public function get_order(WP_REST_Request $r): array {
|
||||||
|
$order_id = absint($r['id']);
|
||||||
|
$order_key = sanitize_text_field($r->get_param('key') ?? '');
|
||||||
|
|
||||||
|
if (!$order_id) {
|
||||||
|
return ['error' => __('Invalid order ID', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = wc_get_order($order_id);
|
||||||
|
if (!$order) {
|
||||||
|
return ['error' => __('Order not found', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate access: order_key must match OR user must be logged in and own the order
|
||||||
|
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
|
||||||
|
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
|
||||||
|
|
||||||
|
if (!$valid_key && !$valid_owner) {
|
||||||
|
return ['error' => __('Unauthorized access to order', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build order items
|
||||||
|
$items = [];
|
||||||
|
foreach ($order->get_items() as $item) {
|
||||||
|
$product = $item->get_product();
|
||||||
|
$items[] = [
|
||||||
|
'id' => $item->get_id(),
|
||||||
|
'product_id' => $product ? $product->get_id() : 0,
|
||||||
|
'name' => $item->get_name(),
|
||||||
|
'qty' => (int) $item->get_quantity(),
|
||||||
|
'price' => (float) $item->get_total() / max(1, $item->get_quantity()),
|
||||||
|
'total' => (float) $item->get_total(),
|
||||||
|
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'id' => $order->get_id(),
|
||||||
|
'number' => $order->get_order_number(),
|
||||||
|
'status' => $order->get_status(),
|
||||||
|
'subtotal' => (float) $order->get_subtotal(),
|
||||||
|
'shipping_total' => (float) $order->get_shipping_total(),
|
||||||
|
'tax_total' => (float) $order->get_total_tax(),
|
||||||
|
'total' => (float) $order->get_total(),
|
||||||
|
'currency' => $order->get_currency(),
|
||||||
|
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
|
||||||
|
'payment_method' => $order->get_payment_method_title(),
|
||||||
|
'billing' => [
|
||||||
|
'first_name' => $order->get_billing_first_name(),
|
||||||
|
'last_name' => $order->get_billing_last_name(),
|
||||||
|
'email' => $order->get_billing_email(),
|
||||||
|
'phone' => $order->get_billing_phone(),
|
||||||
|
],
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an order:
|
* Submit an order:
|
||||||
* {
|
* {
|
||||||
@@ -187,6 +262,68 @@ class CheckoutController {
|
|||||||
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
|
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Guest checkout - check if auto-register is enabled
|
||||||
|
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
|
||||||
|
$auto_register = $customer_settings['auto_register_members'] ?? false;
|
||||||
|
|
||||||
|
if ($auto_register && !empty($payload['billing']['email'])) {
|
||||||
|
$email = sanitize_email($payload['billing']['email']);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
$existing_user = get_user_by('email', $email);
|
||||||
|
|
||||||
|
if ($existing_user) {
|
||||||
|
// User exists - link order to them
|
||||||
|
$order->set_customer_id($existing_user->ID);
|
||||||
|
} else {
|
||||||
|
// Create new user account
|
||||||
|
$password = wp_generate_password(12, true, true);
|
||||||
|
|
||||||
|
$userdata = [
|
||||||
|
'user_login' => $email,
|
||||||
|
'user_email' => $email,
|
||||||
|
'user_pass' => $password,
|
||||||
|
'first_name' => sanitize_text_field($payload['billing']['first_name'] ?? ''),
|
||||||
|
'last_name' => sanitize_text_field($payload['billing']['last_name'] ?? ''),
|
||||||
|
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
|
||||||
|
'role' => 'customer', // WooCommerce customer role
|
||||||
|
];
|
||||||
|
|
||||||
|
$new_user_id = wp_insert_user($userdata);
|
||||||
|
|
||||||
|
if (!is_wp_error($new_user_id)) {
|
||||||
|
// Link order to new user
|
||||||
|
$order->set_customer_id($new_user_id);
|
||||||
|
|
||||||
|
// Store temp password in user meta for email template
|
||||||
|
// The real password is already set via wp_insert_user
|
||||||
|
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
|
||||||
|
|
||||||
|
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
|
||||||
|
wp_set_auth_cookie($new_user_id, true);
|
||||||
|
wp_set_current_user($new_user_id);
|
||||||
|
|
||||||
|
// Set WooCommerce customer billing data
|
||||||
|
$customer = new \WC_Customer($new_user_id);
|
||||||
|
|
||||||
|
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
|
||||||
|
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
|
||||||
|
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
|
||||||
|
if (!empty($payload['billing']['phone'])) $customer->set_billing_phone(sanitize_text_field($payload['billing']['phone']));
|
||||||
|
if (!empty($payload['billing']['address_1'])) $customer->set_billing_address_1(sanitize_text_field($payload['billing']['address_1']));
|
||||||
|
if (!empty($payload['billing']['city'])) $customer->set_billing_city(sanitize_text_field($payload['billing']['city']));
|
||||||
|
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
|
||||||
|
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
|
||||||
|
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
|
||||||
|
|
||||||
|
$customer->save();
|
||||||
|
|
||||||
|
// Send new account email (WooCommerce will handle this automatically via hook)
|
||||||
|
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add items
|
// Add items
|
||||||
@@ -265,6 +402,12 @@ class CheckoutController {
|
|||||||
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear WooCommerce cart after successful order placement
|
||||||
|
// This ensures the cart page won't re-populate from server session
|
||||||
|
if (function_exists('WC') && WC()->cart) {
|
||||||
|
WC()->cart->empty_cart();
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'order_id' => $order->get_id(),
|
'order_id' => $order->get_id(),
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ class ModuleSettingsController extends WP_REST_Controller {
|
|||||||
* Register routes
|
* Register routes
|
||||||
*/
|
*/
|
||||||
public function register_routes() {
|
public function register_routes() {
|
||||||
// GET /woonoow/v1/modules/{module_id}/settings
|
// GET /woonoow/v1/modules/{module_id}/settings (public - needed by frontend)
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/settings', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/settings', [
|
||||||
[
|
[
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => [$this, 'get_settings'],
|
'callback' => [$this, 'get_settings'],
|
||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => '__return_true', // Public: settings are non-sensitive, needed by customer pages
|
||||||
'args' => [
|
'args' => [
|
||||||
'module_id' => [
|
'module_id' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
|
|||||||
@@ -56,6 +56,23 @@ class NewsletterController {
|
|||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Public unsubscribe endpoint (no auth needed, uses token)
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'unsubscribe'],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'args' => [
|
||||||
|
'email' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
'token' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function get_template(WP_REST_Request $request) {
|
public static function get_template(WP_REST_Request $request) {
|
||||||
@@ -197,4 +214,78 @@ class NewsletterController {
|
|||||||
],
|
],
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle unsubscribe request
|
||||||
|
*/
|
||||||
|
public static function unsubscribe(WP_REST_Request $request) {
|
||||||
|
$email = sanitize_email(urldecode($request->get_param('email')));
|
||||||
|
$token = sanitize_text_field($request->get_param('token'));
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
$expected_token = self::generate_unsubscribe_token($email);
|
||||||
|
if (!hash_equals($expected_token, $token)) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'message' => __('Invalid unsubscribe link', 'woonoow'),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscribers
|
||||||
|
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||||
|
$found = false;
|
||||||
|
|
||||||
|
foreach ($subscribers as &$sub) {
|
||||||
|
if (isset($sub['email']) && $sub['email'] === $email) {
|
||||||
|
$sub['status'] = 'unsubscribed';
|
||||||
|
$sub['unsubscribed_at'] = current_time('mysql');
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => false,
|
||||||
|
'message' => __('Email not found', 'woonoow'),
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option('woonoow_newsletter_subscribers', $subscribers);
|
||||||
|
|
||||||
|
do_action('woonoow_newsletter_unsubscribed', $email);
|
||||||
|
|
||||||
|
// Return HTML page for nice UX
|
||||||
|
$site_name = get_bloginfo('name');
|
||||||
|
$html = sprintf(
|
||||||
|
'<!DOCTYPE html><html><head><title>%s</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}.box{background:white;padding:40px;border-radius:8px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;}h1{color:#333;margin-bottom:16px;}p{color:#666;}</style></head><body><div class="box"><h1>✓ Unsubscribed</h1><p>You have been unsubscribed from %s newsletter.</p></div></body></html>',
|
||||||
|
__('Unsubscribed', 'woonoow'),
|
||||||
|
esc_html($site_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
echo $html;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate secure unsubscribe token
|
||||||
|
*/
|
||||||
|
private static function generate_unsubscribe_token($email) {
|
||||||
|
$secret = wp_salt('auth');
|
||||||
|
return hash_hmac('sha256', $email, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unsubscribe URL for email templates
|
||||||
|
*/
|
||||||
|
public static function generate_unsubscribe_url($email) {
|
||||||
|
$token = self::generate_unsubscribe_token($email);
|
||||||
|
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
|
||||||
|
return add_query_arg([
|
||||||
|
'email' => urlencode($email),
|
||||||
|
'token' => $token,
|
||||||
|
], $base_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class NotificationsController {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET/PUT /woonoow/v1/notifications/templates/:eventId/:channelId
|
// GET/POST /woonoow/v1/notifications/templates/:eventId/:channelId
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)', [
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -77,7 +77,7 @@ class NotificationsController {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'methods' => 'PUT',
|
'methods' => 'POST',
|
||||||
'callback' => [$this, 'save_template'],
|
'callback' => [$this, 'save_template'],
|
||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
@@ -486,6 +486,9 @@ class NotificationsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add available variables for this event (contextual)
|
||||||
|
$template['available_variables'] = EventRegistry::get_variables_for_event($event_id, $recipient_type);
|
||||||
|
|
||||||
return new WP_REST_Response($template, 200);
|
return new WP_REST_Response($template, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ class Permissions {
|
|||||||
$has_wc = current_user_can('manage_woocommerce');
|
$has_wc = current_user_can('manage_woocommerce');
|
||||||
$has_opts = current_user_can('manage_options');
|
$has_opts = current_user_can('manage_options');
|
||||||
$result = $has_wc || $has_opts;
|
$result = $has_wc || $has_opts;
|
||||||
error_log(sprintf('WooNooW Permissions: check_admin_permission() - WC:%s Options:%s Result:%s',
|
|
||||||
$has_wc ? 'YES' : 'NO',
|
|
||||||
$has_opts ? 'YES' : 'NO',
|
|
||||||
$result ? 'ALLOWED' : 'DENIED'
|
|
||||||
));
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +447,7 @@ class ProductsController {
|
|||||||
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
|
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
|
||||||
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
|
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
|
||||||
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
|
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
|
||||||
|
|
||||||
if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price']));
|
if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price']));
|
||||||
if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
|
if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
|
||||||
|
|
||||||
@@ -800,15 +801,18 @@ class ProductsController {
|
|||||||
$value = $term ? $term->name : $value;
|
$value = $term ? $term->name : $value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
|
// Custom attribute - stored as lowercase in meta
|
||||||
$meta_key = 'attribute_' . $attr_name;
|
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||||
$value = get_post_meta($variation_id, $meta_key, true);
|
$value = get_post_meta($variation_id, $meta_key, true);
|
||||||
|
|
||||||
// Capitalize the attribute name for display
|
// Capitalize the attribute name for display to match admin SPA
|
||||||
$clean_name = ucfirst($attr_name);
|
$clean_name = ucfirst($attr_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
$formatted_attributes[$clean_name] = $value;
|
// Only add if value exists
|
||||||
|
if (!empty($value)) {
|
||||||
|
$formatted_attributes[$clean_name] = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$image_url = $image ? $image[0] : '';
|
$image_url = $image ? $image[0] : '';
|
||||||
@@ -857,36 +861,106 @@ class ProductsController {
|
|||||||
* Save product variations
|
* Save product variations
|
||||||
*/
|
*/
|
||||||
private static function save_product_variations($product, $variations_data) {
|
private static function save_product_variations($product, $variations_data) {
|
||||||
|
// Get existing variation IDs
|
||||||
|
$existing_variation_ids = $product->get_children();
|
||||||
|
$variations_to_keep = [];
|
||||||
|
|
||||||
foreach ($variations_data as $var_data) {
|
foreach ($variations_data as $var_data) {
|
||||||
if (isset($var_data['id']) && $var_data['id']) {
|
if (isset($var_data['id']) && $var_data['id']) {
|
||||||
// Update existing variation
|
|
||||||
$variation = wc_get_product($var_data['id']);
|
$variation = wc_get_product($var_data['id']);
|
||||||
|
if (!$variation) continue;
|
||||||
|
$variations_to_keep[] = $var_data['id'];
|
||||||
} else {
|
} else {
|
||||||
// Create new variation
|
|
||||||
$variation = new WC_Product_Variation();
|
$variation = new WC_Product_Variation();
|
||||||
$variation->set_parent_id($product->get_id());
|
$variation->set_parent_id($product->get_id());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($variation) {
|
// Build attributes array
|
||||||
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
$wc_attributes = [];
|
||||||
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
|
if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
|
||||||
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']);
|
$parent_attributes = $product->get_attributes();
|
||||||
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
|
|
||||||
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
|
|
||||||
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
|
|
||||||
if (isset($var_data['attributes'])) $variation->set_attributes($var_data['attributes']);
|
|
||||||
|
|
||||||
// Handle image - support both image_id and image URL
|
foreach ($var_data['attributes'] as $display_name => $value) {
|
||||||
if (isset($var_data['image']) && !empty($var_data['image'])) {
|
if (empty($value)) continue;
|
||||||
$image_id = attachment_url_to_postid($var_data['image']);
|
|
||||||
if ($image_id) {
|
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||||
$variation->set_image_id($image_id);
|
if (!$parent_attr->get_variation()) continue;
|
||||||
|
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
|
||||||
|
$wc_attributes[strtolower($attr_name)] = strtolower($value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} elseif (isset($var_data['image_id'])) {
|
|
||||||
$variation->set_image_id($var_data['image_id']);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($wc_attributes)) {
|
||||||
|
$variation->set_attributes($wc_attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||||
|
|
||||||
|
// Set prices - if not provided, use parent's price as fallback
|
||||||
|
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
|
||||||
|
$variation->set_regular_price($var_data['regular_price']);
|
||||||
|
} elseif (!$variation->get_regular_price()) {
|
||||||
|
// Fallback to parent price if variation has no price
|
||||||
|
$parent_price = $product->get_regular_price();
|
||||||
|
if ($parent_price) {
|
||||||
|
$variation->set_regular_price($parent_price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($var_data['sale_price']) && $var_data['sale_price'] !== '') {
|
||||||
|
$variation->set_sale_price($var_data['sale_price']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
|
||||||
|
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
|
||||||
|
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
|
||||||
|
|
||||||
|
if (isset($var_data['image']) && !empty($var_data['image'])) {
|
||||||
|
$image_id = attachment_url_to_postid($var_data['image']);
|
||||||
|
if ($image_id) $variation->set_image_id($image_id);
|
||||||
|
} elseif (isset($var_data['image_id'])) {
|
||||||
|
$variation->set_image_id($var_data['image_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save variation first
|
||||||
|
$saved_id = $variation->save();
|
||||||
|
$variations_to_keep[] = $saved_id;
|
||||||
|
|
||||||
|
// Manually save attributes using direct database insert
|
||||||
|
if (!empty($wc_attributes)) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
$variation->save();
|
foreach ($wc_attributes as $attr_name => $attr_value) {
|
||||||
|
$meta_key = 'attribute_' . $attr_name;
|
||||||
|
|
||||||
|
$wpdb->delete(
|
||||||
|
$wpdb->postmeta,
|
||||||
|
['post_id' => $saved_id, 'meta_key' => $meta_key],
|
||||||
|
['%d', '%s']
|
||||||
|
);
|
||||||
|
|
||||||
|
$wpdb->insert(
|
||||||
|
$wpdb->postmeta,
|
||||||
|
[
|
||||||
|
'post_id' => $saved_id,
|
||||||
|
'meta_key' => $meta_key,
|
||||||
|
'meta_value' => $attr_value
|
||||||
|
],
|
||||||
|
['%d', '%s', '%s']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete variations that are no longer in the list
|
||||||
|
$variations_to_delete = array_diff($existing_variation_ids, $variations_to_keep);
|
||||||
|
foreach ($variations_to_delete as $variation_id) {
|
||||||
|
$variation_to_delete = wc_get_product($variation_id);
|
||||||
|
if ($variation_to_delete) {
|
||||||
|
$variation_to_delete->delete(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use WooNooW\Api\CustomersController;
|
|||||||
use WooNooW\Api\NewsletterController;
|
use WooNooW\Api\NewsletterController;
|
||||||
use WooNooW\Api\ModulesController;
|
use WooNooW\Api\ModulesController;
|
||||||
use WooNooW\Api\ModuleSettingsController;
|
use WooNooW\Api\ModuleSettingsController;
|
||||||
|
use WooNooW\Api\CampaignsController;
|
||||||
use WooNooW\Frontend\ShopController;
|
use WooNooW\Frontend\ShopController;
|
||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
@@ -64,6 +65,34 @@ class Routes {
|
|||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
] );
|
] );
|
||||||
|
|
||||||
|
// Customer login endpoint (no admin permission required)
|
||||||
|
register_rest_route( $namespace, '/auth/customer-login', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [ AuthController::class, 'customer_login' ],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Forgot password endpoint (public)
|
||||||
|
register_rest_route( $namespace, '/auth/forgot-password', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [ AuthController::class, 'forgot_password' ],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Validate password reset key (public)
|
||||||
|
register_rest_route( $namespace, '/auth/validate-reset-key', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [ AuthController::class, 'validate_reset_key' ],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
] );
|
||||||
|
|
||||||
|
// Reset password with key (public)
|
||||||
|
register_rest_route( $namespace, '/auth/reset-password', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [ AuthController::class, 'reset_password' ],
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
] );
|
||||||
|
|
||||||
// Defer to controllers to register their endpoints
|
// Defer to controllers to register their endpoints
|
||||||
CheckoutController::register();
|
CheckoutController::register();
|
||||||
OrdersController::register();
|
OrdersController::register();
|
||||||
@@ -125,6 +154,9 @@ class Routes {
|
|||||||
// Newsletter controller
|
// Newsletter controller
|
||||||
NewsletterController::register_routes();
|
NewsletterController::register_routes();
|
||||||
|
|
||||||
|
// Campaigns controller
|
||||||
|
CampaignsController::register_routes();
|
||||||
|
|
||||||
// Modules controller
|
// Modules controller
|
||||||
$modules_controller = new ModulesController();
|
$modules_controller = new ModulesController();
|
||||||
$modules_controller->register_routes();
|
$modules_controller->register_routes();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class CustomerSettingsProvider {
|
|||||||
// General
|
// General
|
||||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||||
'wishlist_enabled' => get_option('woonoow_wishlist_enabled', 'yes') === 'yes',
|
|
||||||
|
|
||||||
// VIP Customer Qualification
|
// VIP Customer Qualification
|
||||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||||
@@ -50,10 +49,7 @@ class CustomerSettingsProvider {
|
|||||||
update_option('woonoow_multiple_addresses_enabled', $value);
|
update_option('woonoow_multiple_addresses_enabled', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists('wishlist_enabled', $settings)) {
|
|
||||||
$value = !empty($settings['wishlist_enabled']) ? 'yes' : 'no';
|
|
||||||
update_option('woonoow_wishlist_enabled', $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// VIP settings
|
// VIP settings
|
||||||
if (isset($settings['vip_min_spent'])) {
|
if (isset($settings['vip_min_spent'])) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use WooNooW\Core\DataStores\OrderStore;
|
|||||||
use WooNooW\Core\MediaUpload;
|
use WooNooW\Core\MediaUpload;
|
||||||
use WooNooW\Core\Notifications\PushNotificationHandler;
|
use WooNooW\Core\Notifications\PushNotificationHandler;
|
||||||
use WooNooW\Core\Notifications\EmailManager;
|
use WooNooW\Core\Notifications\EmailManager;
|
||||||
|
use WooNooW\Core\Campaigns\CampaignManager;
|
||||||
use WooNooW\Core\ActivityLog\ActivityLogTable;
|
use WooNooW\Core\ActivityLog\ActivityLogTable;
|
||||||
use WooNooW\Branding;
|
use WooNooW\Branding;
|
||||||
use WooNooW\Frontend\Assets as FrontendAssets;
|
use WooNooW\Frontend\Assets as FrontendAssets;
|
||||||
@@ -40,10 +41,11 @@ class Bootstrap {
|
|||||||
MediaUpload::init();
|
MediaUpload::init();
|
||||||
PushNotificationHandler::init();
|
PushNotificationHandler::init();
|
||||||
EmailManager::instance(); // Initialize custom email system
|
EmailManager::instance(); // Initialize custom email system
|
||||||
|
CampaignManager::init(); // Initialize campaigns CPT
|
||||||
|
|
||||||
// Frontend (customer-spa)
|
// Frontend (customer-spa)
|
||||||
FrontendAssets::init();
|
FrontendAssets::init();
|
||||||
Shortcodes::init();
|
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
||||||
TemplateOverride::init();
|
TemplateOverride::init();
|
||||||
new PageAppearance();
|
new PageAppearance();
|
||||||
|
|
||||||
@@ -66,5 +68,64 @@ class Bootstrap {
|
|||||||
MailQueue::init();
|
MailQueue::init();
|
||||||
WooEmailOverride::init();
|
WooEmailOverride::init();
|
||||||
OrderStore::init();
|
OrderStore::init();
|
||||||
|
|
||||||
|
// Initialize cart for REST API requests
|
||||||
|
add_action('woocommerce_init', [self::class, 'init_cart_for_rest_api']);
|
||||||
|
|
||||||
|
// Load custom variation attributes for WooCommerce admin
|
||||||
|
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properly initialize WooCommerce cart for REST API requests
|
||||||
|
* This is the recommended approach per WooCommerce core team
|
||||||
|
*/
|
||||||
|
public static function init_cart_for_rest_api() {
|
||||||
|
// Only load cart for REST API requests
|
||||||
|
if (!WC()->is_rest_api_request()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load frontend includes (required for cart)
|
||||||
|
WC()->frontend_includes();
|
||||||
|
|
||||||
|
// Load cart using WooCommerce's official method
|
||||||
|
if (null === WC()->cart && function_exists('wc_load_cart')) {
|
||||||
|
wc_load_cart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load custom variation attributes from post meta for WooCommerce admin
|
||||||
|
* This ensures WooCommerce's native admin displays custom attributes correctly
|
||||||
|
*/
|
||||||
|
public static function load_variation_attributes($variation) {
|
||||||
|
if (!$variation instanceof \WC_Product_Variation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = wc_get_product($variation->get_parent_id());
|
||||||
|
if (!$parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = [];
|
||||||
|
foreach ($parent->get_attributes() as $attr_name => $attribute) {
|
||||||
|
if (!$attribute->get_variation()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from post meta (stored as lowercase)
|
||||||
|
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||||
|
$value = get_post_meta($variation->get_id(), $meta_key, true);
|
||||||
|
|
||||||
|
if (!empty($value)) {
|
||||||
|
$attributes[strtolower($attr_name)] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($attributes)) {
|
||||||
|
$variation->set_attributes($attributes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
479
includes/Core/Campaigns/CampaignManager.php
Normal file
479
includes/Core/Campaigns/CampaignManager.php
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Campaign Manager
|
||||||
|
*
|
||||||
|
* Manages newsletter campaign CRUD operations and sending
|
||||||
|
*
|
||||||
|
* @package WooNooW\Core\Campaigns
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Core\Campaigns;
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
class CampaignManager {
|
||||||
|
|
||||||
|
const POST_TYPE = 'wnw_campaign';
|
||||||
|
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
|
||||||
|
|
||||||
|
private static $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get instance
|
||||||
|
*/
|
||||||
|
public static function instance() {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize
|
||||||
|
*/
|
||||||
|
public static function init() {
|
||||||
|
add_action('init', [__CLASS__, 'register_post_type']);
|
||||||
|
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register campaign post type
|
||||||
|
*/
|
||||||
|
public static function register_post_type() {
|
||||||
|
register_post_type(self::POST_TYPE, [
|
||||||
|
'labels' => [
|
||||||
|
'name' => __('Campaigns', 'woonoow'),
|
||||||
|
'singular_name' => __('Campaign', 'woonoow'),
|
||||||
|
],
|
||||||
|
'public' => false,
|
||||||
|
'show_ui' => false,
|
||||||
|
'show_in_rest' => false,
|
||||||
|
'supports' => ['title'],
|
||||||
|
'capability_type' => 'post',
|
||||||
|
'map_meta_cap' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new campaign
|
||||||
|
*
|
||||||
|
* @param array $data Campaign data
|
||||||
|
* @return int|WP_Error Campaign ID or error
|
||||||
|
*/
|
||||||
|
public static function create($data) {
|
||||||
|
$post_data = [
|
||||||
|
'post_type' => self::POST_TYPE,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$campaign_id = wp_insert_post($post_data, true);
|
||||||
|
|
||||||
|
if (is_wp_error($campaign_id)) {
|
||||||
|
return $campaign_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save meta fields
|
||||||
|
self::update_meta($campaign_id, $data);
|
||||||
|
|
||||||
|
return $campaign_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update campaign
|
||||||
|
*
|
||||||
|
* @param int $campaign_id Campaign ID
|
||||||
|
* @param array $data Campaign data
|
||||||
|
* @return bool|WP_Error
|
||||||
|
*/
|
||||||
|
public static function update($campaign_id, $data) {
|
||||||
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
|
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update title if provided
|
||||||
|
if (isset($data['title'])) {
|
||||||
|
wp_update_post([
|
||||||
|
'ID' => $campaign_id,
|
||||||
|
'post_title' => sanitize_text_field($data['title']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meta fields
|
||||||
|
self::update_meta($campaign_id, $data);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update campaign meta
|
||||||
|
*
|
||||||
|
* @param int $campaign_id
|
||||||
|
* @param array $data
|
||||||
|
*/
|
||||||
|
private static function update_meta($campaign_id, $data) {
|
||||||
|
$meta_fields = [
|
||||||
|
'subject' => '_wnw_subject',
|
||||||
|
'content' => '_wnw_content',
|
||||||
|
'status' => '_wnw_status',
|
||||||
|
'scheduled_at' => '_wnw_scheduled_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($meta_fields as $key => $meta_key) {
|
||||||
|
if (isset($data[$key])) {
|
||||||
|
$value = $data[$key];
|
||||||
|
|
||||||
|
// Sanitize based on field type
|
||||||
|
if ($key === 'content') {
|
||||||
|
$value = wp_kses_post($value);
|
||||||
|
} elseif ($key === 'scheduled_at') {
|
||||||
|
$value = sanitize_text_field($value);
|
||||||
|
} elseif ($key === 'status') {
|
||||||
|
$allowed = ['draft', 'scheduled', 'sending', 'sent', 'failed'];
|
||||||
|
$value = in_array($value, $allowed) ? $value : 'draft';
|
||||||
|
} else {
|
||||||
|
$value = sanitize_text_field($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($campaign_id, $meta_key, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default status if not provided
|
||||||
|
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
|
||||||
|
update_post_meta($campaign_id, '_wnw_status', 'draft');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get campaign by ID
|
||||||
|
*
|
||||||
|
* @param int $campaign_id
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
public static function get($campaign_id) {
|
||||||
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::format_campaign($post);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all campaigns
|
||||||
|
*
|
||||||
|
* @param array $args Query args
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_all($args = []) {
|
||||||
|
$defaults = [
|
||||||
|
'post_type' => self::POST_TYPE,
|
||||||
|
'post_status' => 'any',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
];
|
||||||
|
|
||||||
|
$query_args = wp_parse_args($args, $defaults);
|
||||||
|
$query_args['post_type'] = self::POST_TYPE; // Force post type
|
||||||
|
|
||||||
|
$posts = get_posts($query_args);
|
||||||
|
|
||||||
|
return array_map([__CLASS__, 'format_campaign'], $posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format campaign post to array
|
||||||
|
*
|
||||||
|
* @param WP_Post $post
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function format_campaign($post) {
|
||||||
|
return [
|
||||||
|
'id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'subject' => get_post_meta($post->ID, '_wnw_subject', true),
|
||||||
|
'content' => get_post_meta($post->ID, '_wnw_content', true),
|
||||||
|
'status' => get_post_meta($post->ID, '_wnw_status', true) ?: 'draft',
|
||||||
|
'scheduled_at' => get_post_meta($post->ID, '_wnw_scheduled_at', true),
|
||||||
|
'sent_at' => get_post_meta($post->ID, '_wnw_sent_at', true),
|
||||||
|
'recipient_count' => (int) get_post_meta($post->ID, '_wnw_recipient_count', true),
|
||||||
|
'sent_count' => (int) get_post_meta($post->ID, '_wnw_sent_count', true),
|
||||||
|
'failed_count' => (int) get_post_meta($post->ID, '_wnw_failed_count', true),
|
||||||
|
'created_at' => $post->post_date,
|
||||||
|
'updated_at' => $post->post_modified,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete campaign
|
||||||
|
*
|
||||||
|
* @param int $campaign_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function delete($campaign_id) {
|
||||||
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return wp_delete_post($campaign_id, true) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send campaign
|
||||||
|
*
|
||||||
|
* @param int $campaign_id
|
||||||
|
* @return array Result with sent/failed counts
|
||||||
|
*/
|
||||||
|
public static function send($campaign_id) {
|
||||||
|
$campaign = self::get($campaign_id);
|
||||||
|
|
||||||
|
if (!$campaign) {
|
||||||
|
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($campaign['status'] === 'sent') {
|
||||||
|
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscribers
|
||||||
|
$subscribers = self::get_subscribers();
|
||||||
|
|
||||||
|
if (empty($subscribers)) {
|
||||||
|
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to sending
|
||||||
|
update_post_meta($campaign_id, '_wnw_status', 'sending');
|
||||||
|
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
// Get email template
|
||||||
|
$template = self::render_campaign_email($campaign);
|
||||||
|
|
||||||
|
// Send in batches
|
||||||
|
$batch_size = 50;
|
||||||
|
$batches = array_chunk($subscribers, $batch_size);
|
||||||
|
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
foreach ($batch as $subscriber) {
|
||||||
|
$email = $subscriber['email'];
|
||||||
|
|
||||||
|
// Replace subscriber-specific variables
|
||||||
|
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||||
|
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
$result = wp_mail(
|
||||||
|
$email,
|
||||||
|
$template['subject'],
|
||||||
|
$body,
|
||||||
|
['Content-Type: text/html; charset=UTF-8']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$sent++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between batches
|
||||||
|
if (count($batches) > 1) {
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update campaign stats
|
||||||
|
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
|
||||||
|
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
|
||||||
|
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
|
||||||
|
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'sent' => $sent,
|
||||||
|
'failed' => $failed,
|
||||||
|
'total' => count($subscribers),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test email
|
||||||
|
*
|
||||||
|
* @param int $campaign_id
|
||||||
|
* @param string $email Test email address
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function send_test($campaign_id, $email) {
|
||||||
|
$campaign = self::get($campaign_id);
|
||||||
|
|
||||||
|
if (!$campaign) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = self::render_campaign_email($campaign);
|
||||||
|
|
||||||
|
// Replace subscriber-specific variables
|
||||||
|
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||||
|
$body = str_replace('{unsubscribe_url}', '#', $body);
|
||||||
|
|
||||||
|
return wp_mail(
|
||||||
|
$email,
|
||||||
|
'[TEST] ' . $template['subject'],
|
||||||
|
$body,
|
||||||
|
['Content-Type: text/html; charset=UTF-8']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render campaign email using EmailRenderer
|
||||||
|
*
|
||||||
|
* @param array $campaign
|
||||||
|
* @return array ['subject' => string, 'body' => string]
|
||||||
|
*/
|
||||||
|
private static function render_campaign_email($campaign) {
|
||||||
|
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||||
|
|
||||||
|
// Get the campaign email template
|
||||||
|
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||||
|
|
||||||
|
// Fallback if no template configured
|
||||||
|
if (!$template) {
|
||||||
|
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||||
|
$body = $campaign['content'];
|
||||||
|
} else {
|
||||||
|
$subject = $template['subject'] ?: $campaign['subject'];
|
||||||
|
|
||||||
|
// Replace {content} with campaign content
|
||||||
|
$body = str_replace('{content}', $campaign['content'], $template['body']);
|
||||||
|
|
||||||
|
// Replace {campaign_title}
|
||||||
|
$body = str_replace('{campaign_title}', $campaign['title'], $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace common variables
|
||||||
|
$site_name = get_bloginfo('name');
|
||||||
|
$site_url = home_url();
|
||||||
|
|
||||||
|
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
|
||||||
|
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
|
||||||
|
$body = str_replace('{site_url}', $site_url, $body);
|
||||||
|
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
|
||||||
|
$body = str_replace('{current_year}', date('Y'), $body);
|
||||||
|
|
||||||
|
// Render through email design template
|
||||||
|
$design_path = $renderer->get_design_template();
|
||||||
|
if (file_exists($design_path)) {
|
||||||
|
$body = $renderer->render_html($design_path, $body, $subject, [
|
||||||
|
'site_name' => $site_name,
|
||||||
|
'site_url' => $site_url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscribers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function get_subscribers() {
|
||||||
|
// Check if using custom table
|
||||||
|
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
|
||||||
|
|
||||||
|
if ($use_table && self::has_subscribers_table()) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscribers';
|
||||||
|
return $wpdb->get_results(
|
||||||
|
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use wp_options storage
|
||||||
|
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||||
|
return array_filter($subscribers, function($sub) {
|
||||||
|
return ($sub['status'] ?? 'active') === 'active';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if subscribers table exists
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function has_subscribers_table() {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'woonoow_subscribers';
|
||||||
|
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unsubscribe URL
|
||||||
|
*
|
||||||
|
* @param string $email
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function get_unsubscribe_url($email) {
|
||||||
|
// Use NewsletterController's secure token-based URL
|
||||||
|
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process scheduled campaigns (WP-Cron)
|
||||||
|
*/
|
||||||
|
public static function process_scheduled_campaigns() {
|
||||||
|
// Only if scheduling is enabled
|
||||||
|
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$campaigns = self::get_all([
|
||||||
|
'meta_query' => [
|
||||||
|
[
|
||||||
|
'key' => '_wnw_status',
|
||||||
|
'value' => 'scheduled',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '_wnw_scheduled_at',
|
||||||
|
'value' => current_time('mysql'),
|
||||||
|
'compare' => '<=',
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($campaigns as $campaign) {
|
||||||
|
self::send($campaign['id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable scheduling (registers cron)
|
||||||
|
*/
|
||||||
|
public static function enable_scheduling() {
|
||||||
|
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||||
|
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable scheduling (clears cron)
|
||||||
|
*/
|
||||||
|
public static function disable_scheduling() {
|
||||||
|
wp_clear_scheduled_hook(self::CRON_HOOK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ class EmailManager {
|
|||||||
// New customer account
|
// New customer account
|
||||||
add_action('woocommerce_created_customer', [$this, 'send_new_customer_email'], 10, 3);
|
add_action('woocommerce_created_customer', [$this, 'send_new_customer_email'], 10, 3);
|
||||||
|
|
||||||
|
// Password reset - intercept WordPress default email and use our template
|
||||||
|
add_filter('retrieve_password_message', [$this, 'handle_password_reset_email'], 10, 4);
|
||||||
|
|
||||||
// Low stock / Out of stock
|
// Low stock / Out of stock
|
||||||
add_action('woocommerce_low_stock', [$this, 'send_low_stock_email'], 10, 1);
|
add_action('woocommerce_low_stock', [$this, 'send_low_stock_email'], 10, 1);
|
||||||
add_action('woocommerce_no_stock', [$this, 'send_out_of_stock_email'], 10, 1);
|
add_action('woocommerce_no_stock', [$this, 'send_out_of_stock_email'], 10, 1);
|
||||||
@@ -304,6 +307,110 @@ class EmailManager {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle password reset email - intercept WordPress default and use our template
|
||||||
|
*
|
||||||
|
* @param string $message Email message (we replace this)
|
||||||
|
* @param string $key Reset key
|
||||||
|
* @param string $user_login User login
|
||||||
|
* @param WP_User $user_data User object
|
||||||
|
* @return string Empty string to prevent WordPress sending default email
|
||||||
|
*/
|
||||||
|
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
|
||||||
|
// Check if WooNooW notification system is enabled
|
||||||
|
if (!self::is_enabled()) {
|
||||||
|
return $message; // Use WordPress default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event is enabled
|
||||||
|
if (!$this->is_event_enabled('password_reset', 'email', 'customer')) {
|
||||||
|
return $message; // Use WordPress default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build reset URL - use SPA page from appearance settings
|
||||||
|
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
|
||||||
|
if ($spa_page_id > 0) {
|
||||||
|
$spa_url = get_permalink($spa_page_id);
|
||||||
|
} else {
|
||||||
|
// Fallback to home URL if SPA page not configured
|
||||||
|
$spa_url = home_url('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SPA reset password URL with hash router format
|
||||||
|
// Format: /store/#/reset-password?key=KEY&login=LOGIN
|
||||||
|
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||||
|
|
||||||
|
// Create a pseudo WC_Customer for template rendering
|
||||||
|
$customer = null;
|
||||||
|
if (class_exists('WC_Customer')) {
|
||||||
|
try {
|
||||||
|
$customer = new \WC_Customer($user_data->ID);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$customer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send our custom email
|
||||||
|
$this->send_password_reset_email($user_data, $key, $reset_link, $customer);
|
||||||
|
|
||||||
|
// Return empty string to prevent WordPress from sending its default plain-text email
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email using our template
|
||||||
|
*
|
||||||
|
* @param WP_User $user User object
|
||||||
|
* @param string $key Reset key
|
||||||
|
* @param string $reset_link Full reset link URL
|
||||||
|
* @param WC_Customer|null $customer WooCommerce customer object if available
|
||||||
|
*/
|
||||||
|
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
|
||||||
|
// Get email renderer
|
||||||
|
$renderer = EmailRenderer::instance();
|
||||||
|
|
||||||
|
// Build extra data for template variables
|
||||||
|
$extra_data = [
|
||||||
|
'reset_key' => $key,
|
||||||
|
'reset_link' => $reset_link,
|
||||||
|
'user_login' => $user->user_login,
|
||||||
|
'user_email' => $user->user_email,
|
||||||
|
'customer_name' => $user->display_name ?: $user->user_login,
|
||||||
|
'customer_email' => $user->user_email,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use WC_Customer if available for better template rendering
|
||||||
|
$data = $customer ?: $user;
|
||||||
|
|
||||||
|
// Render email
|
||||||
|
$email = $renderer->render('password_reset', 'customer', $data, $extra_data);
|
||||||
|
|
||||||
|
if (!$email) {
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log('[EmailManager] Password reset email rendering failed for user: ' . $user->user_login);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email via wp_mail
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: text/html; charset=UTF-8',
|
||||||
|
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
|
||||||
|
];
|
||||||
|
|
||||||
|
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||||
|
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log('[EmailManager] Password reset email sent to ' . $email['to'] . ' - Result: ' . ($sent ? 'success' : 'failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log email sent
|
||||||
|
do_action('woonoow_email_sent', 'password_reset', 'customer', $email);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send low stock email
|
* Send low stock email
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -140,9 +140,12 @@ class EmailRenderer {
|
|||||||
*/
|
*/
|
||||||
private function get_variables($event_id, $data, $extra_data = []) {
|
private function get_variables($event_id, $data, $extra_data = []) {
|
||||||
$variables = [
|
$variables = [
|
||||||
|
'site_name' => get_bloginfo('name'),
|
||||||
|
'site_title' => get_bloginfo('name'),
|
||||||
'store_name' => get_bloginfo('name'),
|
'store_name' => get_bloginfo('name'),
|
||||||
'store_url' => home_url(),
|
'store_url' => home_url(),
|
||||||
'site_title' => get_bloginfo('name'),
|
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||||
|
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||||
'support_email' => get_option('admin_email'),
|
'support_email' => get_option('admin_email'),
|
||||||
'current_year' => date('Y'),
|
'current_year' => date('Y'),
|
||||||
];
|
];
|
||||||
@@ -249,7 +252,15 @@ class EmailRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer variables
|
// Customer variables
|
||||||
if ($data instanceof \WC_Customer) {
|
if ($data instanceof \WC_Customer) {
|
||||||
|
// Get temp password from user meta (stored during auto-registration)
|
||||||
|
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
|
||||||
|
|
||||||
|
// Generate login URL (pointing to SPA login instead of wp-login)
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
$login_url = $spa_page_id ? get_permalink($spa_page_id) . '#/login' : wp_login_url();
|
||||||
|
|
||||||
$variables = array_merge($variables, [
|
$variables = array_merge($variables, [
|
||||||
'customer_id' => $data->get_id(),
|
'customer_id' => $data->get_id(),
|
||||||
'customer_name' => $data->get_display_name(),
|
'customer_name' => $data->get_display_name(),
|
||||||
@@ -257,6 +268,10 @@ class EmailRenderer {
|
|||||||
'customer_last_name' => $data->get_last_name(),
|
'customer_last_name' => $data->get_last_name(),
|
||||||
'customer_email' => $data->get_email(),
|
'customer_email' => $data->get_email(),
|
||||||
'customer_username' => $data->get_username(),
|
'customer_username' => $data->get_username(),
|
||||||
|
'user_temp_password' => $user_temp_password ?: '',
|
||||||
|
'login_url' => $login_url,
|
||||||
|
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||||
|
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,8 +288,11 @@ class EmailRenderer {
|
|||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function parse_cards($content) {
|
private function parse_cards($content) {
|
||||||
// Match [card ...] ... [/card] patterns
|
// Use a single unified regex to match BOTH syntaxes in document order
|
||||||
preg_match_all('/\[card([^\]]*)\](.*?)\[\/card\]/s', $content, $matches, PREG_SET_ORDER);
|
// This ensures cards are rendered in the order they appear
|
||||||
|
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
|
||||||
|
|
||||||
|
preg_match_all($combined_pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||||
|
|
||||||
if (empty($matches)) {
|
if (empty($matches)) {
|
||||||
// No cards found, wrap entire content in a single card
|
// No cards found, wrap entire content in a single card
|
||||||
@@ -283,8 +301,19 @@ class EmailRenderer {
|
|||||||
|
|
||||||
$html = '';
|
$html = '';
|
||||||
foreach ($matches as $match) {
|
foreach ($matches as $match) {
|
||||||
$attributes = $this->parse_card_attributes($match[1]);
|
// Determine which syntax was matched
|
||||||
$card_content = $match[2];
|
$full_match = $match[0][0];
|
||||||
|
$new_syntax_type = !empty($match[1][0]) ? $match[1][0] : null; // [card:type] format
|
||||||
|
$old_syntax_attrs = $match[2][0] ?? ''; // [card type="..."] format
|
||||||
|
$card_content = $match[3][0];
|
||||||
|
|
||||||
|
if ($new_syntax_type) {
|
||||||
|
// NEW syntax [card:type]
|
||||||
|
$attributes = ['type' => $new_syntax_type];
|
||||||
|
} else {
|
||||||
|
// OLD syntax [card type="..."] or [card]
|
||||||
|
$attributes = $this->parse_card_attributes($old_syntax_attrs);
|
||||||
|
}
|
||||||
|
|
||||||
$html .= $this->render_card($card_content, $attributes);
|
$html .= $this->render_card($card_content, $attributes);
|
||||||
$html .= $this->render_card_spacing();
|
$html .= $this->render_card_spacing();
|
||||||
@@ -337,10 +366,65 @@ class EmailRenderer {
|
|||||||
|
|
||||||
// Get email customization settings for colors
|
// Get email customization settings for colors
|
||||||
$email_settings = get_option('woonoow_email_settings', []);
|
$email_settings = get_option('woonoow_email_settings', []);
|
||||||
|
$primary_color = $email_settings['primary_color'] ?? '#7f54b3';
|
||||||
|
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3';
|
||||||
|
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff';
|
||||||
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
|
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
|
||||||
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
||||||
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
|
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
|
||||||
|
|
||||||
|
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
||||||
|
// Helper function to generate button HTML
|
||||||
|
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
|
||||||
|
if ($style === 'outline') {
|
||||||
|
// Outline button - transparent background with border
|
||||||
|
$button_style = sprintf(
|
||||||
|
'display: inline-block; background-color: transparent; color: %s; padding: 14px 28px; border: 2px solid %s; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
|
||||||
|
esc_attr($secondary_color),
|
||||||
|
esc_attr($secondary_color)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Solid button - full background color
|
||||||
|
$button_style = sprintf(
|
||||||
|
'display: inline-block; background-color: %s; color: %s; padding: 14px 28px; border: none; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
|
||||||
|
esc_attr($primary_color),
|
||||||
|
esc_attr($button_text_color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use table-based button for better email client compatibility
|
||||||
|
return sprintf(
|
||||||
|
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
|
||||||
|
esc_url($url),
|
||||||
|
$button_style,
|
||||||
|
esc_html($text)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// NEW FORMAT: [button:style](url)Text[/button]
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) use ($generateButtonHtml) {
|
||||||
|
$style = $matches[1]; // solid or outline
|
||||||
|
$url = $matches[2];
|
||||||
|
$text = trim($matches[3]);
|
||||||
|
return $generateButtonHtml($url, $style, $text);
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
|
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
|
||||||
|
$content = preg_replace_callback(
|
||||||
|
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
|
||||||
|
function($matches) use ($generateButtonHtml) {
|
||||||
|
$url = $matches[1];
|
||||||
|
$style = $matches[2] ?? 'solid';
|
||||||
|
$text = trim($matches[3]);
|
||||||
|
return $generateButtonHtml($url, $style, $text);
|
||||||
|
},
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
|
||||||
$class = 'card';
|
$class = 'card';
|
||||||
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
|
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
|
||||||
$content_style = 'padding: 32px 40px;';
|
$content_style = 'padding: 32px 40px;';
|
||||||
@@ -367,15 +451,15 @@ class EmailRenderer {
|
|||||||
}
|
}
|
||||||
// Success card - green theme
|
// Success card - green theme
|
||||||
elseif ($type === 'success') {
|
elseif ($type === 'success') {
|
||||||
$style .= ' background-color: #f0fdf4; border-left: 4px solid #22c55e;';
|
$style .= ' background-color: #f0fdf4;';
|
||||||
}
|
}
|
||||||
// Info card - blue theme
|
// Info card - blue theme
|
||||||
elseif ($type === 'info') {
|
elseif ($type === 'info') {
|
||||||
$style .= ' background-color: #f0f7ff; border-left: 4px solid #0071e3;';
|
$style .= ' background-color: #f0f7ff;';
|
||||||
}
|
}
|
||||||
// Warning card - orange theme
|
// Warning card - orange/yellow theme
|
||||||
elseif ($type === 'warning') {
|
elseif ($type === 'warning') {
|
||||||
$style .= ' background-color: #fff8e1; border-left: 4px solid #ff9800;';
|
$style .= ' background-color: #fff8e1;';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,8 +640,13 @@ class EmailRenderer {
|
|||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function get_social_icon_url($platform, $color = 'white') {
|
private function get_social_icon_url($platform, $color = 'white') {
|
||||||
// Use local PNG icons
|
// Use plugin URL constant if available, otherwise calculate from file path
|
||||||
$plugin_url = plugin_dir_url(dirname(dirname(dirname(__FILE__))));
|
if (defined('WOONOOW_URL')) {
|
||||||
|
$plugin_url = WOONOOW_URL;
|
||||||
|
} else {
|
||||||
|
// File is at includes/Core/Notifications/EmailRenderer.php - need 4 levels up
|
||||||
|
$plugin_url = plugin_dir_url(dirname(dirname(dirname(dirname(__FILE__)))));
|
||||||
|
}
|
||||||
$filename = sprintf('mage--%s-%s.png', $platform, $color);
|
$filename = sprintf('mage--%s-%s.png', $platform, $color);
|
||||||
return $plugin_url . 'assets/icons/' . $filename;
|
return $plugin_url . 'assets/icons/' . $filename;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,22 @@ class EventRegistry {
|
|||||||
'wc_email' => 'customer_new_account',
|
'wc_email' => 'customer_new_account',
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
],
|
],
|
||||||
|
'password_reset' => [
|
||||||
|
'id' => 'password_reset',
|
||||||
|
'label' => __('Password Reset', 'woonoow'),
|
||||||
|
'description' => __('When a customer requests a password reset', 'woonoow'),
|
||||||
|
'category' => 'customers',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => [
|
||||||
|
'{reset_link}' => __('Password reset link', 'woonoow'),
|
||||||
|
'{reset_key}' => __('Password reset key', 'woonoow'),
|
||||||
|
'{user_login}' => __('Username', 'woonoow'),
|
||||||
|
'{user_email}' => __('User email', 'woonoow'),
|
||||||
|
'{site_name}' => __('Site name', 'woonoow'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// ===== NEWSLETTER EVENTS =====
|
// ===== NEWSLETTER EVENTS =====
|
||||||
'newsletter_welcome' => [
|
'newsletter_welcome' => [
|
||||||
@@ -63,6 +79,21 @@ class EventRegistry {
|
|||||||
'wc_email' => '',
|
'wc_email' => '',
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
],
|
],
|
||||||
|
'newsletter_campaign' => [
|
||||||
|
'id' => 'newsletter_campaign',
|
||||||
|
'label' => __('Newsletter Campaign', 'woonoow'),
|
||||||
|
'description' => __('Master email design template for newsletter campaigns', 'woonoow'),
|
||||||
|
'category' => 'marketing',
|
||||||
|
'recipient_type' => 'customer',
|
||||||
|
'wc_email' => '',
|
||||||
|
'enabled' => true,
|
||||||
|
'variables' => [
|
||||||
|
'{content}' => __('Campaign content', 'woonoow'),
|
||||||
|
'{campaign_title}' => __('Campaign title', 'woonoow'),
|
||||||
|
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
|
||||||
|
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// ===== ORDER INITIATION =====
|
// ===== ORDER INITIATION =====
|
||||||
'order_placed' => [
|
'order_placed' => [
|
||||||
@@ -340,4 +371,150 @@ class EventRegistry {
|
|||||||
public static function event_exists($event_id, $recipient_type) {
|
public static function event_exists($event_id, $recipient_type) {
|
||||||
return self::get_event($event_id, $recipient_type) !== null;
|
return self::get_event($event_id, $recipient_type) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get variables available for a specific event
|
||||||
|
*
|
||||||
|
* Returns both common variables and event-specific variables
|
||||||
|
*
|
||||||
|
* @param string $event_id Event ID
|
||||||
|
* @param string $recipient_type Recipient type
|
||||||
|
* @return array Array of variable definitions with key => description
|
||||||
|
*/
|
||||||
|
public static function get_variables_for_event($event_id, $recipient_type = 'customer') {
|
||||||
|
// Common variables available for ALL events
|
||||||
|
$common = [
|
||||||
|
'{site_name}' => __('Store/Site name', 'woonoow'),
|
||||||
|
'{site_title}' => __('Site title', 'woonoow'),
|
||||||
|
'{store_url}' => __('Store URL', 'woonoow'),
|
||||||
|
'{shop_url}' => __('Shop page URL', 'woonoow'),
|
||||||
|
'{my_account_url}' => __('My Account page URL', 'woonoow'),
|
||||||
|
'{login_url}' => __('Login page URL', 'woonoow'),
|
||||||
|
'{support_email}' => __('Support email address', 'woonoow'),
|
||||||
|
'{current_year}' => __('Current year', 'woonoow'),
|
||||||
|
'{current_date}' => __('Current date', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Customer variables (for customer-facing events)
|
||||||
|
$customer_vars = [
|
||||||
|
'{customer_name}' => __('Customer full name', 'woonoow'),
|
||||||
|
'{customer_first_name}' => __('Customer first name', 'woonoow'),
|
||||||
|
'{customer_last_name}' => __('Customer last name', 'woonoow'),
|
||||||
|
'{customer_email}' => __('Customer email', 'woonoow'),
|
||||||
|
'{customer_phone}' => __('Customer phone', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Order variables (for order-related events)
|
||||||
|
$order_vars = [
|
||||||
|
'{order_id}' => __('Order ID/number', 'woonoow'),
|
||||||
|
'{order_number}' => __('Order number', 'woonoow'),
|
||||||
|
'{order_date}' => __('Order date', 'woonoow'),
|
||||||
|
'{order_total}' => __('Order total', 'woonoow'),
|
||||||
|
'{order_subtotal}' => __('Order subtotal', 'woonoow'),
|
||||||
|
'{order_tax}' => __('Order tax', 'woonoow'),
|
||||||
|
'{order_shipping}' => __('Shipping cost', 'woonoow'),
|
||||||
|
'{order_discount}' => __('Discount amount', 'woonoow'),
|
||||||
|
'{order_status}' => __('Order status', 'woonoow'),
|
||||||
|
'{order_url}' => __('Order details URL', 'woonoow'),
|
||||||
|
'{order_items_table}' => __('Order items table (HTML)', 'woonoow'),
|
||||||
|
'{billing_address}' => __('Billing address', 'woonoow'),
|
||||||
|
'{shipping_address}' => __('Shipping address', 'woonoow'),
|
||||||
|
'{payment_method}' => __('Payment method', 'woonoow'),
|
||||||
|
'{payment_status}' => __('Payment status', 'woonoow'),
|
||||||
|
'{shipping_method}' => __('Shipping method', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shipping/tracking variables (for shipped/delivered events)
|
||||||
|
$shipping_vars = [
|
||||||
|
'{tracking_number}' => __('Tracking number', 'woonoow'),
|
||||||
|
'{tracking_url}' => __('Tracking URL', 'woonoow'),
|
||||||
|
'{shipping_carrier}' => __('Shipping carrier', 'woonoow'),
|
||||||
|
'{estimated_delivery}' => __('Estimated delivery date', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Product variables (for stock alerts)
|
||||||
|
$product_vars = [
|
||||||
|
'{product_name}' => __('Product name', 'woonoow'),
|
||||||
|
'{product_sku}' => __('Product SKU', 'woonoow'),
|
||||||
|
'{product_url}' => __('Product URL', 'woonoow'),
|
||||||
|
'{product_price}' => __('Product price', 'woonoow'),
|
||||||
|
'{stock_quantity}' => __('Stock quantity', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Newsletter variables
|
||||||
|
$newsletter_vars = [
|
||||||
|
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
|
||||||
|
'{subscriber_name}' => __('Subscriber name', 'woonoow'),
|
||||||
|
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build variables based on event ID and category
|
||||||
|
$event = self::get_event($event_id, $recipient_type);
|
||||||
|
|
||||||
|
// If event not found, try to match by just event_id
|
||||||
|
if (!$event) {
|
||||||
|
$all_events = self::get_all_events();
|
||||||
|
foreach ($all_events as $e) {
|
||||||
|
if ($e['id'] === $event_id) {
|
||||||
|
$event = $e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with common vars
|
||||||
|
$variables = $common;
|
||||||
|
|
||||||
|
// Add category-specific vars
|
||||||
|
if ($event) {
|
||||||
|
$category = $event['category'] ?? '';
|
||||||
|
|
||||||
|
// Add customer vars for customer-facing events
|
||||||
|
if (($event['recipient_type'] ?? '') === 'customer') {
|
||||||
|
$variables = array_merge($variables, $customer_vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add based on category
|
||||||
|
switch ($category) {
|
||||||
|
case 'orders':
|
||||||
|
$variables = array_merge($variables, $order_vars);
|
||||||
|
// Add tracking for completed/shipped events
|
||||||
|
if (in_array($event_id, ['order_completed', 'order_shipped', 'order_delivered'])) {
|
||||||
|
$variables = array_merge($variables, $shipping_vars);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'products':
|
||||||
|
$variables = array_merge($variables, $product_vars);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'marketing':
|
||||||
|
$variables = array_merge($variables, $newsletter_vars);
|
||||||
|
// Add campaign-specific for newsletter_campaign
|
||||||
|
if ($event_id === 'newsletter_campaign') {
|
||||||
|
$variables['{content}'] = __('Campaign content', 'woonoow');
|
||||||
|
$variables['{campaign_title}'] = __('Campaign title', 'woonoow');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customers':
|
||||||
|
$variables = array_merge($variables, $customer_vars);
|
||||||
|
// Add account-specific vars
|
||||||
|
if ($event_id === 'new_customer') {
|
||||||
|
$variables['{user_temp_password}'] = __('Temporary password', 'woonoow');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event-specific variables if defined
|
||||||
|
if (!empty($event['variables'])) {
|
||||||
|
$variables = array_merge($variables, $event['variables']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically for easier browsing
|
||||||
|
ksort($variables);
|
||||||
|
|
||||||
|
return $variables;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class DefaultTemplates
|
|||||||
'order_cancelled' => self::customer_order_cancelled(),
|
'order_cancelled' => self::customer_order_cancelled(),
|
||||||
'order_refunded' => self::customer_order_refunded(),
|
'order_refunded' => self::customer_order_refunded(),
|
||||||
'new_customer' => self::customer_new_customer(),
|
'new_customer' => self::customer_new_customer(),
|
||||||
|
'newsletter_campaign' => self::customer_newsletter_campaign(),
|
||||||
],
|
],
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'order_placed' => self::staff_order_placed(),
|
'order_placed' => self::staff_order_placed(),
|
||||||
@@ -139,6 +140,7 @@ class DefaultTemplates
|
|||||||
'order_cancelled' => 'Order #{order_number} has been cancelled',
|
'order_cancelled' => 'Order #{order_number} has been cancelled',
|
||||||
'order_refunded' => 'Refund processed for order #{order_number}',
|
'order_refunded' => 'Refund processed for order #{order_number}',
|
||||||
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
||||||
|
'newsletter_campaign' => '{campaign_title}',
|
||||||
],
|
],
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
|
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
|
||||||
@@ -194,18 +196,85 @@ Your account is ready. Here\'s what you can do now:
|
|||||||
✓ Easy returns and refunds
|
✓ Easy returns and refunds
|
||||||
[/card]
|
[/card]
|
||||||
|
|
||||||
[button url="{my_account_url}"]Access Your Account[/button]
|
[card type="success"]
|
||||||
[button url="{shop_url}"]Start Shopping[/button]
|
**Your Login Credentials:**
|
||||||
|
|
||||||
[card type="info"]
|
📧 **Email:** {customer_email}
|
||||||
💡 **Tip:** Check your account settings to receive personalized recommendations based on your interests.
|
🔑 **Password:** {user_temp_password}
|
||||||
|
|
||||||
|
[button url="{login_url}" style="solid"]Log In Now[/button]
|
||||||
|
|
||||||
|
We recommend changing your password in Account Settings after logging in.
|
||||||
[/card]
|
[/card]
|
||||||
|
|
||||||
|
[button url="{shop_url}" style="outline"]Start Shopping[/button]
|
||||||
|
|
||||||
[card type="basic"]
|
[card type="basic"]
|
||||||
Got questions? Our customer service team is ready to help: {support_email}
|
Got questions? Our customer service team is ready to help: {support_email}
|
||||||
[/card]';
|
[/card]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer: Password Reset
|
||||||
|
* Sent when customer requests a password reset
|
||||||
|
*/
|
||||||
|
private static function customer_password_reset()
|
||||||
|
{
|
||||||
|
return '[card type="hero"]
|
||||||
|
## Reset Your Password 🔐
|
||||||
|
|
||||||
|
Hi {customer_name},
|
||||||
|
|
||||||
|
You\'ve requested to reset your password for your {site_name} account.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="warning"]
|
||||||
|
**Click the button below to reset your password:**
|
||||||
|
|
||||||
|
[button url="{reset_link}" style="solid"]Reset My Password[/button]
|
||||||
|
|
||||||
|
This link will expire in 24 hours for security reasons.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="basic"]
|
||||||
|
**Didn\'t request this?**
|
||||||
|
|
||||||
|
If you didn\'t request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||||
|
|
||||||
|
For security, never share this link with anyone.
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="basic" bg="#f5f5f5"]
|
||||||
|
If the button above doesn\'t work, copy and paste this link into your browser:
|
||||||
|
|
||||||
|
{reset_link}
|
||||||
|
[/card]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer: Newsletter Campaign
|
||||||
|
* Master design template for newsletter campaigns
|
||||||
|
* The {content} variable is replaced with the actual campaign content
|
||||||
|
*/
|
||||||
|
private static function customer_newsletter_campaign()
|
||||||
|
{
|
||||||
|
return '[card type="hero"]
|
||||||
|
## {campaign_title}
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card]
|
||||||
|
{content}
|
||||||
|
[/card]
|
||||||
|
|
||||||
|
[card type="basic" bg="#f5f5f5"]
|
||||||
|
You are receiving this because you subscribed to {site_name} newsletter.
|
||||||
|
|
||||||
|
[Unsubscribe]({unsubscribe_url}) | [Visit Store]({site_url})
|
||||||
|
|
||||||
|
© {current_year} {site_name}. All rights reserved.
|
||||||
|
[/card]';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer: Order Placed
|
* Customer: Order Placed
|
||||||
* Sent immediately when customer places an order
|
* Sent immediately when customer places an order
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class Assets {
|
|||||||
add_action('wp_head', [self::class, 'add_inline_config'], 5);
|
add_action('wp_head', [self::class, 'add_inline_config'], 5);
|
||||||
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||||
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
||||||
|
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,9 +62,6 @@ class Assets {
|
|||||||
null,
|
null,
|
||||||
false // Load in header
|
false // Load in header
|
||||||
);
|
);
|
||||||
|
|
||||||
error_log('WooNooW Customer: Loading from Vite dev server at ' . $dev_server);
|
|
||||||
error_log('WooNooW Customer: Scripts enqueued - vite client and main.tsx');
|
|
||||||
} else {
|
} else {
|
||||||
// Production mode: Load from build
|
// Production mode: Load from build
|
||||||
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
||||||
@@ -71,14 +69,16 @@ class Assets {
|
|||||||
|
|
||||||
// Check if build exists
|
// Check if build exists
|
||||||
if (!file_exists($dist_path)) {
|
if (!file_exists($dist_path)) {
|
||||||
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production build - load app.js and app.css directly
|
// Production build - load app.js and app.css directly
|
||||||
|
$js_url = $plugin_url . 'customer-spa/dist/app.js';
|
||||||
|
$css_url = $plugin_url . 'customer-spa/dist/app.css';
|
||||||
|
|
||||||
wp_enqueue_script(
|
wp_enqueue_script(
|
||||||
'woonoow-customer-spa',
|
'woonoow-customer-spa',
|
||||||
$plugin_url . 'customer-spa/dist/app.js',
|
$js_url,
|
||||||
[],
|
[],
|
||||||
null,
|
null,
|
||||||
true
|
true
|
||||||
@@ -94,13 +94,31 @@ class Assets {
|
|||||||
|
|
||||||
wp_enqueue_style(
|
wp_enqueue_style(
|
||||||
'woonoow-customer-spa',
|
'woonoow-customer-spa',
|
||||||
$plugin_url . 'customer-spa/dist/app.css',
|
$css_url,
|
||||||
[],
|
[],
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject SPA mounting point for full mode
|
||||||
|
*/
|
||||||
|
public static function inject_spa_mount_point() {
|
||||||
|
if (!self::should_load_assets()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in full mode and not on a page with shortcode
|
||||||
|
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
|
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||||
|
|
||||||
|
if ($mode === 'full') {
|
||||||
|
// Only inject if the mount point doesn't already exist (from shortcode)
|
||||||
|
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add inline config and scripts to page head
|
* Add inline config and scripts to page head
|
||||||
*/
|
*/
|
||||||
@@ -211,7 +229,6 @@ class Assets {
|
|||||||
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
|
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
|
||||||
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
|
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
|
||||||
<?php
|
<?php
|
||||||
error_log('WooNooW Customer: Scripts output directly in head with React Refresh preamble');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,21 +238,42 @@ class Assets {
|
|||||||
private static function should_load_assets() {
|
private static function should_load_assets() {
|
||||||
global $post;
|
global $post;
|
||||||
|
|
||||||
|
// First check: Is this a designated SPA page?
|
||||||
|
if (self::is_spa_page()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Get Customer SPA settings
|
// Get Customer SPA settings
|
||||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||||
|
|
||||||
// If disabled, don't load
|
// If disabled, don't load
|
||||||
if ($mode === 'disabled') {
|
if ($mode === 'disabled') {
|
||||||
// Still check for shortcodes
|
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
if (function_exists('is_shop') && is_shop()) {
|
||||||
return true;
|
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||||
|
if ($shop_page_id) {
|
||||||
|
$shop_page = get_post($shop_page_id);
|
||||||
|
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($post && has_shortcode($post->post_content, 'woonoow_cart')) {
|
|
||||||
return true;
|
// Check for shortcodes on regular pages
|
||||||
}
|
if ($post) {
|
||||||
if ($post && has_shortcode($post->post_content, 'woonoow_checkout')) {
|
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (has_shortcode($post->post_content, 'woonoow_account')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -296,6 +334,27 @@ class Assets {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current page is the designated SPA page
|
||||||
|
*/
|
||||||
|
private static function is_spa_page() {
|
||||||
|
global $post;
|
||||||
|
if (!$post) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SPA page ID from appearance settings
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
||||||
|
|
||||||
|
// Check if current page matches the SPA page
|
||||||
|
if ($spa_page_id && $post->ID == $spa_page_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dequeue conflicting scripts when SPA is active
|
* Dequeue conflicting scripts when SPA is active
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,413 +9,465 @@ use WP_Error;
|
|||||||
* Cart Controller - Customer-facing cart API
|
* Cart Controller - Customer-facing cart API
|
||||||
* Handles cart operations for customer-spa
|
* Handles cart operations for customer-spa
|
||||||
*/
|
*/
|
||||||
class CartController {
|
class CartController
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize controller
|
* Initialize controller
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
// Bypass cookie authentication for cart endpoints to allow guest users
|
// Bypass cookie authentication for cart endpoints to allow guest users
|
||||||
add_filter('rest_authentication_errors', function($result) {
|
add_filter('rest_authentication_errors', function ($result) {
|
||||||
// If already authenticated or error, return as is
|
// If already authenticated or error, return as is
|
||||||
if (!empty($result)) {
|
if (!empty($result)) {
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a cart endpoint
|
// Check if this is a cart endpoint
|
||||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
if (strpos($request_uri, '/woonoow/v1/cart') !== false) {
|
if (strpos($request_uri, '/woonoow/v1/cart') !== false) {
|
||||||
return true; // Allow access
|
return true; // Allow access
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register REST API routes
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
$namespace = 'woonoow/v1';
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
// Get cart
|
// Get cart
|
||||||
$result = register_rest_route($namespace, '/cart', [
|
$result = register_rest_route($namespace, '/cart', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_cart'],
|
'callback' => [__CLASS__, 'get_cart'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add to cart
|
// Add to cart
|
||||||
$result = register_rest_route($namespace, '/cart/add', [
|
$result = register_rest_route($namespace, '/cart/add', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'add_to_cart'],
|
'callback' => [__CLASS__, 'add_to_cart'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function () {
|
||||||
// Allow both logged-in and guest users
|
// Allow both logged-in and guest users
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
'args' => [
|
'args' => [
|
||||||
'product_id' => [
|
'product_id' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'validate_callback' => function($param) {
|
'validate_callback' => function ($param) {
|
||||||
return is_numeric($param);
|
return is_numeric($param);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'quantity' => [
|
'quantity' => [
|
||||||
'default' => 1,
|
'default' => 1,
|
||||||
'sanitize_callback' => 'absint',
|
'sanitize_callback' => 'absint',
|
||||||
],
|
],
|
||||||
'variation_id' => [
|
'variation_id' => [
|
||||||
'default' => 0,
|
'default' => 0,
|
||||||
'sanitize_callback' => 'absint',
|
'sanitize_callback' => 'absint',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update cart item
|
// Update cart item
|
||||||
register_rest_route($namespace, '/cart/update', [
|
register_rest_route($namespace, '/cart/update', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'update_cart'],
|
'callback' => [__CLASS__, 'update_cart'],
|
||||||
'permission_callback' => function() { return true; },
|
'permission_callback' => function () {
|
||||||
'args' => [
|
return true; },
|
||||||
|
'args' => [
|
||||||
'cart_item_key' => [
|
'cart_item_key' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
],
|
],
|
||||||
'quantity' => [
|
'quantity' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'absint',
|
'sanitize_callback' => 'absint',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Remove from cart
|
// Remove from cart
|
||||||
register_rest_route($namespace, '/cart/remove', [
|
register_rest_route($namespace, '/cart/remove', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||||
'permission_callback' => function() { return true; },
|
'permission_callback' => function () {
|
||||||
'args' => [
|
return true; },
|
||||||
|
'args' => [
|
||||||
'cart_item_key' => [
|
'cart_item_key' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply coupon
|
// Apply coupon
|
||||||
register_rest_route($namespace, '/cart/apply-coupon', [
|
register_rest_route($namespace, '/cart/apply-coupon', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'apply_coupon'],
|
'callback' => [__CLASS__, 'apply_coupon'],
|
||||||
'permission_callback' => function() { return true; },
|
'permission_callback' => function () {
|
||||||
'args' => [
|
return true; },
|
||||||
|
'args' => [
|
||||||
'coupon_code' => [
|
'coupon_code' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Clear cart
|
||||||
|
register_rest_route($namespace, '/cart/clear', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'clear_cart'],
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return true; },
|
||||||
|
]);
|
||||||
|
|
||||||
// Remove coupon
|
// Remove coupon
|
||||||
register_rest_route($namespace, '/cart/remove-coupon', [
|
register_rest_route($namespace, '/cart/remove-coupon', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'remove_coupon'],
|
'callback' => [__CLASS__, 'remove_coupon'],
|
||||||
'permission_callback' => function() { return true; },
|
'permission_callback' => function () {
|
||||||
'args' => [
|
return true; },
|
||||||
|
'args' => [
|
||||||
'coupon_code' => [
|
'coupon_code' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
'type' => 'string',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cart contents
|
* Get cart contents
|
||||||
*/
|
*/
|
||||||
public static function get_cart(WP_REST_Request $request) {
|
public static function get_cart(WP_REST_Request $request)
|
||||||
if (!WC()->cart) {
|
{
|
||||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
// Initialize WooCommerce session and cart for REST API requests
|
||||||
|
if (!WC()->session) {
|
||||||
|
WC()->initialize_session();
|
||||||
}
|
}
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->initialize_cart();
|
||||||
|
}
|
||||||
|
// Set session cookie for guest users to persist cart
|
||||||
|
if (!WC()->session->has_session()) {
|
||||||
|
WC()->session->set_customer_session_cookie(true);
|
||||||
|
}
|
||||||
|
|
||||||
return new WP_REST_Response(self::format_cart(), 200);
|
return new WP_REST_Response(self::format_cart(), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add item to cart
|
* Add item to cart
|
||||||
*/
|
*/
|
||||||
public static function add_to_cart(WP_REST_Request $request) {
|
public static function add_to_cart(WP_REST_Request $request)
|
||||||
$product_id = $request->get_param('product_id');
|
{
|
||||||
$quantity = $request->get_param('quantity');
|
$product_id = $request->get_param('product_id');
|
||||||
|
$quantity = $request->get_param('quantity') ?: 1; // Default to 1
|
||||||
$variation_id = $request->get_param('variation_id');
|
$variation_id = $request->get_param('variation_id');
|
||||||
|
|
||||||
error_log("WooNooW Cart: Adding product {$product_id} (variation: {$variation_id}) qty: {$quantity}");
|
|
||||||
|
|
||||||
// Check if WooCommerce is available
|
|
||||||
if (!function_exists('WC')) {
|
|
||||||
error_log('WooNooW Cart Error: WooCommerce not loaded');
|
|
||||||
return new WP_Error('wc_not_loaded', 'WooCommerce is not loaded', ['status' => 500]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize WooCommerce session and cart for REST API requests
|
// Initialize WooCommerce session and cart for REST API requests
|
||||||
// WooCommerce doesn't auto-initialize these for REST API calls
|
|
||||||
if (!WC()->session) {
|
if (!WC()->session) {
|
||||||
error_log('WooNooW Cart: Initializing WC session for REST API');
|
|
||||||
WC()->initialize_session();
|
WC()->initialize_session();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WC()->cart) {
|
if (!WC()->cart) {
|
||||||
error_log('WooNooW Cart: Initializing WC cart for REST API');
|
|
||||||
WC()->initialize_cart();
|
WC()->initialize_cart();
|
||||||
}
|
}
|
||||||
|
// CRITICAL: Set session cookie for guest users to persist cart
|
||||||
// Set session cookie for guest users
|
|
||||||
if (!WC()->session->has_session()) {
|
if (!WC()->session->has_session()) {
|
||||||
WC()->session->set_customer_session_cookie(true);
|
WC()->session->set_customer_session_cookie(true);
|
||||||
error_log('WooNooW Cart: Session cookie set for guest user');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log('WooNooW Cart: WC Session and Cart initialized successfully');
|
|
||||||
|
|
||||||
// Validate product
|
// Validate product
|
||||||
$product = wc_get_product($product_id);
|
$product = wc_get_product($product_id);
|
||||||
if (!$product) {
|
if (!$product) {
|
||||||
error_log("WooNooW Cart Error: Product {$product_id} not found");
|
|
||||||
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
|
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log("WooNooW Cart: Product validated - {$product->get_name()} (Type: {$product->get_type()})");
|
// For variable products, get attributes from request or variation
|
||||||
|
|
||||||
// For variable products, validate the variation and get attributes
|
|
||||||
$variation_attributes = [];
|
$variation_attributes = [];
|
||||||
if ($variation_id > 0) {
|
if ($variation_id > 0) {
|
||||||
$variation = wc_get_product($variation_id);
|
$variation = wc_get_product($variation_id);
|
||||||
if (!$variation) {
|
if (!$variation) {
|
||||||
error_log("WooNooW Cart Error: Variation {$variation_id} not found");
|
return new WP_Error('invalid_variation', "Variation not found", ['status' => 404]);
|
||||||
return new WP_Error('invalid_variation', "Variation {$variation_id} not found", ['status' => 404]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($variation->get_parent_id() != $product_id) {
|
if ($variation->get_parent_id() != $product_id) {
|
||||||
error_log("WooNooW Cart Error: Variation {$variation_id} does not belong to product {$product_id}");
|
|
||||||
return new WP_Error('invalid_variation', "Variation does not belong to this product", ['status' => 400]);
|
return new WP_Error('invalid_variation', "Variation does not belong to this product", ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$variation->is_purchasable() || !$variation->is_in_stock()) {
|
if (!$variation->is_in_stock()) {
|
||||||
error_log("WooNooW Cart Error: Variation {$variation_id} is not purchasable or out of stock");
|
return new WP_Error('variation_not_available', "This variation is out of stock", ['status' => 400]);
|
||||||
return new WP_Error('variation_not_available', "This variation is not available for purchase", ['status' => 400]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get variation attributes from post meta
|
// Build attributes from request parameters (like WooCommerce does)
|
||||||
// WooCommerce stores variation attributes as post meta with 'attribute_' prefix
|
// Check for attribute_* parameters in the request
|
||||||
$variation_attributes = [];
|
$params = $request->get_params();
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
// Get parent product to know which attributes to look for
|
if (strpos($key, 'attribute_') === 0) {
|
||||||
$parent_product = wc_get_product($product_id);
|
$variation_attributes[sanitize_title($key)] = wc_clean($value);
|
||||||
$parent_attributes = $parent_product->get_attributes();
|
}
|
||||||
|
}
|
||||||
error_log("WooNooW Cart: Parent product attributes: " . print_r(array_keys($parent_attributes), true));
|
|
||||||
|
// If no attributes in request, get from variation meta directly
|
||||||
// For each parent attribute, get the value from variation post meta
|
if (empty($variation_attributes)) {
|
||||||
foreach ($parent_attributes as $attribute) {
|
$parent = wc_get_product($product_id);
|
||||||
if ($attribute->get_variation()) {
|
foreach ($parent->get_attributes() as $attr_name => $attribute) {
|
||||||
$attribute_name = $attribute->get_name();
|
if (!$attribute->get_variation())
|
||||||
$meta_key = 'attribute_' . $attribute_name;
|
continue;
|
||||||
|
|
||||||
// Get the value from post meta
|
$meta_key = 'attribute_' . $attr_name;
|
||||||
$attribute_value = get_post_meta($variation_id, $meta_key, true);
|
$value = get_post_meta($variation_id, $meta_key, true);
|
||||||
|
|
||||||
error_log("WooNooW Cart: Checking attribute {$attribute_name} (meta key: {$meta_key}): {$attribute_value}");
|
if (!empty($value)) {
|
||||||
|
$variation_attributes[$meta_key] = $value;
|
||||||
if (!empty($attribute_value)) {
|
|
||||||
// WooCommerce expects lowercase attribute names
|
|
||||||
$wc_attribute_key = 'attribute_' . strtolower($attribute_name);
|
|
||||||
$variation_attributes[$wc_attribute_key] = $attribute_value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log("WooNooW Cart: Variation validated - {$variation->get_name()}");
|
|
||||||
error_log("WooNooW Cart: Variation attributes extracted: " . print_r($variation_attributes, true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any existing notices before adding to cart
|
// Clear any existing notices before adding to cart
|
||||||
wc_clear_notices();
|
wc_clear_notices();
|
||||||
|
|
||||||
// Add to cart with variation attributes
|
// Add to cart with variation attributes
|
||||||
error_log("WooNooW Cart: Calling WC()->cart->add_to_cart({$product_id}, {$quantity}, {$variation_id}, attributes)");
|
|
||||||
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation_attributes);
|
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation_attributes);
|
||||||
|
|
||||||
if (!$cart_item_key) {
|
if (!$cart_item_key) {
|
||||||
// Get WooCommerce notices to provide better error message
|
|
||||||
$notices = wc_get_notices('error');
|
$notices = wc_get_notices('error');
|
||||||
$error_messages = [];
|
$error_messages = [];
|
||||||
foreach ($notices as $notice) {
|
foreach ($notices as $notice) {
|
||||||
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
|
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
|
||||||
}
|
}
|
||||||
$error_message = !empty($error_messages) ? implode(', ', $error_messages) : 'Failed to add product to cart';
|
$error_message = !empty($error_messages) ? implode(', ', $error_messages) : 'Failed to add product to cart';
|
||||||
wc_clear_notices(); // Clear notices after reading
|
wc_clear_notices();
|
||||||
|
|
||||||
error_log("WooNooW Cart Error: add_to_cart returned false - {$error_message}");
|
|
||||||
error_log("WooNooW Cart Error: All WC notices: " . print_r($notices, true));
|
|
||||||
return new WP_Error('add_to_cart_failed', $error_message, ['status' => 400]);
|
return new WP_Error('add_to_cart_failed', $error_message, ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log("WooNooW Cart: Product added successfully - Key: {$cart_item_key}");
|
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'message' => 'Product added to cart',
|
'message' => 'Product added to cart',
|
||||||
'cart_item_key' => $cart_item_key,
|
'cart_item_key' => $cart_item_key,
|
||||||
'cart' => self::format_cart(),
|
'cart' => self::format_cart(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update cart item quantity
|
* Update cart item quantity
|
||||||
*/
|
*/
|
||||||
public static function update_cart(WP_REST_Request $request) {
|
public static function update_cart(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$cart_item_key = $request->get_param('cart_item_key');
|
$cart_item_key = $request->get_param('cart_item_key');
|
||||||
$quantity = $request->get_param('quantity');
|
$quantity = $request->get_param('quantity');
|
||||||
|
|
||||||
if (!WC()->cart) {
|
// Initialize WooCommerce session and cart for REST API requests
|
||||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
if (!WC()->session) {
|
||||||
|
WC()->initialize_session();
|
||||||
}
|
}
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->initialize_cart();
|
||||||
|
}
|
||||||
|
if (!WC()->session->has_session()) {
|
||||||
|
WC()->session->set_customer_session_cookie(true);
|
||||||
|
}
|
||||||
|
|
||||||
// Update quantity
|
// Update quantity
|
||||||
$updated = WC()->cart->set_quantity($cart_item_key, $quantity);
|
$updated = WC()->cart->set_quantity($cart_item_key, $quantity);
|
||||||
|
|
||||||
if (!$updated) {
|
if (!$updated) {
|
||||||
return new WP_Error('update_failed', 'Failed to update cart item', ['status' => 400]);
|
return new WP_Error('update_failed', 'Failed to update cart item', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'message' => 'Cart updated',
|
'message' => 'Cart updated',
|
||||||
'cart' => self::format_cart(),
|
'cart' => self::format_cart(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove item from cart
|
* Remove item from cart
|
||||||
*/
|
*/
|
||||||
public static function remove_from_cart(WP_REST_Request $request) {
|
public static function remove_from_cart(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$cart_item_key = $request->get_param('cart_item_key');
|
$cart_item_key = $request->get_param('cart_item_key');
|
||||||
|
|
||||||
if (!WC()->cart) {
|
// Initialize WooCommerce session and cart for REST API requests
|
||||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
if (!WC()->session) {
|
||||||
|
WC()->initialize_session();
|
||||||
}
|
}
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->initialize_cart();
|
||||||
|
}
|
||||||
|
if (!WC()->session->has_session()) {
|
||||||
|
WC()->session->set_customer_session_cookie(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item exists in cart
|
||||||
|
$cart_contents = WC()->cart->get_cart();
|
||||||
|
if (!isset($cart_contents[$cart_item_key])) {
|
||||||
|
return new WP_Error('item_not_found', "Cart item not found", ['status' => 404]);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove item
|
// Remove item
|
||||||
$removed = WC()->cart->remove_cart_item($cart_item_key);
|
$removed = WC()->cart->remove_cart_item($cart_item_key);
|
||||||
|
|
||||||
if (!$removed) {
|
if (!$removed) {
|
||||||
return new WP_Error('remove_failed', 'Failed to remove cart item', ['status' => 400]);
|
return new WP_Error('remove_failed', 'Failed to remove cart item', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'message' => 'Item removed from cart',
|
'message' => 'Item removed from cart',
|
||||||
'cart' => self::format_cart(),
|
'cart' => self::format_cart(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear entire cart
|
||||||
|
*/
|
||||||
|
public static function clear_cart(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
// Initialize WooCommerce session and cart for REST API requests
|
||||||
|
if (!WC()->session) {
|
||||||
|
WC()->initialize_session();
|
||||||
|
}
|
||||||
|
if (!WC()->cart) {
|
||||||
|
WC()->initialize_cart();
|
||||||
|
}
|
||||||
|
if (!WC()->session->has_session()) {
|
||||||
|
WC()->session->set_customer_session_cookie(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty the cart
|
||||||
|
WC()->cart->empty_cart();
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'message' => 'Cart cleared',
|
||||||
|
'cart' => self::format_cart(),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply coupon to cart
|
* Apply coupon to cart
|
||||||
*/
|
*/
|
||||||
public static function apply_coupon(WP_REST_Request $request) {
|
public static function apply_coupon(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$coupon_code = $request->get_param('coupon_code');
|
$coupon_code = $request->get_param('coupon_code');
|
||||||
|
|
||||||
if (!WC()->cart) {
|
if (!WC()->cart) {
|
||||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply coupon
|
// Apply coupon
|
||||||
$applied = WC()->cart->apply_coupon($coupon_code);
|
$applied = WC()->cart->apply_coupon($coupon_code);
|
||||||
|
|
||||||
if (!$applied) {
|
if (!$applied) {
|
||||||
return new WP_Error('coupon_failed', 'Failed to apply coupon', ['status' => 400]);
|
return new WP_Error('coupon_failed', 'Failed to apply coupon', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'message' => 'Coupon applied',
|
'message' => 'Coupon applied',
|
||||||
'cart' => self::format_cart(),
|
'cart' => self::format_cart(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove coupon from cart
|
* Remove coupon from cart
|
||||||
*/
|
*/
|
||||||
public static function remove_coupon(WP_REST_Request $request) {
|
public static function remove_coupon(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$coupon_code = $request->get_param('coupon_code');
|
$coupon_code = $request->get_param('coupon_code');
|
||||||
|
|
||||||
if (!WC()->cart) {
|
if (!WC()->cart) {
|
||||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove coupon
|
// Remove coupon
|
||||||
$removed = WC()->cart->remove_coupon($coupon_code);
|
$removed = WC()->cart->remove_coupon($coupon_code);
|
||||||
|
|
||||||
if (!$removed) {
|
if (!$removed) {
|
||||||
return new WP_Error('remove_coupon_failed', 'Failed to remove coupon', ['status' => 400]);
|
return new WP_Error('remove_coupon_failed', 'Failed to remove coupon', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'message' => 'Coupon removed',
|
'message' => 'Coupon removed',
|
||||||
'cart' => self::format_cart(),
|
'cart' => self::format_cart(),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format cart data for API response
|
* Format cart data for API response
|
||||||
*/
|
*/
|
||||||
private static function format_cart() {
|
private static function format_cart()
|
||||||
|
{
|
||||||
$cart = WC()->cart;
|
$cart = WC()->cart;
|
||||||
|
|
||||||
if (!$cart) {
|
if (!$cart) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
||||||
$product = $cart_item['data'];
|
$product = $cart_item['data'];
|
||||||
|
|
||||||
|
// Format variation attributes with clean names (Size instead of attribute_size)
|
||||||
|
$formatted_attributes = [];
|
||||||
|
if (!empty($cart_item['variation'])) {
|
||||||
|
foreach ($cart_item['variation'] as $attr_key => $attr_value) {
|
||||||
|
// Remove 'attribute_' prefix and capitalize
|
||||||
|
$clean_key = str_replace('attribute_', '', $attr_key);
|
||||||
|
$clean_key = ucfirst($clean_key);
|
||||||
|
// Capitalize value
|
||||||
|
$formatted_attributes[$clean_key] = ucfirst($attr_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'key' => $cart_item_key,
|
'key' => $cart_item_key,
|
||||||
'product_id' => $cart_item['product_id'],
|
'product_id' => $cart_item['product_id'],
|
||||||
'variation_id' => $cart_item['variation_id'] ?? 0,
|
'variation_id' => $cart_item['variation_id'] ?? 0,
|
||||||
'quantity' => $cart_item['quantity'],
|
'quantity' => $cart_item['quantity'],
|
||||||
'name' => $product->get_name(),
|
'name' => $product->get_name(),
|
||||||
'price' => $product->get_price(),
|
'price' => $product->get_price(),
|
||||||
'subtotal' => $cart_item['line_subtotal'],
|
'subtotal' => $cart_item['line_subtotal'],
|
||||||
'total' => $cart_item['line_total'],
|
'total' => $cart_item['line_total'],
|
||||||
'image' => wp_get_attachment_url($product->get_image_id()),
|
'image' => wp_get_attachment_url($product->get_image_id()),
|
||||||
'permalink' => get_permalink($cart_item['product_id']),
|
'permalink' => get_permalink($cart_item['product_id']),
|
||||||
'attributes' => $cart_item['variation'] ?? [],
|
'attributes' => $formatted_attributes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get applied coupons
|
// Get applied coupons
|
||||||
$coupons = [];
|
$coupons = [];
|
||||||
foreach ($cart->get_applied_coupons() as $coupon_code) {
|
foreach ($cart->get_applied_coupons() as $coupon_code) {
|
||||||
$coupon = new \WC_Coupon($coupon_code);
|
$coupon = new \WC_Coupon($coupon_code);
|
||||||
$coupons[] = [
|
$coupons[] = [
|
||||||
'code' => $coupon_code,
|
'code' => $coupon_code,
|
||||||
'discount' => $cart->get_coupon_discount_amount($coupon_code),
|
'discount' => $cart->get_coupon_discount_amount($coupon_code),
|
||||||
'type' => $coupon->get_discount_type(),
|
'type' => $coupon->get_discount_type(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'subtotal' => $cart->get_subtotal(),
|
'subtotal' => $cart->get_subtotal(),
|
||||||
'subtotal_tax' => $cart->get_subtotal_tax(),
|
'subtotal_tax' => $cart->get_subtotal_tax(),
|
||||||
'discount_total' => $cart->get_discount_total(),
|
'discount_total' => $cart->get_discount_total(),
|
||||||
'discount_tax' => $cart->get_discount_tax(),
|
'discount_tax' => $cart->get_discount_tax(),
|
||||||
'shipping_total' => $cart->get_shipping_total(),
|
'shipping_total' => $cart->get_shipping_total(),
|
||||||
'shipping_tax' => $cart->get_shipping_tax(),
|
'shipping_tax' => $cart->get_shipping_tax(),
|
||||||
'cart_contents_tax' => $cart->get_cart_contents_tax(),
|
'cart_contents_tax' => $cart->get_cart_contents_tax(),
|
||||||
'fee_total' => $cart->get_fee_total(),
|
'fee_total' => $cart->get_fee_total(),
|
||||||
'fee_tax' => $cart->get_fee_tax(),
|
'fee_tax' => $cart->get_fee_tax(),
|
||||||
'total' => $cart->get_total('edit'),
|
'total' => $cart->get_total('edit'),
|
||||||
'total_tax' => $cart->get_total_tax(),
|
'total_tax' => $cart->get_total_tax(),
|
||||||
'coupons' => $coupons,
|
'coupons' => $coupons,
|
||||||
'needs_shipping' => $cart->needs_shipping(),
|
'needs_shipping' => $cart->needs_shipping(),
|
||||||
'needs_payment' => $cart->needs_payment(),
|
'needs_payment' => $cart->needs_payment(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,85 +5,227 @@ namespace WooNooW\Frontend;
|
|||||||
* Template Override
|
* Template Override
|
||||||
* Overrides WooCommerce templates to use WooNooW SPA
|
* Overrides WooCommerce templates to use WooNooW SPA
|
||||||
*/
|
*/
|
||||||
class TemplateOverride {
|
class TemplateOverride
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
|
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||||
|
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||||
|
|
||||||
|
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
|
||||||
|
// This ensures we process add-to-cart before WooCommerce does
|
||||||
|
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
|
||||||
|
|
||||||
// Use blank template for full-page SPA
|
// Use blank template for full-page SPA
|
||||||
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
|
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
|
||||||
|
|
||||||
// Disable canonical redirects for SPA routes
|
// Disable canonical redirects for SPA routes
|
||||||
add_filter('redirect_canonical', [__CLASS__, 'disable_canonical_redirect'], 10, 2);
|
add_filter('redirect_canonical', [__CLASS__, 'disable_canonical_redirect'], 10, 2);
|
||||||
|
|
||||||
// Override WooCommerce shop page
|
// Override WooCommerce shop page
|
||||||
add_filter('woocommerce_show_page_title', '__return_false');
|
add_filter('woocommerce_show_page_title', '__return_false');
|
||||||
|
|
||||||
// Replace WooCommerce content with our SPA
|
// Replace WooCommerce content with our SPA
|
||||||
add_action('woocommerce_before_main_content', [__CLASS__, 'start_spa_wrapper'], 5);
|
add_action('woocommerce_before_main_content', [__CLASS__, 'start_spa_wrapper'], 5);
|
||||||
add_action('woocommerce_after_main_content', [__CLASS__, 'end_spa_wrapper'], 999);
|
add_action('woocommerce_after_main_content', [__CLASS__, 'end_spa_wrapper'], 999);
|
||||||
|
|
||||||
// Remove WooCommerce default content
|
// Remove WooCommerce default content
|
||||||
remove_action('woocommerce_before_shop_loop', 'woocommerce_result_count', 20);
|
remove_action('woocommerce_before_shop_loop', 'woocommerce_result_count', 20);
|
||||||
remove_action('woocommerce_before_shop_loop', 'woocommerce_catalog_ordering', 30);
|
remove_action('woocommerce_before_shop_loop', 'woocommerce_catalog_ordering', 30);
|
||||||
remove_action('woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10);
|
remove_action('woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10);
|
||||||
remove_action('woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10);
|
remove_action('woocommerce_after_main_content', 'woocommerce_output_content_wrapper_end', 10);
|
||||||
|
|
||||||
// Override single product template
|
// Override single product template
|
||||||
add_filter('woocommerce_locate_template', [__CLASS__, 'override_template'], 10, 3);
|
add_filter('woocommerce_locate_template', [__CLASS__, 'override_template'], 10, 3);
|
||||||
|
|
||||||
|
// Remove theme header and footer when SPA is active
|
||||||
|
add_action('get_header', [__CLASS__, 'remove_theme_header']);
|
||||||
|
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
|
||||||
|
* Let WooCommerce handle the cart operation properly, we just redirect afterward
|
||||||
|
*
|
||||||
|
* This is the proper approach - WooCommerce manages sessions correctly,
|
||||||
|
* we just customize where the redirect goes.
|
||||||
|
*/
|
||||||
|
public static function intercept_add_to_cart()
|
||||||
|
{
|
||||||
|
// Only act if add-to-cart is present
|
||||||
|
if (!isset($_GET['add-to-cart'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SPA page from appearance settings
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
||||||
|
|
||||||
|
if (!$spa_page_id) {
|
||||||
|
return; // No SPA page configured, let WooCommerce handle everything
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook into WooCommerce's redirect filter AFTER it adds to cart
|
||||||
|
// This is the proper way to customize the redirect destination
|
||||||
|
add_filter('woocommerce_add_to_cart_redirect', function ($url) use ($spa_page_id) {
|
||||||
|
// Get redirect parameter from original request
|
||||||
|
$redirect_to = isset($_GET['redirect']) ? sanitize_text_field($_GET['redirect']) : 'cart';
|
||||||
|
|
||||||
|
// Build redirect URL with hash route for SPA
|
||||||
|
$redirect_url = get_permalink($spa_page_id);
|
||||||
|
|
||||||
|
// Determine hash route based on redirect parameter
|
||||||
|
$hash_route = '/cart'; // Default
|
||||||
|
if ($redirect_to === 'checkout') {
|
||||||
|
$hash_route = '/checkout';
|
||||||
|
} elseif ($redirect_to === 'shop') {
|
||||||
|
$hash_route = '/shop';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the SPA URL with hash route
|
||||||
|
return trailingslashit($redirect_url) . '#' . $hash_route;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
// Prevent caching
|
||||||
|
add_action('template_redirect', function () {
|
||||||
|
nocache_headers();
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect WooCommerce pages to SPA routes
|
||||||
|
* Maps: /shop → /store/#/, /cart → /store/#/cart, etc.
|
||||||
|
*/
|
||||||
|
public static function redirect_wc_pages_to_spa()
|
||||||
|
{
|
||||||
|
// Get SPA page URL
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
|
||||||
|
if (!$spa_page_id) {
|
||||||
|
return; // No SPA page configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already on SPA page, don't redirect
|
||||||
|
global $post;
|
||||||
|
if ($post && $post->ID == $spa_page_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
||||||
|
|
||||||
|
// Check which WC page we're on and redirect
|
||||||
|
if (is_shop()) {
|
||||||
|
wp_redirect($spa_url . '#/', 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_product()) {
|
||||||
|
global $product;
|
||||||
|
if ($product) {
|
||||||
|
$slug = $product->get_slug();
|
||||||
|
wp_redirect($spa_url . '#/products/' . $slug, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_cart()) {
|
||||||
|
wp_redirect($spa_url . '#/cart', 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_checkout() && !is_order_received_page()) {
|
||||||
|
wp_redirect($spa_url . '#/checkout', 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_account_page()) {
|
||||||
|
wp_redirect($spa_url . '#/account', 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable canonical redirects for SPA routes
|
* Disable canonical redirects for SPA routes
|
||||||
* This prevents WordPress from redirecting /product/slug URLs
|
* This prevents WordPress from redirecting /product/slug URLs
|
||||||
*/
|
*/
|
||||||
public static function disable_canonical_redirect($redirect_url, $requested_url) {
|
public static function disable_canonical_redirect($redirect_url, $requested_url)
|
||||||
|
{
|
||||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||||
|
|
||||||
// Only disable redirects in full SPA mode
|
// Only disable redirects in full SPA mode
|
||||||
if ($mode !== 'full') {
|
if ($mode !== 'full') {
|
||||||
return $redirect_url;
|
return $redirect_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a SPA route
|
// Check if this is a SPA route
|
||||||
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
$spa_routes = ['/product/', '/cart', '/checkout', '/my-account'];
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
foreach ($spa_routes as $route) {
|
||||||
if (strpos($requested_url, $route) !== false) {
|
if (strpos($requested_url, $route) !== false) {
|
||||||
// This is a SPA route, disable WordPress redirect
|
// This is a SPA route, disable WordPress redirect
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $redirect_url;
|
return $redirect_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use SPA template (blank page)
|
* Use SPA template (blank page)
|
||||||
*/
|
*/
|
||||||
public static function use_spa_template($template) {
|
public static function use_spa_template($template)
|
||||||
|
{
|
||||||
|
// Check if current page is a designated SPA page
|
||||||
|
if (self::is_spa_page()) {
|
||||||
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
|
if (file_exists($spa_template)) {
|
||||||
|
return $spa_template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: Check SPA mode settings
|
||||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||||
|
|
||||||
// Mode 1: Disabled
|
// Mode 1: Disabled - but still check for shortcodes (legacy)
|
||||||
if ($mode === 'disabled') {
|
if ($mode === 'disabled') {
|
||||||
|
// Check if page has woonoow shortcodes
|
||||||
|
global $post;
|
||||||
|
if (
|
||||||
|
$post && (
|
||||||
|
has_shortcode($post->post_content, 'woonoow_shop') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_cart') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_checkout') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_account')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Use blank template for shortcode pages too
|
||||||
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
|
if (file_exists($spa_template)) {
|
||||||
|
return $spa_template;
|
||||||
|
}
|
||||||
|
}
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if current URL is a SPA route (for direct access)
|
// Check if current URL is a SPA route (for direct access)
|
||||||
$request_uri = $_SERVER['REQUEST_URI'];
|
$request_uri = $_SERVER['REQUEST_URI'];
|
||||||
$spa_routes = ['/shop', '/product/', '/cart', '/checkout', '/my-account'];
|
$spa_routes = ['/shop', '/product/', '/cart', '/checkout', '/my-account'];
|
||||||
$is_spa_route = false;
|
$is_spa_route = false;
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
foreach ($spa_routes as $route) {
|
||||||
if (strpos($request_uri, $route) !== false) {
|
if (strpos($request_uri, $route) !== false) {
|
||||||
$is_spa_route = true;
|
$is_spa_route = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a SPA route in full mode, use SPA template
|
// If it's a SPA route in full mode, use SPA template
|
||||||
if ($mode === 'full' && $is_spa_route) {
|
if ($mode === 'full' && $is_spa_route) {
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
@@ -93,18 +235,18 @@ class TemplateOverride {
|
|||||||
return $spa_template;
|
return $spa_template;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 3: Checkout-Only (partial SPA)
|
// Mode 3: Checkout-Only (partial SPA)
|
||||||
if ($mode === 'checkout_only') {
|
if ($mode === 'checkout_only') {
|
||||||
$checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [
|
$checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [
|
||||||
'checkout' => true,
|
'checkout' => true,
|
||||||
'thankyou' => true,
|
'thankyou' => true,
|
||||||
'account' => true,
|
'account' => true,
|
||||||
'cart' => false,
|
'cart' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
$should_override = false;
|
$should_override = false;
|
||||||
|
|
||||||
if (!empty($checkout_pages['checkout']) && is_checkout() && !is_order_received_page()) {
|
if (!empty($checkout_pages['checkout']) && is_checkout() && !is_order_received_page()) {
|
||||||
$should_override = true;
|
$should_override = true;
|
||||||
}
|
}
|
||||||
@@ -117,17 +259,17 @@ class TemplateOverride {
|
|||||||
if (!empty($checkout_pages['cart']) && is_cart()) {
|
if (!empty($checkout_pages['cart']) && is_cart()) {
|
||||||
$should_override = true;
|
$should_override = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($should_override) {
|
if ($should_override) {
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
if (file_exists($spa_template)) {
|
if (file_exists($spa_template)) {
|
||||||
return $spa_template;
|
return $spa_template;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 2: Full SPA
|
// Mode 2: Full SPA
|
||||||
if ($mode === 'full') {
|
if ($mode === 'full') {
|
||||||
// Override all WooCommerce pages
|
// Override all WooCommerce pages
|
||||||
@@ -138,23 +280,24 @@ class TemplateOverride {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SPA wrapper
|
* Start SPA wrapper
|
||||||
*/
|
*/
|
||||||
public static function start_spa_wrapper() {
|
public static function start_spa_wrapper()
|
||||||
|
{
|
||||||
// Check if we should use SPA
|
// Check if we should use SPA
|
||||||
if (!self::should_use_spa()) {
|
if (!self::should_use_spa()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine page type
|
// Determine page type
|
||||||
$page_type = 'shop';
|
$page_type = 'shop';
|
||||||
$data_attrs = 'data-page="shop"';
|
$data_attrs = 'data-page="shop"';
|
||||||
|
|
||||||
if (is_product()) {
|
if (is_product()) {
|
||||||
$page_type = 'product';
|
$page_type = 'product';
|
||||||
global $post;
|
global $post;
|
||||||
@@ -169,63 +312,163 @@ class TemplateOverride {
|
|||||||
$page_type = 'account';
|
$page_type = 'account';
|
||||||
$data_attrs = 'data-page="account"';
|
$data_attrs = 'data-page="account"';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output SPA mount point
|
// Output SPA mount point
|
||||||
echo '<div id="woonoow-customer-app" ' . $data_attrs . '>';
|
echo '<div id="woonoow-customer-app" ' . $data_attrs . '>';
|
||||||
echo '<div class="woonoow-loading">';
|
echo '<div class="woonoow-loading">';
|
||||||
echo '<p>' . esc_html__('Loading...', 'woonoow') . '</p>';
|
echo '<p>' . esc_html__('Loading...', 'woonoow') . '</p>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
|
|
||||||
// Hide WooCommerce content
|
// Hide WooCommerce content
|
||||||
echo '<div style="display: none;">';
|
echo '<div style="display: none;">';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End SPA wrapper
|
* End SPA wrapper
|
||||||
*/
|
*/
|
||||||
public static function end_spa_wrapper() {
|
public static function end_spa_wrapper()
|
||||||
|
{
|
||||||
if (!self::should_use_spa()) {
|
if (!self::should_use_spa()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close hidden wrapper
|
// Close hidden wrapper
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we should use SPA
|
* Check if we should use SPA
|
||||||
*/
|
*/
|
||||||
private static function should_use_spa() {
|
private static function should_use_spa()
|
||||||
|
{
|
||||||
// Check if frontend mode is enabled
|
// Check if frontend mode is enabled
|
||||||
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
||||||
|
|
||||||
if ($mode === 'disabled') {
|
if ($mode === 'disabled') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For full SPA mode, always use SPA
|
// For full SPA mode, always use SPA
|
||||||
if ($mode === 'full_spa') {
|
if ($mode === 'full_spa') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For shortcode mode, check if we're on WooCommerce pages
|
// For shortcode mode, check if we're on WooCommerce pages
|
||||||
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove theme header when SPA is active
|
||||||
|
*/
|
||||||
|
public static function remove_theme_header()
|
||||||
|
{
|
||||||
|
if (self::should_remove_theme_elements()) {
|
||||||
|
remove_all_actions('wp_head');
|
||||||
|
// Re-add essential WordPress head actions
|
||||||
|
add_action('wp_head', 'wp_enqueue_scripts', 1);
|
||||||
|
add_action('wp_head', 'wp_print_styles', 8);
|
||||||
|
add_action('wp_head', 'wp_print_head_scripts', 9);
|
||||||
|
add_action('wp_head', 'wp_resource_hints', 2);
|
||||||
|
add_action('wp_head', 'wp_site_icon', 99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove theme footer when SPA is active
|
||||||
|
*/
|
||||||
|
public static function remove_theme_footer()
|
||||||
|
{
|
||||||
|
if (self::should_remove_theme_elements()) {
|
||||||
|
remove_all_actions('wp_footer');
|
||||||
|
// Re-add essential WordPress footer actions
|
||||||
|
add_action('wp_footer', 'wp_print_footer_scripts', 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current page is the designated SPA page
|
||||||
|
*/
|
||||||
|
private static function is_spa_page()
|
||||||
|
{
|
||||||
|
global $post;
|
||||||
|
if (!$post) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SPA page ID from appearance settings
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
||||||
|
|
||||||
|
// Check if current page matches the SPA page
|
||||||
|
if ($spa_page_id && $post->ID == $spa_page_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we should remove theme header/footer
|
||||||
|
*/
|
||||||
|
private static function should_remove_theme_elements()
|
||||||
|
{
|
||||||
|
// Remove for designated SPA pages
|
||||||
|
if (self::is_spa_page()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
|
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||||
|
|
||||||
|
// Check if we're on a WooCommerce page in full mode
|
||||||
|
if ($mode === 'full') {
|
||||||
|
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove for pages with shortcodes (even in disabled mode)
|
||||||
|
global $post;
|
||||||
|
if (
|
||||||
|
$post && (
|
||||||
|
has_shortcode($post->post_content, 'woonoow_shop') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_cart') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_checkout') ||
|
||||||
|
has_shortcode($post->post_content, 'woonoow_account')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special check for Shop page (archive)
|
||||||
|
if (function_exists('is_shop') && is_shop()) {
|
||||||
|
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||||
|
if ($shop_page_id) {
|
||||||
|
$shop_page = get_post($shop_page_id);
|
||||||
|
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override WooCommerce templates
|
* Override WooCommerce templates
|
||||||
*/
|
*/
|
||||||
public static function override_template($template, $template_name, $template_path) {
|
public static function override_template($template, $template_name, $template_path)
|
||||||
|
{
|
||||||
// Only override if SPA is enabled
|
// Only override if SPA is enabled
|
||||||
if (!self::should_use_spa()) {
|
if (!self::should_use_spa()) {
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Templates to override
|
// Templates to override
|
||||||
$override_templates = [
|
$override_templates = [
|
||||||
'archive-product.php',
|
'archive-product.php',
|
||||||
@@ -233,7 +476,7 @@ class TemplateOverride {
|
|||||||
'cart/cart.php',
|
'cart/cart.php',
|
||||||
'checkout/form-checkout.php',
|
'checkout/form-checkout.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if this template should be overridden
|
// Check if this template should be overridden
|
||||||
foreach ($override_templates as $override) {
|
foreach ($override_templates as $override) {
|
||||||
if (strpos($template_name, $override) !== false) {
|
if (strpos($template_name, $override) !== false) {
|
||||||
@@ -241,7 +484,7 @@ class TemplateOverride {
|
|||||||
return plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-wrapper.php';
|
return plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-wrapper.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,25 @@ class NewsletterSettings {
|
|||||||
'placeholder' => __('I agree to receive marketing emails', 'woonoow'),
|
'placeholder' => __('I agree to receive marketing emails', 'woonoow'),
|
||||||
'default' => __('I agree to receive marketing emails and understand I can unsubscribe at any time.', 'woonoow'),
|
'default' => __('I agree to receive marketing emails and understand I can unsubscribe at any time.', 'woonoow'),
|
||||||
],
|
],
|
||||||
|
// Campaign Settings
|
||||||
|
'campaign_scheduling' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Campaign Scheduling', 'woonoow'),
|
||||||
|
'description' => __('Enable scheduled campaigns. When on, you can schedule campaigns to be sent at a specific date and time.', 'woonoow'),
|
||||||
|
'default' => false,
|
||||||
|
],
|
||||||
|
'subscriber_limit_enabled' => [
|
||||||
|
'type' => 'toggle',
|
||||||
|
'label' => __('Subscriber Limit', 'woonoow'),
|
||||||
|
'description' => __('Limit subscribers to 1000. When disabled, a custom database table will be created for unlimited subscribers.', 'woonoow'),
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'subscriber_limit' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'label' => __('Max Subscribers', 'woonoow'),
|
||||||
|
'description' => __('Maximum number of subscribers when limit is enabled (default: 1000)', 'woonoow'),
|
||||||
|
'default' => 1000,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $schemas;
|
return $schemas;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cd admin-spa && npm run dev",
|
"dev": "cd admin-spa && npm run dev",
|
||||||
"dev:admin": "cd admin-spa && npm run dev",
|
"dev:admin": "cd admin-spa && npm run dev",
|
||||||
"build:admin": "cd admin-spa && npm i && npm run build && mkdir -p ../admin-spa/dist && cp -r admin-spa/dist/* plugin/admin-spa/dist/ 2>/dev/null || true",
|
"dev:customer": "cd customer-spa && npm run dev",
|
||||||
"build:customer": "echo \"(todo) customer-spa build\"",
|
"build:admin": "cd admin-spa && npm install && npm run build",
|
||||||
|
"build:customer": "cd customer-spa && npm install && npm run build",
|
||||||
"build": "npm run build:admin && npm run build:customer",
|
"build": "npm run build:admin && npm run build:customer",
|
||||||
"pack": "node scripts/package-zip.mjs"
|
"pack": "node scripts/package-zip.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-tooltip": "^1.2.8"
|
"@radix-ui/react-tooltip": "^1.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,23 +8,19 @@
|
|||||||
</head>
|
</head>
|
||||||
<body <?php body_class('woonoow-spa-page'); ?>>
|
<body <?php body_class('woonoow-spa-page'); ?>>
|
||||||
<?php
|
<?php
|
||||||
// Determine page type and data attributes
|
// Determine initial route based on SPA mode
|
||||||
$page_type = 'shop';
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$data_attrs = 'data-page="shop"';
|
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
|
||||||
|
|
||||||
if (is_product()) {
|
// Set initial page based on mode
|
||||||
$page_type = 'product';
|
if ($spa_mode === 'checkout_only') {
|
||||||
global $post;
|
// Checkout Only mode starts at cart
|
||||||
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
|
||||||
} elseif (is_cart()) {
|
|
||||||
$page_type = 'cart';
|
$page_type = 'cart';
|
||||||
$data_attrs = 'data-page="cart"';
|
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
|
||||||
} elseif (is_checkout()) {
|
} else {
|
||||||
$page_type = 'checkout';
|
// Full SPA mode starts at shop
|
||||||
$data_attrs = 'data-page="checkout"';
|
$page_type = 'shop';
|
||||||
} elseif (is_account_page()) {
|
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
||||||
$page_type = 'account';
|
|
||||||
$data_attrs = 'data-page="account"';
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user