Compare commits
66 Commits
8093938e8b
...
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 | ||
|
|
0609c6e3d8 | ||
|
|
a5e5db827b | ||
|
|
447ca501c7 | ||
|
|
f1bab5ec46 | ||
|
|
8762c7d2c9 |
@@ -1,7 +1,7 @@
|
||||
# WooNooW Feature Roadmap - 2025
|
||||
|
||||
**Last Updated**: December 26, 2025
|
||||
**Status**: Planning Phase
|
||||
**Last Updated**: December 31, 2025
|
||||
**Status**: Active Development
|
||||
|
||||
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
|
||||
- ✅ Coupon System
|
||||
- ✅ Customer Wishlist (basic)
|
||||
- ✅ Product Reviews & Ratings
|
||||
- ✅ Module Management System (enable/disable features)
|
||||
- ✅ Admin SPA with modern UI
|
||||
- ✅ Customer SPA with theme system
|
||||
- ✅ REST API infrastructure
|
||||
- ✅ 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
|
||||
Central control panel for enabling/disabling features to improve performance and reduce clutter.
|
||||
|
||||
### Status: **Planning** 🔵
|
||||
### Status: **Built** ✅
|
||||
|
||||
### Implementation
|
||||
|
||||
@@ -94,8 +95,8 @@ class ModuleRegistry {
|
||||
#### Navigation Integration
|
||||
Only show module routes if enabled in navigation tree.
|
||||
|
||||
### Priority: **High** 🔴
|
||||
### Effort: 1 week
|
||||
### Priority: ~~High~~ **Complete** ✅
|
||||
### Effort: ~~1 week~~ Done
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
||||
import { Login } from './routes/Login';
|
||||
import ResetPassword from './routes/ResetPassword';
|
||||
import Dashboard from '@/routes/Dashboard';
|
||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||
@@ -257,6 +258,8 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
@@ -499,6 +502,7 @@ function AppRoutes() {
|
||||
<Routes>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
||||
@@ -576,6 +580,8 @@ function AppRoutes() {
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
|
||||
@@ -101,11 +101,13 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
};
|
||||
|
||||
const openEditDialog = (block: EmailBlock) => {
|
||||
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
||||
setEditingBlockId(block.id);
|
||||
|
||||
if (block.type === 'card') {
|
||||
// Convert markdown to HTML for rich text editor
|
||||
const htmlContent = parseMarkdownBasics(block.content);
|
||||
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
|
||||
setEditingContent(htmlContent);
|
||||
setEditingCardType(block.cardType);
|
||||
} else if (block.type === 'button') {
|
||||
@@ -122,6 +124,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
setEditingAlign(block.align);
|
||||
}
|
||||
|
||||
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -270,28 +273,22 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-2xl"
|
||||
className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
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');
|
||||
|
||||
if (wpMediaOpen) {
|
||||
// If WP media is open, ALWAYS prevent dialog from closing
|
||||
// regardless of where the click happened
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// If WP media is not open, prevent closing dialog for outside clicks
|
||||
e.preventDefault();
|
||||
// Otherwise, allow the dialog to close normally via outside click
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Allow escape to close WP media modal
|
||||
// Only prevent escape if WP media modal is open
|
||||
const wpMediaOpen = document.querySelector('.media-modal');
|
||||
if (wpMediaOpen) {
|
||||
return;
|
||||
e.preventDefault();
|
||||
}
|
||||
e.preventDefault();
|
||||
// Otherwise, allow escape to close dialog
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
@@ -305,7 +302,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 px-6 py-4">
|
||||
{editingBlock?.type === 'card' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -359,7 +356,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
/>
|
||||
{variables.length > 0 && (
|
||||
<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
|
||||
key={variable}
|
||||
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
|
||||
|
||||
@@ -320,7 +320,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
|
||||
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\]/);
|
||||
if (cardMatch) {
|
||||
const attributes = cardMatch[1].trim();
|
||||
@@ -347,7 +364,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
|
||||
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\]/);
|
||||
if (buttonMatch) {
|
||||
blocks.push({
|
||||
|
||||
@@ -30,25 +30,43 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.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}
|
||||
>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-dialog-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-dialog-portal';
|
||||
appContainer.appendChild(portalRoot);
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPortal container={getPortalContainer()}>
|
||||
<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
|
||||
|
||||
const DialogHeader = ({
|
||||
@@ -57,7 +75,7 @@ const DialogHeader = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +89,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -106,6 +124,20 @@ const DialogDescription = React.forwardRef<
|
||||
))
|
||||
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 {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
@@ -117,4 +149,5 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogBody,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Label } from './label';
|
||||
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';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
@@ -45,7 +45,8 @@ export function RichTextEditor({
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
// StarterKit 3.10+ includes Link by default, disable since we configure separately
|
||||
StarterKit.configure({ link: false }),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
@@ -75,14 +76,6 @@ export function RichTextEditor({
|
||||
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',
|
||||
},
|
||||
handleClick: (view, pos, event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,6 +113,8 @@ export function RichTextEditor({
|
||||
const [buttonText, setButtonText] = useState('Click Here');
|
||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
||||
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||
|
||||
const addImage = () => {
|
||||
openWPMediaImage((file) => {
|
||||
@@ -135,12 +130,81 @@ export function RichTextEditor({
|
||||
setButtonText('Click Here');
|
||||
setButtonHref('{order_url}');
|
||||
setButtonStyle('solid');
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
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 = () => {
|
||||
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);
|
||||
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 = () => {
|
||||
@@ -292,97 +356,174 @@ export function RichTextEditor({
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="overflow-y-auto max-h-[400px] min-h-[200px]">
|
||||
<div onClick={handleEditorClick}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{/* Variables Dropdown */}
|
||||
{/* Variables - Collapsible and Categorized */}
|
||||
{variables.length > 0 && (
|
||||
<div className="border-t bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{__('Insert Variable:')}
|
||||
</Label>
|
||||
<Select onValueChange={(value) => insertVariable(value)}>
|
||||
<SelectTrigger id="variable-select" className="h-8 text-xs">
|
||||
<SelectValue placeholder={__('Choose a variable...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{variables.map((variable) => (
|
||||
<SelectItem key={variable} value={variable} className="text-xs">
|
||||
{`{${variable}}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<details className="border-t bg-muted/30">
|
||||
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
|
||||
<span className="text-[10px]">▶</span>
|
||||
{__('Insert Variable')}
|
||||
<span className="text-[10px] opacity-60">({variables.length})</span>
|
||||
</summary>
|
||||
<div className="p-3 pt-0 space-y-3">
|
||||
{/* Order Variables */}
|
||||
{variables.some(v => v.startsWith('order')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('order')).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
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>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Button Dialog */}
|
||||
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
|
||||
setButtonDialogOpen(open);
|
||||
if (!open) {
|
||||
setIsEditingButton(false);
|
||||
setEditingButtonPos(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Insert Button')}</DialogTitle>
|
||||
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="btn-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder={__('e.g., View Order')}
|
||||
/>
|
||||
</div>
|
||||
<DialogBody>
|
||||
<div className="space-y-4 !p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-text">{__('Button Text')}</Label>
|
||||
<Input
|
||||
id="btn-text"
|
||||
value={buttonText}
|
||||
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')).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-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 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>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
{isEditingButton && (
|
||||
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
|
||||
{__('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={insertButton}>
|
||||
{__('Insert Button')}
|
||||
{isEditingButton ? __('Update Button') : __('Insert Button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -69,33 +69,49 @@ SelectScrollDownButton.displayName =
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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" &&
|
||||
"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}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
>(({ className, children, position = "popper", ...props }, ref) => {
|
||||
// Get or create portal container inside the app for proper CSS scoping
|
||||
const getPortalContainer = () => {
|
||||
const appContainer = document.getElementById('woonoow-admin-app');
|
||||
if (!appContainer) return document.body;
|
||||
|
||||
let portalRoot = document.getElementById('woonoow-select-portal');
|
||||
if (!portalRoot) {
|
||||
portalRoot = document.createElement('div');
|
||||
portalRoot.id = 'woonoow-select-portal';
|
||||
appContainer.appendChild(portalRoot);
|
||||
}
|
||||
return portalRoot;
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal container={getPortalContainer()}>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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" &&
|
||||
"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}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
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
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
|
||||
@@ -37,54 +37,50 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
|
||||
parseHTML() {
|
||||
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',
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
style: 'solid',
|
||||
}),
|
||||
},
|
||||
{
|
||||
tag: 'a.button-outline',
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
text: node.textContent || 'Click Here',
|
||||
href: node.getAttribute('href') || '#',
|
||||
style: 'outline',
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ 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 [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, {
|
||||
href,
|
||||
class: className,
|
||||
style: Object.entries(buttonStyle)
|
||||
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
|
||||
.join('; '),
|
||||
class: 'button-node',
|
||||
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
|
||||
'data-button': '',
|
||||
'data-text': text,
|
||||
'data-href': href,
|
||||
'data-style': style,
|
||||
title: `Button: ${text} → ${href}`,
|
||||
}),
|
||||
text,
|
||||
];
|
||||
@@ -94,12 +90,12 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
return {
|
||||
setButton:
|
||||
(options) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
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-search {
|
||||
border: none !important;
|
||||
|
||||
@@ -22,7 +22,33 @@ export function htmlToMarkdown(html: string): string {
|
||||
markdown = markdown.replace(/<em>(.*?)<\/em>/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)');
|
||||
|
||||
// Lists
|
||||
|
||||
@@ -98,13 +98,13 @@ export function markdownToHtml(markdown: string): string {
|
||||
// Parse [button:style](url)Text[/button] (new syntax)
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||
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)
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
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
|
||||
@@ -151,15 +151,20 @@ export function parseMarkdownBasics(text: string): string {
|
||||
|
||||
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
||||
// 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) => {
|
||||
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)
|
||||
// Include data-button attributes for TipTap recognition
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
const buttonStyle = style || 'solid';
|
||||
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)
|
||||
@@ -267,8 +272,33 @@ export function htmlToMarkdown(html: string): string {
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
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]`;
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,17 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface WordPressPage {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function AppearanceGeneral() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||
@@ -40,11 +48,13 @@ export default function AppearanceGeneral() {
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load appearance settings
|
||||
const response = await api.get('/appearance/settings');
|
||||
const general = response.data?.general;
|
||||
|
||||
if (general) {
|
||||
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.typography) {
|
||||
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) {
|
||||
console.error('Failed to load settings:', error);
|
||||
console.error('Error details:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -76,7 +97,8 @@ export default function AppearanceGeneral() {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/general', {
|
||||
spa_mode: spaMode,
|
||||
spaMode,
|
||||
spaPage,
|
||||
toastPosition,
|
||||
typography: {
|
||||
mode: typographyMode,
|
||||
@@ -113,7 +135,7 @@ export default function AppearanceGeneral() {
|
||||
Disabled
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use WordPress default pages (no SPA functionality)
|
||||
SPA never loads (use WordPress default pages)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,7 +147,7 @@ export default function AppearanceGeneral() {
|
||||
Checkout Only
|
||||
</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,13 +159,53 @@ export default function AppearanceGeneral() {
|
||||
Full SPA
|
||||
</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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 */}
|
||||
<SettingsCard
|
||||
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() {
|
||||
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 { 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 { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { useApp } from '@/contexts/AppContext';
|
||||
@@ -16,10 +16,10 @@ interface MenuItem {
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
icon: <Tag className="w-5 h-5" />,
|
||||
label: __('Coupons'),
|
||||
description: __('Manage discount codes and promotions'),
|
||||
to: '/coupons'
|
||||
icon: <Megaphone className="w-5 h-5" />,
|
||||
label: __('Marketing'),
|
||||
description: __('Newsletter, coupons, and promotions'),
|
||||
to: '/marketing'
|
||||
},
|
||||
{
|
||||
icon: <Palette className="w-5 h-5" />,
|
||||
@@ -78,7 +78,7 @@ export default function MorePage() {
|
||||
<button
|
||||
key={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">
|
||||
{item.icon}
|
||||
@@ -102,11 +102,10 @@ export default function MorePage() {
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
|
||||
theme === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${theme === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="text-xs font-medium">{option.label}</span>
|
||||
|
||||
@@ -127,6 +127,7 @@ export default function ProductEdit() {
|
||||
onSubmit={handleSubmit}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
productId={product.id}
|
||||
/>
|
||||
|
||||
{/* 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;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
hideSubmitButton?: boolean;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function ProductFormTabbed({
|
||||
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
|
||||
className,
|
||||
formRef,
|
||||
hideSubmitButton = false,
|
||||
productId,
|
||||
}: Props) {
|
||||
// Form state
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
|
||||
variations={variations}
|
||||
setVariations={setVariations}
|
||||
regularPrice={regularPrice}
|
||||
productId={productId}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { getStoreCurrency } from '@/lib/currency';
|
||||
import { openWPMediaImage } from '@/lib/wp-media';
|
||||
@@ -30,6 +30,7 @@ type VariationsTabProps = {
|
||||
variations: ProductVariant[];
|
||||
setVariations: (value: ProductVariant[]) => void;
|
||||
regularPrice: string;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function VariationsTab({
|
||||
@@ -38,8 +39,33 @@ export function VariationsTab({
|
||||
variations,
|
||||
setVariations,
|
||||
regularPrice,
|
||||
productId,
|
||||
}: VariationsTabProps) {
|
||||
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 = () => {
|
||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||
@@ -305,6 +331,45 @@ export function VariationsTab({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</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 {
|
||||
auto_register_members: boolean;
|
||||
multiple_addresses_enabled: boolean;
|
||||
wishlist_enabled: boolean;
|
||||
vip_min_spent: number;
|
||||
vip_min_orders: number;
|
||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||
@@ -25,7 +24,6 @@ export default function CustomersSettings() {
|
||||
const [settings, setSettings] = useState<CustomerSettings>({
|
||||
auto_register_members: false,
|
||||
multiple_addresses_enabled: true,
|
||||
wishlist_enabled: true,
|
||||
vip_min_spent: 1000,
|
||||
vip_min_orders: 10,
|
||||
vip_timeframe: 'all',
|
||||
@@ -140,13 +138,7 @@ export default function CustomersSettings() {
|
||||
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>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
@@ -38,54 +38,6 @@ export default function EditTemplate() {
|
||||
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
|
||||
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
|
||||
const { data: emailSettings } = useQuery({
|
||||
queryKey: ['email-settings'],
|
||||
@@ -176,8 +128,10 @@ export default function EditTemplate() {
|
||||
setBlocks(newBlocks); // Keep blocks in sync
|
||||
};
|
||||
|
||||
// Variable keys for the rich text editor dropdown
|
||||
const variableKeys = availableVariables;
|
||||
// Variable keys for the rich text editor dropdown - from API (contextual per event)
|
||||
const variableKeys = template?.available_variables
|
||||
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
|
||||
: [];
|
||||
|
||||
// Parse [card] tags and [button] shortcodes for preview
|
||||
const parseCardsForPreview = (content: string) => {
|
||||
@@ -310,6 +264,15 @@ export default function EditTemplate() {
|
||||
store_url: '#',
|
||||
store_email: 'store@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) => {
|
||||
@@ -318,16 +281,13 @@ export default function EditTemplate() {
|
||||
});
|
||||
|
||||
// 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]) {
|
||||
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
|
||||
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
|
||||
}
|
||||
});
|
||||
|
||||
// Parse [card] tags
|
||||
previewBody = parseCardsForPreview(previewBody);
|
||||
|
||||
// Get email settings for preview
|
||||
const settings = emailSettings || {};
|
||||
const primaryColor = settings.primary_color || '#7f54b3';
|
||||
@@ -380,14 +340,13 @@ export default function EditTemplate() {
|
||||
.header { padding: 20px 16px; }
|
||||
.footer { padding: 20px 16px; }
|
||||
}
|
||||
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-success * { color: ${heroTextColor} !important; }
|
||||
.card-success { background-color: #f0fdf4; }
|
||||
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-highlight * { color: ${heroTextColor} !important; }
|
||||
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
|
||||
.card-hero * { color: ${heroTextColor} !important; }
|
||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
||||
.card-info { background-color: #f0f7ff; }
|
||||
.card-warning { background-color: #fff8e1; }
|
||||
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
|
||||
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
|
||||
@@ -492,91 +451,91 @@ export default function EditTemplate() {
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</p>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{__('Subject / Title')}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={__('Enter notification subject')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channelId === 'email'
|
||||
? __('Email subject line')
|
||||
: __('Push notification title')}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Tab */}
|
||||
{activeTab === 'preview' && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={generatePreviewHTML()}
|
||||
className="w-full min-h-[600px] overflow-hidden bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<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.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual Tab */}
|
||||
{activeTab === 'visual' && (
|
||||
<div>
|
||||
<EmailBuilder
|
||||
blocks={blocks}
|
||||
onChange={handleBlocksChange}
|
||||
variables={variableKeys}
|
||||
/>
|
||||
<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.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('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>
|
||||
{/* Markdown Tab */}
|
||||
{activeTab === 'markdown' && (
|
||||
<div className="space-y-2">
|
||||
<CodeEditor
|
||||
value={markdownContent}
|
||||
onChange={handleMarkdownChange}
|
||||
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
|
||||
supportMarkdown={true}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function TemplateEditor({
|
||||
.header { padding: 32px; text-align: center; background: #f8f8f8; }
|
||||
.card-gutter { padding: 0 16px; }
|
||||
.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 * { color: #fff !important; }
|
||||
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
|
||||
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
|
||||
.card-info { background-color: #f0f7ff; }
|
||||
.card-warning { background-color: #fff8e1; }
|
||||
h1 { font-size: 26px; 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; }
|
||||
|
||||
@@ -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} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
important: '#woonoow-admin-app',
|
||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
||||
theme: {
|
||||
container: { center: true, padding: "1rem" },
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: { app: 'src/main.tsx' },
|
||||
output: { entryFileNames: 'app.js', assetFileNames: 'app.[ext]' }
|
||||
|
||||
106
build-production.sh
Executable file
106
build-production.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# WooNooW Plugin - Production Build Script
|
||||
# This script creates a production-ready zip file of the plugin
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
PLUGIN_NAME="woonoow"
|
||||
VERSION=$(grep "Version:" woonoow.php | awk '{print $3}')
|
||||
BUILD_DIR="build"
|
||||
DIST_DIR="dist"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
ZIP_NAME="${PLUGIN_NAME}-${VERSION}-${TIMESTAMP}.zip"
|
||||
|
||||
echo "=========================================="
|
||||
echo "WooNooW Production Build"
|
||||
echo "=========================================="
|
||||
echo "Plugin: ${PLUGIN_NAME}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Timestamp: ${TIMESTAMP}"
|
||||
echo "=========================================="
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf ${BUILD_DIR}
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}
|
||||
mkdir -p ${DIST_DIR}
|
||||
|
||||
# Copy plugin files
|
||||
echo "Copying plugin files..."
|
||||
rsync -av --progress \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.git' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='build' \
|
||||
--exclude='dist' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='customer-spa' \
|
||||
--exclude='admin-spa' \
|
||||
--exclude='examples' \
|
||||
--exclude='*.sh' \
|
||||
--exclude='*.md' \
|
||||
--exclude='archive' \
|
||||
--exclude='test-*.php' \
|
||||
--exclude='check-*.php' \
|
||||
./ ${BUILD_DIR}/${PLUGIN_NAME}/
|
||||
|
||||
# Verify production builds exist in source before copying
|
||||
echo "Verifying production builds..."
|
||||
if [ ! -f "customer-spa/dist/app.js" ]; then
|
||||
echo "ERROR: Customer SPA production build not found!"
|
||||
echo "Please run: cd customer-spa && npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "admin-spa/dist/app.js" ]; then
|
||||
echo "ERROR: Admin SPA production build not found!"
|
||||
echo "Please run: cd admin-spa && npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Customer SPA build verified ($(du -h customer-spa/dist/app.js | cut -f1))"
|
||||
echo "✓ Admin SPA build verified ($(du -h admin-spa/dist/app.js | cut -f1))"
|
||||
|
||||
# Copy only essential SPA build files
|
||||
echo "Copying SPA build files..."
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist
|
||||
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist
|
||||
|
||||
# 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.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 - app.js and app.css
|
||||
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/
|
||||
|
||||
echo "✓ Copied customer-spa: app.js ($(du -h customer-spa/dist/app.js | cut -f1)), app.css ($(du -h customer-spa/dist/app.css | cut -f1))"
|
||||
echo "✓ Copied admin-spa: app.js ($(du -h admin-spa/dist/app.js | cut -f1)), app.css ($(du -h admin-spa/dist/app.css | cut -f1))"
|
||||
|
||||
# Create zip file
|
||||
echo "Creating zip file..."
|
||||
cd ${BUILD_DIR}
|
||||
zip -r ../${DIST_DIR}/${ZIP_NAME} ${PLUGIN_NAME} -q
|
||||
cd ..
|
||||
|
||||
# Calculate file size
|
||||
FILE_SIZE=$(du -h ${DIST_DIR}/${ZIP_NAME} | cut -f1)
|
||||
|
||||
echo "=========================================="
|
||||
echo "✓ Production build complete!"
|
||||
echo "=========================================="
|
||||
echo "File: ${DIST_DIR}/${ZIP_NAME}"
|
||||
echo "Size: ${FILE_SIZE}"
|
||||
echo "=========================================="
|
||||
|
||||
# Clean up build directory
|
||||
echo "Cleaning up..."
|
||||
rm -rf ${BUILD_DIR}
|
||||
|
||||
echo "Done! 🚀"
|
||||
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-dialog": "^1.1.15",
|
||||
"@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-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@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-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
||||
@@ -15,6 +15,9 @@ import Checkout from './pages/Checkout';
|
||||
import ThankYou from './pages/ThankYou';
|
||||
import Account from './pages/Account';
|
||||
import Wishlist from './pages/Wishlist';
|
||||
import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
|
||||
// Create QueryClient instance
|
||||
const queryClient = new QueryClient({
|
||||
@@ -51,6 +54,54 @@ const getAppearanceSettings = () => {
|
||||
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() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
@@ -60,28 +111,7 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<BaseLayout>
|
||||
<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>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
|
||||
{/* Toast notifications - position from settings */}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -75,29 +75,29 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
{headerSettings.elements.logo && (
|
||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||
<Link to="/shop" className="flex items-center gap-3 group">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -121,42 +121,42 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<Search className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
{/* Account */}
|
||||
{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">
|
||||
<User className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Account</span>
|
||||
</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">
|
||||
<User className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Account</span>
|
||||
</a>
|
||||
))}
|
||||
{/* Account */}
|
||||
{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">
|
||||
<User className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Account</span>
|
||||
</Link>
|
||||
) : (
|
||||
<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" />
|
||||
<span className="hidden lg:block">Account</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Wishlist */}
|
||||
{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">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Wishlist</span>
|
||||
</Link>
|
||||
)}
|
||||
{/* Wishlist */}
|
||||
{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">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="hidden lg:block">Wishlist</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{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">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden lg:block">
|
||||
Cart ({itemCount})
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{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">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden lg:block">
|
||||
Cart ({itemCount})
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||
@@ -248,10 +248,10 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<span>Account</span>
|
||||
</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" />
|
||||
<span>Login</span>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -272,57 +272,57 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
return true;
|
||||
})
|
||||
.map((section: any) => (
|
||||
<div key={section.id}>
|
||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||
<div key={section.id}>
|
||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||
|
||||
{/* Contact Section */}
|
||||
{section.type === 'contact' && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
||||
<p>Email: {footerSettings.contact_data.email}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
||||
<p>{footerSettings.contact_data.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Contact Section */}
|
||||
{section.type === 'contact' && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
{footerSettings.contact_data?.show_email && footerSettings.contact_data?.email && (
|
||||
<p>Email: {footerSettings.contact_data.email}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_phone && footerSettings.contact_data?.phone && (
|
||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||
)}
|
||||
{footerSettings.contact_data?.show_address && footerSettings.contact_data?.address && (
|
||||
<p>{footerSettings.contact_data.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Section */}
|
||||
{section.type === 'menu' && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||
</ul>
|
||||
)}
|
||||
{/* Menu Section */}
|
||||
{section.type === 'menu' && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Social Section */}
|
||||
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{footerSettings.social_links.map((link: any) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||
{link.platform}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Social Section */}
|
||||
{section.type === 'social' && footerSettings.social_links?.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{footerSettings.social_links.map((link: any) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||
{link.platform}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Newsletter Section */}
|
||||
{section.type === 'newsletter' && (
|
||||
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
||||
)}
|
||||
{/* Newsletter Section */}
|
||||
{section.type === 'newsletter' && (
|
||||
<NewsletterForm description={footerSettings.labels?.newsletter_description} />
|
||||
)}
|
||||
|
||||
{/* Custom HTML Section */}
|
||||
{section.type === 'custom' && (
|
||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Custom HTML Section */}
|
||||
{section.type === 'custom' && (
|
||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Payment Icons */}
|
||||
@@ -423,9 +423,9 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<User className="h-4 w-4" /> Account
|
||||
</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
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
|
||||
@@ -519,23 +519,23 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
|
||||
{headerSettings.elements.logo && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex justify-end">
|
||||
@@ -557,9 +557,9 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
<User className="h-4 w-4" /> Account
|
||||
</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
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{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">
|
||||
@@ -629,8 +629,8 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
*/
|
||||
function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
const isCheckoutFlow = window.location.pathname.includes('/checkout') ||
|
||||
window.location.pathname.includes('/my-account') ||
|
||||
window.location.pathname.includes('/order-received');
|
||||
window.location.pathname.includes('/my-account') ||
|
||||
window.location.pathname.includes('/order-received');
|
||||
|
||||
if (!isCheckoutFlow) {
|
||||
// For non-checkout pages, use minimal layout
|
||||
|
||||
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 { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface AccountLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -12,6 +23,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const { isEnabled } = useModules();
|
||||
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const allMenuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
@@ -27,8 +39,27 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
window.location.href = '/wp-login.php?action=logout';
|
||||
const handleLogout = async () => {
|
||||
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) => {
|
||||
@@ -38,6 +69,38 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
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
|
||||
const SidebarNav = () => (
|
||||
<aside className="bg-white rounded-lg border p-4">
|
||||
@@ -60,11 +123,10 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
|
||||
isActive(item.path)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${isActive(item.path)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
@@ -72,13 +134,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
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>
|
||||
<LogoutButton />
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
@@ -93,11 +149,10 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${
|
||||
isActive(item.path)
|
||||
? 'border-primary text-primary font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${isActive(item.path)
|
||||
? 'border-primary text-primary font-medium'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span>{item.label}</span>
|
||||
@@ -128,3 +183,4 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { AccountLayout } from './components/AccountLayout';
|
||||
import Dashboard from './Dashboard';
|
||||
@@ -12,11 +12,12 @@ import AccountDetails from './AccountDetails';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const location = useLocation();
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user?.isLoggedIn) {
|
||||
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
|
||||
return null;
|
||||
const currentPath = location.pathname;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,37 +14,96 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
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';
|
||||
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const { cart, setCart } = useCartStore();
|
||||
const { layout, elements } = useCartSettings();
|
||||
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
|
||||
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) {
|
||||
handleRemoveItem(key);
|
||||
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) => {
|
||||
removeItem(key);
|
||||
toast.success('Item removed from cart');
|
||||
const handleRemoveItem = async (key: string) => {
|
||||
setIsUpdating(true);
|
||||
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 = () => {
|
||||
clearCart();
|
||||
setShowClearDialog(false);
|
||||
toast.success('Cart cleared');
|
||||
const handleClearCart = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
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) {
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -237,13 +237,16 @@ export default function Checkout() {
|
||||
const data = (response as any).data || response;
|
||||
|
||||
if (data.ok && data.order_id) {
|
||||
// Clear cart
|
||||
cart.items.forEach(item => {
|
||||
useCartStore.getState().removeItem(item.key);
|
||||
});
|
||||
// Clear cart - use store method directly
|
||||
useCartStore.getState().clearCart();
|
||||
|
||||
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 {
|
||||
throw new Error(data.error || 'Failed to create order');
|
||||
}
|
||||
@@ -357,201 +360,16 @@ export default function Checkout() {
|
||||
|
||||
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
|
||||
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<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 className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<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 })}
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
@@ -560,66 +378,251 @@ export default function Checkout() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
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">Street Address *</label>
|
||||
<label className="block text-sm font-medium mb-2">Email Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
type="email"
|
||||
required
|
||||
value={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: 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>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Phone *</label>
|
||||
<input
|
||||
type="text"
|
||||
type="tel"
|
||||
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 })}
|
||||
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>
|
||||
<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 */}
|
||||
|
||||
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 { useParams, Link } from 'react-router-dom';
|
||||
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||
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 { formatPrice } from '@/lib/currency';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export default function ThankYou() {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderKey = searchParams.get('key');
|
||||
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||
const [order, setOrder] = useState<any>(null);
|
||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrderData = async () => {
|
||||
if (!orderId) return;
|
||||
|
||||
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);
|
||||
|
||||
// Fetch related products from first order item
|
||||
@@ -30,15 +36,16 @@ export default function ThankYou() {
|
||||
setRelatedProducts(productData.related_products.slice(0, 4));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch order data:', error);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch order data:', err);
|
||||
setError(err.message || 'Failed to load order');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrderData();
|
||||
}, [orderId]);
|
||||
}, [orderId, orderKey]);
|
||||
|
||||
if (loading || settingsLoading || !order) {
|
||||
return (
|
||||
@@ -68,55 +75,171 @@ export default function ThankYou() {
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||
{/* Receipt Header */}
|
||||
<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">
|
||||
<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 className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||
{/* Receipt Header */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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)}
|
||||
{/* 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 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -218,67 +341,115 @@ export default function ThankYou() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</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>
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<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-sm">
|
||||
<span>SHIPPING:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
<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-sm">
|
||||
<span>TAX:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Tax</span>
|
||||
<span>{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 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="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 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 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>
|
||||
|
||||
{/* 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">
|
||||
@@ -287,8 +458,22 @@ export default function ThankYou() {
|
||||
</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>
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
@@ -319,153 +504,7 @@ export default function ThankYou() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Continue Shopping Button */}
|
||||
{elements.continue_shopping_button && (
|
||||
<div className="text-center">
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useWishlist } from '@/hooks/useWishlist';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
@@ -119,7 +119,7 @@ export default function Wishlist() {
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: { app: 'src/main.tsx' },
|
||||
output: { entryFileNames: 'app.js', assetFileNames: 'app.[ext]' }
|
||||
|
||||
@@ -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() {
|
||||
@@ -82,6 +89,7 @@ class AppearanceController {
|
||||
|
||||
$general_data = [
|
||||
'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'),
|
||||
'typography' => [
|
||||
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
||||
@@ -371,6 +379,30 @@ class AppearanceController {
|
||||
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
|
||||
*/
|
||||
@@ -378,6 +410,7 @@ class AppearanceController {
|
||||
return [
|
||||
'general' => [
|
||||
'spa_mode' => 'full',
|
||||
'spa_page' => 0,
|
||||
'toast_position' => 'top-right',
|
||||
'typography' => [
|
||||
'mode' => 'predefined',
|
||||
|
||||
@@ -6,12 +6,15 @@ use WooNooW\Compat\AddonRegistry;
|
||||
use WooNooW\Compat\RouteRegistry;
|
||||
use WooNooW\Compat\NavigationRegistry;
|
||||
|
||||
class Assets {
|
||||
public static function init() {
|
||||
class Assets
|
||||
{
|
||||
public static function init()
|
||||
{
|
||||
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
|
||||
}
|
||||
|
||||
public static function enqueue($hook) {
|
||||
public static function enqueue($hook)
|
||||
{
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||
@@ -42,7 +45,8 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* 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
|
||||
|
||||
// 1) Create a small handle to attach config (window.WNW_API)
|
||||
@@ -53,38 +57,38 @@ class Assets {
|
||||
// Attach runtime config (before module loader runs)
|
||||
// If you prefer, keep using self::localize_runtime($handle)
|
||||
wp_localize_script($handle, 'WNW_API', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'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');
|
||||
|
||||
// WNW_CONFIG for compatibility with standalone mode code
|
||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'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');
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
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'),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after');
|
||||
|
||||
// Also expose compact global for convenience
|
||||
wp_localize_script($handle, 'wnw', [
|
||||
'isDev' => true,
|
||||
'isDev' => true,
|
||||
'devServer' => $dev_url,
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||
@@ -117,11 +121,11 @@ class Assets {
|
||||
// 1) React Refresh preamble (required by @vitejs/plugin-react)
|
||||
?>
|
||||
<script type="module">
|
||||
import RefreshRuntime from "<?php echo esc_url( $dev_url ); ?>/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
import RefreshRuntime from "<?php echo esc_url($dev_url); ?>/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => { };
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<?php
|
||||
|
||||
@@ -136,17 +140,18 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* 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/)
|
||||
$plugin_dir = dirname(dirname(__DIR__));
|
||||
$dist_dir = $plugin_dir . '/admin-spa/dist/';
|
||||
$base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php');
|
||||
|
||||
$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_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
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
@@ -159,51 +164,49 @@ class Assets {
|
||||
|
||||
if (file_exists($dist_dir . $css)) {
|
||||
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
|
||||
|
||||
// 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);
|
||||
// Note: Icon fixes are now in index.css with proper specificity
|
||||
}
|
||||
|
||||
if (file_exists($dist_dir . $js)) {
|
||||
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
|
||||
|
||||
// Add type="module" attribute for Vite build
|
||||
add_filter('script_loader_tag', function ($tag, $handle, $src) {
|
||||
if ($handle === 'wnw-admin') {
|
||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||
}
|
||||
return $tag;
|
||||
}, 10, 3);
|
||||
|
||||
self::localize_runtime('wnw-admin');
|
||||
}
|
||||
}
|
||||
|
||||
/** 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', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => self::is_dev_mode(),
|
||||
'devServer' => self::dev_server_url(),
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'isDev' => self::is_dev_mode(),
|
||||
'devServer' => self::dev_server_url(),
|
||||
'adminScreen' => 'woonoow',
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
]);
|
||||
|
||||
// WNW_CONFIG for compatibility with standalone mode code
|
||||
wp_localize_script($handle, 'WNW_CONFIG', [
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'standaloneMode' => false,
|
||||
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
|
||||
'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)
|
||||
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'),
|
||||
]);
|
||||
|
||||
@@ -212,9 +215,9 @@ class Assets {
|
||||
|
||||
// Compact global (prod)
|
||||
wp_localize_script($handle, 'wnw', [
|
||||
'isDev' => (bool) self::is_dev_mode(),
|
||||
'isDev' => (bool) self::is_dev_mode(),
|
||||
'devServer' => (string) self::dev_server_url(),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'adminUrl' => admin_url('admin.php'),
|
||||
'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
|
||||
@@ -240,22 +243,23 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$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;
|
||||
$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() : '.';
|
||||
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$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;
|
||||
$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() : '.';
|
||||
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
|
||||
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $currency_sym,
|
||||
'decimals' => (int) $decimals,
|
||||
'thousand_sep' => (string) $thousand_sep,
|
||||
'decimal_sep' => (string) $decimal_sep,
|
||||
'currency_pos' => (string) $currency_pos,
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $currency_sym,
|
||||
'decimals' => (int) $decimals,
|
||||
'thousand_sep' => (string) $thousand_sep,
|
||||
'decimal_sep' => (string) $decimal_sep,
|
||||
'currency_pos' => (string) $currency_pos,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -266,9 +270,10 @@ class Assets {
|
||||
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
|
||||
* 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
|
||||
$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.
|
||||
@@ -288,7 +293,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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)
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$protocol = is_ssl() ? 'https' : 'http';
|
||||
@@ -305,7 +311,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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
|
||||
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
||||
}
|
||||
|
||||
@@ -78,6 +78,58 @@ class AuthController {
|
||||
], 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
|
||||
*
|
||||
@@ -134,4 +186,144 @@ class AuthController {
|
||||
],
|
||||
], 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' ],
|
||||
'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:
|
||||
* {
|
||||
@@ -187,6 +262,68 @@ class CheckoutController {
|
||||
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
|
||||
@@ -265,6 +402,12 @@ class CheckoutController {
|
||||
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 [
|
||||
'ok' => true,
|
||||
'order_id' => $order->get_id(),
|
||||
|
||||
@@ -32,12 +32,12 @@ class ModuleSettingsController extends WP_REST_Controller {
|
||||
* 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', [
|
||||
[
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_settings'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
'permission_callback' => '__return_true', // Public: settings are non-sensitive, needed by customer pages
|
||||
'args' => [
|
||||
'module_id' => [
|
||||
'required' => true,
|
||||
|
||||
@@ -56,6 +56,23 @@ class NewsletterController {
|
||||
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) {
|
||||
@@ -197,4 +214,78 @@ class NewsletterController {
|
||||
],
|
||||
], 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_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
@@ -77,7 +77,7 @@ class NotificationsController {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
],
|
||||
[
|
||||
'methods' => 'PUT',
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'save_template'],
|
||||
'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);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,6 @@ class Permissions {
|
||||
$has_wc = current_user_can('manage_woocommerce');
|
||||
$has_opts = current_user_can('manage_options');
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -447,6 +447,7 @@ class ProductsController {
|
||||
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['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['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
|
||||
|
||||
@@ -800,15 +801,18 @@ class ProductsController {
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
// Custom attribute - stored as lowercase in meta
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
$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);
|
||||
}
|
||||
|
||||
$formatted_attributes[$clean_name] = $value;
|
||||
// Only add if value exists
|
||||
if (!empty($value)) {
|
||||
$formatted_attributes[$clean_name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$image_url = $image ? $image[0] : '';
|
||||
@@ -857,36 +861,106 @@ class ProductsController {
|
||||
* Save product variations
|
||||
*/
|
||||
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) {
|
||||
if (isset($var_data['id']) && $var_data['id']) {
|
||||
// Update existing variation
|
||||
$variation = wc_get_product($var_data['id']);
|
||||
if (!$variation) continue;
|
||||
$variations_to_keep[] = $var_data['id'];
|
||||
} else {
|
||||
// Create new variation
|
||||
$variation = new WC_Product_Variation();
|
||||
$variation->set_parent_id($product->get_id());
|
||||
}
|
||||
|
||||
if ($variation) {
|
||||
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
|
||||
if (isset($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['attributes'])) $variation->set_attributes($var_data['attributes']);
|
||||
// Build attributes array
|
||||
$wc_attributes = [];
|
||||
if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
|
||||
$parent_attributes = $product->get_attributes();
|
||||
|
||||
// Handle image - support both image_id and image URL
|
||||
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);
|
||||
foreach ($var_data['attributes'] as $display_name => $value) {
|
||||
if (empty($value)) continue;
|
||||
|
||||
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
$variation->save();
|
||||
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;
|
||||
|
||||
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\ModulesController;
|
||||
use WooNooW\Api\ModuleSettingsController;
|
||||
use WooNooW\Api\CampaignsController;
|
||||
use WooNooW\Frontend\ShopController;
|
||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||
use WooNooW\Frontend\AccountController;
|
||||
@@ -64,6 +65,34 @@ class Routes {
|
||||
'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
|
||||
CheckoutController::register();
|
||||
OrdersController::register();
|
||||
@@ -125,6 +154,9 @@ class Routes {
|
||||
// Newsletter controller
|
||||
NewsletterController::register_routes();
|
||||
|
||||
// Campaigns controller
|
||||
CampaignsController::register_routes();
|
||||
|
||||
// Modules controller
|
||||
$modules_controller = new ModulesController();
|
||||
$modules_controller->register_routes();
|
||||
|
||||
@@ -21,7 +21,6 @@ class CustomerSettingsProvider {
|
||||
// General
|
||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === '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_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||
@@ -50,10 +49,7 @@ class CustomerSettingsProvider {
|
||||
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
|
||||
if (isset($settings['vip_min_spent'])) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use WooNooW\Core\DataStores\OrderStore;
|
||||
use WooNooW\Core\MediaUpload;
|
||||
use WooNooW\Core\Notifications\PushNotificationHandler;
|
||||
use WooNooW\Core\Notifications\EmailManager;
|
||||
use WooNooW\Core\Campaigns\CampaignManager;
|
||||
use WooNooW\Core\ActivityLog\ActivityLogTable;
|
||||
use WooNooW\Branding;
|
||||
use WooNooW\Frontend\Assets as FrontendAssets;
|
||||
@@ -40,10 +41,11 @@ class Bootstrap {
|
||||
MediaUpload::init();
|
||||
PushNotificationHandler::init();
|
||||
EmailManager::instance(); // Initialize custom email system
|
||||
CampaignManager::init(); // Initialize campaigns CPT
|
||||
|
||||
// Frontend (customer-spa)
|
||||
FrontendAssets::init();
|
||||
Shortcodes::init();
|
||||
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
|
||||
TemplateOverride::init();
|
||||
new PageAppearance();
|
||||
|
||||
@@ -66,5 +68,64 @@ class Bootstrap {
|
||||
MailQueue::init();
|
||||
WooEmailOverride::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);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ namespace WooNooW\Core\Mail;
|
||||
class MailQueue {
|
||||
public static function init() {
|
||||
add_action('woonoow/mail/send', [__CLASS__, 'sendNow'], 10, 1);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW MailQueue] Hook registered: woonoow/mail/send -> MailQueue::sendNow');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,10 +21,6 @@ class MailQueue {
|
||||
// Store payload in wp_options (temporary, will be deleted after sending)
|
||||
update_option($email_id, $payload, false); // false = don't autoload
|
||||
|
||||
// Debug log in dev mode
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW MailQueue] Queued email ID: ' . $email_id . ' to: ' . ($payload['to'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
if (function_exists('as_enqueue_async_action')) {
|
||||
// Use Action Scheduler - pass email_id as single argument
|
||||
@@ -45,49 +37,28 @@ class MailQueue {
|
||||
* Retrieves payload from wp_options and deletes it after sending.
|
||||
*/
|
||||
public static function sendNow($email_id = null) {
|
||||
error_log('[WooNooW MailQueue] sendNow() called with args: ' . print_r(func_get_args(), true));
|
||||
error_log('[WooNooW MailQueue] email_id type: ' . gettype($email_id));
|
||||
error_log('[WooNooW MailQueue] email_id value: ' . var_export($email_id, true));
|
||||
|
||||
// Action Scheduler might pass an array, extract the first element
|
||||
if (is_array($email_id)) {
|
||||
error_log('[WooNooW MailQueue] email_id is array, extracting first element');
|
||||
$email_id = $email_id[0] ?? null;
|
||||
}
|
||||
|
||||
// email_id should be a string
|
||||
if (empty($email_id)) {
|
||||
error_log('[WooNooW MailQueue] ERROR: No email_id provided after extraction. Received: ' . print_r(func_get_args(), true));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Processing email_id: ' . $email_id);
|
||||
|
||||
// Retrieve payload from wp_options
|
||||
$p = get_option($email_id);
|
||||
|
||||
if (!$p) {
|
||||
error_log('[WooNooW MailQueue] ERROR: Email payload not found for ID: ' . $email_id);
|
||||
error_log('[WooNooW MailQueue] Checking if option exists in database...');
|
||||
global $wpdb;
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
|
||||
$email_id
|
||||
));
|
||||
error_log('[WooNooW MailQueue] Option exists in DB: ' . ($exists ? 'yes' : 'no'));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Payload retrieved - To: ' . ($p['to'] ?? 'unknown') . ', Subject: ' . ($p['subject'] ?? 'unknown'));
|
||||
|
||||
// Temporarily disable WooEmailOverride to prevent infinite loop
|
||||
if (class_exists('WooNooW\Core\Mail\WooEmailOverride')) {
|
||||
error_log('[WooNooW MailQueue] Disabling WooEmailOverride to prevent loop');
|
||||
WooEmailOverride::disable();
|
||||
}
|
||||
|
||||
error_log('[WooNooW MailQueue] Calling wp_mail() now...');
|
||||
|
||||
$result = wp_mail(
|
||||
$p['to'] ?? '',
|
||||
$p['subject'] ?? '',
|
||||
@@ -96,17 +67,12 @@ class MailQueue {
|
||||
$p['attachments'] ?? []
|
||||
);
|
||||
|
||||
error_log('[WooNooW MailQueue] wp_mail() returned: ' . ($result ? 'TRUE (success)' : 'FALSE (failed)'));
|
||||
|
||||
// Re-enable
|
||||
if (class_exists('WooNooW\Core\Mail\WooEmailOverride')) {
|
||||
error_log('[WooNooW MailQueue] Re-enabling WooEmailOverride');
|
||||
WooEmailOverride::enable();
|
||||
}
|
||||
|
||||
// Delete the temporary option after sending
|
||||
delete_option($email_id);
|
||||
|
||||
error_log('[WooNooW MailQueue] Sent and deleted email ID: ' . $email_id . ' to: ' . ($p['to'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ class EmailManager {
|
||||
// New customer account
|
||||
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
|
||||
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);
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -140,9 +140,12 @@ class EmailRenderer {
|
||||
*/
|
||||
private function get_variables($event_id, $data, $extra_data = []) {
|
||||
$variables = [
|
||||
'site_name' => get_bloginfo('name'),
|
||||
'site_title' => get_bloginfo('name'),
|
||||
'store_name' => get_bloginfo('name'),
|
||||
'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'),
|
||||
'current_year' => date('Y'),
|
||||
];
|
||||
@@ -249,7 +252,15 @@ class EmailRenderer {
|
||||
}
|
||||
|
||||
// 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, [
|
||||
'customer_id' => $data->get_id(),
|
||||
'customer_name' => $data->get_display_name(),
|
||||
@@ -257,6 +268,10 @@ class EmailRenderer {
|
||||
'customer_last_name' => $data->get_last_name(),
|
||||
'customer_email' => $data->get_email(),
|
||||
'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
|
||||
*/
|
||||
private function parse_cards($content) {
|
||||
// Match [card ...] ... [/card] patterns
|
||||
preg_match_all('/\[card([^\]]*)\](.*?)\[\/card\]/s', $content, $matches, PREG_SET_ORDER);
|
||||
// Use a single unified regex to match BOTH syntaxes in document 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)) {
|
||||
// No cards found, wrap entire content in a single card
|
||||
@@ -283,8 +301,19 @@ class EmailRenderer {
|
||||
|
||||
$html = '';
|
||||
foreach ($matches as $match) {
|
||||
$attributes = $this->parse_card_attributes($match[1]);
|
||||
$card_content = $match[2];
|
||||
// Determine which syntax was matched
|
||||
$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_spacing();
|
||||
@@ -337,10 +366,65 @@ class EmailRenderer {
|
||||
|
||||
// Get email customization settings for colors
|
||||
$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_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
|
||||
$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';
|
||||
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
|
||||
$content_style = 'padding: 32px 40px;';
|
||||
@@ -367,15 +451,15 @@ class EmailRenderer {
|
||||
}
|
||||
// Success card - green theme
|
||||
elseif ($type === 'success') {
|
||||
$style .= ' background-color: #f0fdf4; border-left: 4px solid #22c55e;';
|
||||
$style .= ' background-color: #f0fdf4;';
|
||||
}
|
||||
// Info card - blue theme
|
||||
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') {
|
||||
$style .= ' background-color: #fff8e1; border-left: 4px solid #ff9800;';
|
||||
$style .= ' background-color: #fff8e1;';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,8 +640,13 @@ class EmailRenderer {
|
||||
* @return string
|
||||
*/
|
||||
private function get_social_icon_url($platform, $color = 'white') {
|
||||
// Use local PNG icons
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(dirname(__FILE__))));
|
||||
// Use plugin URL constant if available, otherwise calculate from file path
|
||||
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);
|
||||
return $plugin_url . 'assets/icons/' . $filename;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ class EventRegistry {
|
||||
'wc_email' => 'customer_new_account',
|
||||
'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_welcome' => [
|
||||
@@ -63,6 +79,21 @@ class EventRegistry {
|
||||
'wc_email' => '',
|
||||
'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_placed' => [
|
||||
@@ -340,4 +371,150 @@ class EventRegistry {
|
||||
public static function event_exists($event_id, $recipient_type) {
|
||||
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_refunded' => self::customer_order_refunded(),
|
||||
'new_customer' => self::customer_new_customer(),
|
||||
'newsletter_campaign' => self::customer_newsletter_campaign(),
|
||||
],
|
||||
'staff' => [
|
||||
'order_placed' => self::staff_order_placed(),
|
||||
@@ -139,6 +140,7 @@ class DefaultTemplates
|
||||
'order_cancelled' => 'Order #{order_number} has been cancelled',
|
||||
'order_refunded' => 'Refund processed for order #{order_number}',
|
||||
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
|
||||
'newsletter_campaign' => '{campaign_title}',
|
||||
],
|
||||
'staff' => [
|
||||
'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
|
||||
[/card]
|
||||
|
||||
[button url="{my_account_url}"]Access Your Account[/button]
|
||||
[button url="{shop_url}"]Start Shopping[/button]
|
||||
[card type="success"]
|
||||
**Your Login Credentials:**
|
||||
|
||||
[card type="info"]
|
||||
💡 **Tip:** Check your account settings to receive personalized recommendations based on your interests.
|
||||
📧 **Email:** {customer_email}
|
||||
🔑 **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]
|
||||
|
||||
[button url="{shop_url}" style="outline"]Start Shopping[/button]
|
||||
|
||||
[card type="basic"]
|
||||
Got questions? Our customer service team is ready to help: {support_email}
|
||||
[/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
|
||||
* 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_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||
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,
|
||||
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 {
|
||||
// Production mode: Load from build
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
||||
@@ -71,55 +69,53 @@ class Assets {
|
||||
|
||||
// Check if build exists
|
||||
if (!file_exists($dist_path)) {
|
||||
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load manifest to get hashed filenames
|
||||
$manifest_file = $dist_path . 'manifest.json';
|
||||
if (file_exists($manifest_file)) {
|
||||
$manifest = json_decode(file_get_contents($manifest_file), true);
|
||||
// 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';
|
||||
|
||||
// Enqueue main JS
|
||||
if (isset($manifest['src/main.tsx'])) {
|
||||
$main_js = $manifest['src/main.tsx']['file'];
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/' . $main_js,
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$js_url,
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
// Add type="module" for Vite build
|
||||
add_filter('script_loader_tag', function($tag, $handle, $src) {
|
||||
if ($handle === 'woonoow-customer-spa') {
|
||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||
}
|
||||
return $tag;
|
||||
}, 10, 3);
|
||||
|
||||
// Enqueue main CSS
|
||||
if (isset($manifest['src/main.tsx']['css'])) {
|
||||
foreach ($manifest['src/main.tsx']['css'] as $css_file) {
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/' . $css_file,
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for production build without manifest
|
||||
wp_enqueue_script(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/app.js',
|
||||
[],
|
||||
null,
|
||||
true
|
||||
);
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$css_url,
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'woonoow-customer-spa',
|
||||
$plugin_url . 'customer-spa/dist/app.css',
|
||||
[],
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,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; ?>/src/main.tsx"></script>
|
||||
<?php
|
||||
error_log('WooNooW Customer: Scripts output directly in head with React Refresh preamble');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,21 +238,42 @@ class Assets {
|
||||
private static function should_load_assets() {
|
||||
global $post;
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
if (self::is_spa_page()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get Customer SPA settings
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
|
||||
// If disabled, don't load
|
||||
if ($mode === 'disabled') {
|
||||
// Still check for shortcodes
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
return true;
|
||||
}
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
return true;
|
||||
|
||||
// Check for shortcodes on regular pages
|
||||
if ($post) {
|
||||
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
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;
|
||||
}
|
||||
@@ -318,6 +334,27 @@ class Assets {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,16 @@ use WP_Error;
|
||||
* Cart Controller - Customer-facing cart API
|
||||
* Handles cart operations for customer-spa
|
||||
*/
|
||||
class CartController {
|
||||
class CartController
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize controller
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// 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 (!empty($result)) {
|
||||
return $result;
|
||||
@@ -35,37 +37,38 @@ class CartController {
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
public static function register_routes()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
|
||||
// Get cart
|
||||
$result = register_rest_route($namespace, '/cart', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_cart'],
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_cart'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Add to cart
|
||||
$result = register_rest_route($namespace, '/cart/add', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'add_to_cart'],
|
||||
'permission_callback' => function() {
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'add_to_cart'],
|
||||
'permission_callback' => function () {
|
||||
// Allow both logged-in and guest users
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'product_id' => [
|
||||
'required' => true,
|
||||
'validate_callback' => function($param) {
|
||||
'args' => [
|
||||
'product_id' => [
|
||||
'required' => true,
|
||||
'validate_callback' => function ($param) {
|
||||
return is_numeric($param);
|
||||
},
|
||||
],
|
||||
'quantity' => [
|
||||
'default' => 1,
|
||||
'quantity' => [
|
||||
'default' => 1,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'variation_id' => [
|
||||
'default' => 0,
|
||||
'default' => 0,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
],
|
||||
@@ -73,16 +76,17 @@ class CartController {
|
||||
|
||||
// Update cart item
|
||||
register_rest_route($namespace, '/cart/update', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'update_cart'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'args' => [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'update_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'quantity' => [
|
||||
'required' => true,
|
||||
'quantity' => [
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
],
|
||||
@@ -90,12 +94,13 @@ class CartController {
|
||||
|
||||
// Remove from cart
|
||||
register_rest_route($namespace, '/cart/remove', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'args' => [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
@@ -103,26 +108,36 @@ class CartController {
|
||||
|
||||
// Apply coupon
|
||||
register_rest_route($namespace, '/cart/apply-coupon', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'apply_coupon'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'args' => [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'apply_coupon'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
'required' => true,
|
||||
'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
|
||||
register_rest_route($namespace, '/cart/remove-coupon', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_coupon'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'args' => [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_coupon'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -131,9 +146,18 @@ class CartController {
|
||||
/**
|
||||
* Get cart contents
|
||||
*/
|
||||
public static function get_cart(WP_REST_Request $request) {
|
||||
public static function get_cart(WP_REST_Request $request)
|
||||
{
|
||||
// Initialize WooCommerce session and cart for REST API requests
|
||||
if (!WC()->session) {
|
||||
WC()->initialize_session();
|
||||
}
|
||||
if (!WC()->cart) {
|
||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
||||
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);
|
||||
@@ -142,140 +166,114 @@ class CartController {
|
||||
/**
|
||||
* Add item to cart
|
||||
*/
|
||||
public static function add_to_cart(WP_REST_Request $request) {
|
||||
$product_id = $request->get_param('product_id');
|
||||
$quantity = $request->get_param('quantity');
|
||||
public static function add_to_cart(WP_REST_Request $request)
|
||||
{
|
||||
$product_id = $request->get_param('product_id');
|
||||
$quantity = $request->get_param('quantity') ?: 1; // Default to 1
|
||||
$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
|
||||
// WooCommerce doesn't auto-initialize these for REST API calls
|
||||
if (!WC()->session) {
|
||||
error_log('WooNooW Cart: Initializing WC session for REST API');
|
||||
WC()->initialize_session();
|
||||
}
|
||||
|
||||
if (!WC()->cart) {
|
||||
error_log('WooNooW Cart: Initializing WC cart for REST API');
|
||||
WC()->initialize_cart();
|
||||
}
|
||||
|
||||
// Set session cookie for guest users
|
||||
// CRITICAL: Set session cookie for guest users to persist cart
|
||||
if (!WC()->session->has_session()) {
|
||||
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
|
||||
$product = wc_get_product($product_id);
|
||||
if (!$product) {
|
||||
error_log("WooNooW Cart Error: Product {$product_id} not found");
|
||||
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, validate the variation and get attributes
|
||||
// For variable products, get attributes from request or variation
|
||||
$variation_attributes = [];
|
||||
if ($variation_id > 0) {
|
||||
$variation = wc_get_product($variation_id);
|
||||
if (!$variation) {
|
||||
error_log("WooNooW Cart Error: Variation {$variation_id} not found");
|
||||
return new WP_Error('invalid_variation', "Variation {$variation_id} not found", ['status' => 404]);
|
||||
return new WP_Error('invalid_variation', "Variation not found", ['status' => 404]);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
if (!$variation->is_purchasable() || !$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 not available for purchase", ['status' => 400]);
|
||||
if (!$variation->is_in_stock()) {
|
||||
return new WP_Error('variation_not_available', "This variation is out of stock", ['status' => 400]);
|
||||
}
|
||||
|
||||
// Get variation attributes from post meta
|
||||
// WooCommerce stores variation attributes as post meta with 'attribute_' prefix
|
||||
$variation_attributes = [];
|
||||
|
||||
// Get parent product to know which attributes to look for
|
||||
$parent_product = wc_get_product($product_id);
|
||||
$parent_attributes = $parent_product->get_attributes();
|
||||
|
||||
error_log("WooNooW Cart: Parent product attributes: " . print_r(array_keys($parent_attributes), true));
|
||||
|
||||
// For each parent attribute, get the value from variation post meta
|
||||
foreach ($parent_attributes as $attribute) {
|
||||
if ($attribute->get_variation()) {
|
||||
$attribute_name = $attribute->get_name();
|
||||
$meta_key = 'attribute_' . $attribute_name;
|
||||
|
||||
// Get the value from post meta
|
||||
$attribute_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($attribute_value)) {
|
||||
// WooCommerce expects lowercase attribute names
|
||||
$wc_attribute_key = 'attribute_' . strtolower($attribute_name);
|
||||
$variation_attributes[$wc_attribute_key] = $attribute_value;
|
||||
}
|
||||
// Build attributes from request parameters (like WooCommerce does)
|
||||
// Check for attribute_* parameters in the request
|
||||
$params = $request->get_params();
|
||||
foreach ($params as $key => $value) {
|
||||
if (strpos($key, 'attribute_') === 0) {
|
||||
$variation_attributes[sanitize_title($key)] = wc_clean($value);
|
||||
}
|
||||
}
|
||||
|
||||
error_log("WooNooW Cart: Variation validated - {$variation->get_name()}");
|
||||
error_log("WooNooW Cart: Variation attributes extracted: " . print_r($variation_attributes, true));
|
||||
// If no attributes in request, get from variation meta directly
|
||||
if (empty($variation_attributes)) {
|
||||
$parent = wc_get_product($product_id);
|
||||
foreach ($parent->get_attributes() as $attr_name => $attribute) {
|
||||
if (!$attribute->get_variation())
|
||||
continue;
|
||||
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
$value = get_post_meta($variation_id, $meta_key, true);
|
||||
|
||||
if (!empty($value)) {
|
||||
$variation_attributes[$meta_key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any existing notices before adding to cart
|
||||
wc_clear_notices();
|
||||
|
||||
// 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);
|
||||
|
||||
if (!$cart_item_key) {
|
||||
// Get WooCommerce notices to provide better error message
|
||||
$notices = wc_get_notices('error');
|
||||
$error_messages = [];
|
||||
foreach ($notices as $notice) {
|
||||
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
|
||||
}
|
||||
$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]);
|
||||
}
|
||||
|
||||
error_log("WooNooW Cart: Product added successfully - Key: {$cart_item_key}");
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Product added to cart',
|
||||
'message' => 'Product added to cart',
|
||||
'cart_item_key' => $cart_item_key,
|
||||
'cart' => self::format_cart(),
|
||||
'cart' => self::format_cart(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
$quantity = $request->get_param('quantity');
|
||||
$quantity = $request->get_param('quantity');
|
||||
|
||||
// Initialize WooCommerce session and cart for REST API requests
|
||||
if (!WC()->session) {
|
||||
WC()->initialize_session();
|
||||
}
|
||||
if (!WC()->cart) {
|
||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
||||
WC()->initialize_cart();
|
||||
}
|
||||
if (!WC()->session->has_session()) {
|
||||
WC()->session->set_customer_session_cookie(true);
|
||||
}
|
||||
|
||||
// Update quantity
|
||||
@@ -287,18 +285,32 @@ class CartController {
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Cart updated',
|
||||
'cart' => self::format_cart(),
|
||||
'cart' => self::format_cart(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// Initialize WooCommerce session and cart for REST API requests
|
||||
if (!WC()->session) {
|
||||
WC()->initialize_session();
|
||||
}
|
||||
if (!WC()->cart) {
|
||||
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
|
||||
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
|
||||
@@ -310,14 +322,40 @@ class CartController {
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Item removed from cart',
|
||||
'cart' => self::format_cart(),
|
||||
'cart' => self::format_cart(),
|
||||
], 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
|
||||
*/
|
||||
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');
|
||||
|
||||
if (!WC()->cart) {
|
||||
@@ -333,14 +371,15 @@ class CartController {
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Coupon applied',
|
||||
'cart' => self::format_cart(),
|
||||
'cart' => self::format_cart(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
if (!WC()->cart) {
|
||||
@@ -356,14 +395,15 @@ class CartController {
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Coupon removed',
|
||||
'cart' => self::format_cart(),
|
||||
'cart' => self::format_cart(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cart data for API response
|
||||
*/
|
||||
private static function format_cart() {
|
||||
private static function format_cart()
|
||||
{
|
||||
$cart = WC()->cart;
|
||||
|
||||
if (!$cart) {
|
||||
@@ -374,18 +414,30 @@ class CartController {
|
||||
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
||||
$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[] = [
|
||||
'key' => $cart_item_key,
|
||||
'product_id' => $cart_item['product_id'],
|
||||
'key' => $cart_item_key,
|
||||
'product_id' => $cart_item['product_id'],
|
||||
'variation_id' => $cart_item['variation_id'] ?? 0,
|
||||
'quantity' => $cart_item['quantity'],
|
||||
'name' => $product->get_name(),
|
||||
'price' => $product->get_price(),
|
||||
'subtotal' => $cart_item['line_subtotal'],
|
||||
'total' => $cart_item['line_total'],
|
||||
'image' => wp_get_attachment_url($product->get_image_id()),
|
||||
'permalink' => get_permalink($cart_item['product_id']),
|
||||
'attributes' => $cart_item['variation'] ?? [],
|
||||
'quantity' => $cart_item['quantity'],
|
||||
'name' => $product->get_name(),
|
||||
'price' => $product->get_price(),
|
||||
'subtotal' => $cart_item['line_subtotal'],
|
||||
'total' => $cart_item['line_total'],
|
||||
'image' => wp_get_attachment_url($product->get_image_id()),
|
||||
'permalink' => get_permalink($cart_item['product_id']),
|
||||
'attributes' => $formatted_attributes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -394,28 +446,28 @@ class CartController {
|
||||
foreach ($cart->get_applied_coupons() as $coupon_code) {
|
||||
$coupon = new \WC_Coupon($coupon_code);
|
||||
$coupons[] = [
|
||||
'code' => $coupon_code,
|
||||
'code' => $coupon_code,
|
||||
'discount' => $cart->get_coupon_discount_amount($coupon_code),
|
||||
'type' => $coupon->get_discount_type(),
|
||||
'type' => $coupon->get_discount_type(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'subtotal' => $cart->get_subtotal(),
|
||||
'subtotal_tax' => $cart->get_subtotal_tax(),
|
||||
'discount_total' => $cart->get_discount_total(),
|
||||
'discount_tax' => $cart->get_discount_tax(),
|
||||
'shipping_total' => $cart->get_shipping_total(),
|
||||
'shipping_tax' => $cart->get_shipping_tax(),
|
||||
'items' => $items,
|
||||
'subtotal' => $cart->get_subtotal(),
|
||||
'subtotal_tax' => $cart->get_subtotal_tax(),
|
||||
'discount_total' => $cart->get_discount_total(),
|
||||
'discount_tax' => $cart->get_discount_tax(),
|
||||
'shipping_total' => $cart->get_shipping_total(),
|
||||
'shipping_tax' => $cart->get_shipping_tax(),
|
||||
'cart_contents_tax' => $cart->get_cart_contents_tax(),
|
||||
'fee_total' => $cart->get_fee_total(),
|
||||
'fee_tax' => $cart->get_fee_tax(),
|
||||
'total' => $cart->get_total('edit'),
|
||||
'total_tax' => $cart->get_total_tax(),
|
||||
'coupons' => $coupons,
|
||||
'needs_shipping' => $cart->needs_shipping(),
|
||||
'needs_payment' => $cart->needs_payment(),
|
||||
'fee_total' => $cart->get_fee_total(),
|
||||
'fee_tax' => $cart->get_fee_tax(),
|
||||
'total' => $cart->get_total('edit'),
|
||||
'total_tax' => $cart->get_total_tax(),
|
||||
'coupons' => $coupons,
|
||||
'needs_shipping' => $cart->needs_shipping(),
|
||||
'needs_payment' => $cart->needs_payment(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,21 @@ namespace WooNooW\Frontend;
|
||||
* Template Override
|
||||
* Overrides WooCommerce templates to use WooNooW SPA
|
||||
*/
|
||||
class TemplateOverride {
|
||||
class TemplateOverride
|
||||
{
|
||||
|
||||
/**
|
||||
* 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
|
||||
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
|
||||
|
||||
@@ -32,13 +41,120 @@ class TemplateOverride {
|
||||
|
||||
// Override single product template
|
||||
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
|
||||
* 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', []);
|
||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||
|
||||
@@ -63,12 +179,38 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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', []);
|
||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||
|
||||
// Mode 1: Disabled
|
||||
// Mode 1: Disabled - but still check for shortcodes (legacy)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -99,8 +241,8 @@ class TemplateOverride {
|
||||
$checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [
|
||||
'checkout' => true,
|
||||
'thankyou' => true,
|
||||
'account' => true,
|
||||
'cart' => false,
|
||||
'account' => true,
|
||||
'cart' => false,
|
||||
];
|
||||
|
||||
$should_override = false;
|
||||
@@ -145,7 +287,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* Start SPA wrapper
|
||||
*/
|
||||
public static function start_spa_wrapper() {
|
||||
public static function start_spa_wrapper()
|
||||
{
|
||||
// Check if we should use SPA
|
||||
if (!self::should_use_spa()) {
|
||||
return;
|
||||
@@ -184,7 +327,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* End SPA wrapper
|
||||
*/
|
||||
public static function end_spa_wrapper() {
|
||||
public static function end_spa_wrapper()
|
||||
{
|
||||
if (!self::should_use_spa()) {
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +340,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* Check if we should use SPA
|
||||
*/
|
||||
private static function should_use_spa() {
|
||||
private static function should_use_spa()
|
||||
{
|
||||
// Check if frontend mode is enabled
|
||||
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
||||
|
||||
@@ -217,10 +362,108 @@ class TemplateOverride {
|
||||
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
|
||||
*/
|
||||
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
|
||||
if (!self::should_use_spa()) {
|
||||
return $template;
|
||||
|
||||
@@ -75,6 +75,25 @@ class NewsletterSettings {
|
||||
'placeholder' => __('I agree to receive marketing emails', '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;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"scripts": {
|
||||
"dev": "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",
|
||||
"build:customer": "echo \"(todo) customer-spa build\"",
|
||||
"dev:customer": "cd customer-spa && npm run dev",
|
||||
"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",
|
||||
"pack": "node scripts/package-zip.mjs"
|
||||
},
|
||||
|
||||
@@ -8,23 +8,19 @@
|
||||
</head>
|
||||
<body <?php body_class('woonoow-spa-page'); ?>>
|
||||
<?php
|
||||
// Determine page type and data attributes
|
||||
$page_type = 'shop';
|
||||
$data_attrs = 'data-page="shop"';
|
||||
// Determine initial route based on SPA mode
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
|
||||
|
||||
if (is_product()) {
|
||||
$page_type = 'product';
|
||||
global $post;
|
||||
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
} elseif (is_cart()) {
|
||||
// Set initial page based on mode
|
||||
if ($spa_mode === 'checkout_only') {
|
||||
// Checkout Only mode starts at cart
|
||||
$page_type = 'cart';
|
||||
$data_attrs = 'data-page="cart"';
|
||||
} elseif (is_checkout()) {
|
||||
$page_type = 'checkout';
|
||||
$data_attrs = 'data-page="checkout"';
|
||||
} elseif (is_account_page()) {
|
||||
$page_type = 'account';
|
||||
$data_attrs = 'data-page="account"';
|
||||
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
|
||||
} else {
|
||||
// Full SPA mode starts at shop
|
||||
$page_type = 'shop';
|
||||
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user