Compare commits
17 Commits
v1.0-pre-s
...
0f542ad452
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f542ad452 | ||
|
|
befacf9d29 | ||
|
|
d9878c8b20 | ||
|
|
d65259db8a | ||
|
|
54a1ec1c88 | ||
|
|
3a8c436839 | ||
|
|
bfb961ccbe | ||
|
|
f49dde9484 | ||
|
|
b64a979a61 | ||
|
|
0e38b0eb5f | ||
|
|
68c3423f50 | ||
|
|
1206117df1 | ||
|
|
7c2f21f7a2 | ||
|
|
7c15850c8f | ||
|
|
670bd7d351 | ||
|
|
75a82cf16c | ||
|
|
45fcbf9d29 |
1472
admin-spa/package-lock.json
generated
1472
admin-spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,8 +49,10 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|||||||
@@ -257,10 +257,10 @@ import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
|||||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
import MarketingIndex from '@/routes/Marketing';
|
||||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
|
||||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
|
import Help from '@/routes/Help';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
function AddonRoute({ config }: { config: any }) {
|
function AddonRoute({ config }: { config: any }) {
|
||||||
@@ -579,9 +579,11 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
||||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
||||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
|
||||||
|
{/* Help - Main menu route with no submenu */}
|
||||||
|
<Route path="/help" element={<Help />} />
|
||||||
|
|
||||||
{/* Dynamic Addon Routes */}
|
{/* Dynamic Addon Routes */}
|
||||||
{addonRoutes.map((route: any) => (
|
{addonRoutes.map((route: any) => (
|
||||||
|
|||||||
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
167
admin-spa/src/routes/Help/DocContent.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { DocContent as DocContentType } from './types';
|
||||||
|
|
||||||
|
interface DocContentProps {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocContent({ slug }: DocContentProps) {
|
||||||
|
const [doc, setDoc] = useState<DocContentType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDoc = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||||
|
const response = await fetch(`/wp-json/woonoow/v1/docs/${slug}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setDoc(data.doc);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to load document');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load document');
|
||||||
|
setDoc(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDoc();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-32 w-full mt-6" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="prose prose-slate dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// Custom heading with anchor links
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-3xl font-bold mb-6 pb-4 border-b">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-xl font-medium mt-8 mb-3">{children}</h3>
|
||||||
|
),
|
||||||
|
// Styled tables
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto my-6">
|
||||||
|
<table className="min-w-full border-collapse border border-border rounded-lg">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-border px-4 py-2">{children}</td>
|
||||||
|
),
|
||||||
|
// Styled code blocks
|
||||||
|
code: ({ className, children }) => {
|
||||||
|
const isInline = !className;
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={className}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-muted p-4 rounded-lg overflow-x-auto my-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// Styled blockquotes for notes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-4 border-primary bg-primary/5 pl-4 py-2 my-4 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// Links
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-6 my-4 space-y-2">{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-6 my-4 space-y-2">{children}</ol>
|
||||||
|
),
|
||||||
|
// Horizontal rule
|
||||||
|
hr: () => <hr className="my-8 border-border" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doc.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
admin-spa/src/routes/Help/index.tsx
Normal file
212
admin-spa/src/routes/Help/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Book, ChevronRight, FileText, Settings, Layers, Puzzle, Menu, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import DocContent from './DocContent';
|
||||||
|
import type { DocSection } from './types';
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
'book-open': <Book className="w-4 h-4" />,
|
||||||
|
'file-text': <FileText className="w-4 h-4" />,
|
||||||
|
'settings': <Settings className="w-4 h-4" />,
|
||||||
|
'layers': <Layers className="w-4 h-4" />,
|
||||||
|
'puzzle': <Puzzle className="w-4 h-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Help() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [sections, setSections] = useState<DocSection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const currentSlug = searchParams.get('doc') || 'getting-started';
|
||||||
|
|
||||||
|
// Fetch documentation registry
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocs = async () => {
|
||||||
|
try {
|
||||||
|
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
|
||||||
|
const response = await fetch('/wp-json/woonoow/v1/docs', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setSections(data.sections);
|
||||||
|
const expanded: Record<string, boolean> = {};
|
||||||
|
data.sections.forEach((section: DocSection) => {
|
||||||
|
expanded[section.key] = true;
|
||||||
|
});
|
||||||
|
setExpandedSections(expanded);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch docs:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDocs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSection = (key: string) => {
|
||||||
|
setExpandedSections(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectDoc = (slug: string) => {
|
||||||
|
setSearchParams({ doc: slug });
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = (slug: string) => slug === currentSlug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden fixed bottom-20 right-4 z-50 bg-primary text-primary-foreground shadow-lg rounded-full w-12 h-12"
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Backdrop for mobile sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar - fixed overlay */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"lg:hidden fixed left-0 top-0 bottom-0 z-40 w-72 bg-background border-r overflow-y-auto",
|
||||||
|
sidebarOpen ? "block" : "hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
loading={loading}
|
||||||
|
sections={sections}
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
toggleSection={toggleSection}
|
||||||
|
selectDoc={selectDoc}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop layout - simple flexbox, no sticky */}
|
||||||
|
<div className="hidden lg:flex gap-0">
|
||||||
|
{/* Desktop sidebar - flex-shrink-0 keeps it visible */}
|
||||||
|
<aside className="w-72 flex-shrink-0 border-r bg-muted/30 min-h-[600px]">
|
||||||
|
<SidebarContent
|
||||||
|
loading={loading}
|
||||||
|
sections={sections}
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
toggleSection={toggleSection}
|
||||||
|
selectDoc={selectDoc}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop content */}
|
||||||
|
<main className="flex-1 min-w-0">
|
||||||
|
<div className="max-w-4xl mx-auto py-6 px-10">
|
||||||
|
<DocContent slug={currentSlug} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile content - shown when sidebar is hidden */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div className="max-w-4xl mx-auto py-6 px-6">
|
||||||
|
<DocContent slug={currentSlug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted sidebar content to avoid duplication
|
||||||
|
function SidebarContent({
|
||||||
|
loading,
|
||||||
|
sections,
|
||||||
|
expandedSections,
|
||||||
|
toggleSection,
|
||||||
|
selectDoc,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
sections: DocSection[];
|
||||||
|
expandedSections: Record<string, boolean>;
|
||||||
|
toggleSection: (key: string) => void;
|
||||||
|
selectDoc: (slug: string) => void;
|
||||||
|
isActive: (slug: string) => boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b bg-muted/30">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Book className="w-5 h-5" />
|
||||||
|
Documentation
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Help & Guides</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
) : sections.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">No documentation available</div>
|
||||||
|
) : (
|
||||||
|
sections.map((section) => (
|
||||||
|
<div key={section.key} className="mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection(section.key)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
|
||||||
|
>
|
||||||
|
{iconMap[section.icon] || <FileText className="w-4 h-4" />}
|
||||||
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 transition-transform",
|
||||||
|
expandedSections[section.key] && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections[section.key] && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.slug}
|
||||||
|
onClick={() => selectDoc(item.slug)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||||
|
isActive(item.slug)
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
admin-spa/src/routes/Help/types.ts
Normal file
31
admin-spa/src/routes/Help/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Documentation Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DocItem {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocSection {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
items: DocItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocContent {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocsRegistryResponse {
|
||||||
|
success: boolean;
|
||||||
|
sections: DocSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocContentResponse {
|
||||||
|
success: boolean;
|
||||||
|
doc: DocContent;
|
||||||
|
}
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
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 { Download, Trash2, Mail, Search } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useModules } from '@/hooks/useModules';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
|
|
||||||
export default function NewsletterSubscribers() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isEnabled } = useModules();
|
|
||||||
|
|
||||||
// Always call ALL hooks before any conditional returns
|
|
||||||
const { data: subscribersData, isLoading } = useQuery({
|
|
||||||
queryKey: ['newsletter-subscribers'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get('/newsletter/subscribers');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteSubscriber = useMutation({
|
|
||||||
mutationFn: async (email: string) => {
|
|
||||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
|
||||||
toast.success('Subscriber removed successfully');
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Failed to remove subscriber');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportSubscribers = () => {
|
|
||||||
if (!subscribersData?.subscribers) return;
|
|
||||||
|
|
||||||
const csv = ['Email,Subscribed Date'].concat(
|
|
||||||
subscribersData.subscribers.map((sub: any) =>
|
|
||||||
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
|
||||||
)
|
|
||||||
).join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscribers = subscribersData?.subscribers || [];
|
|
||||||
const filteredSubscribers = subscribers.filter((sub: any) =>
|
|
||||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isEnabled('newsletter')) {
|
|
||||||
return (
|
|
||||||
<SettingsLayout
|
|
||||||
title="Newsletter Subscribers"
|
|
||||||
description="Newsletter module is disabled"
|
|
||||||
>
|
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
|
||||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
|
||||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => navigate('/settings/modules')}>
|
|
||||||
Go to Module Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsLayout
|
|
||||||
title="Newsletter Subscribers"
|
|
||||||
description="Manage your newsletter subscribers and send campaigns"
|
|
||||||
>
|
|
||||||
<SettingsCard
|
|
||||||
title="Subscribers List"
|
|
||||||
description={`Total subscribers: ${subscribersData?.count || 0}`}
|
|
||||||
>
|
|
||||||
<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="Filter subscribers..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="!pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
Send Campaign
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subscribers Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading subscribers...
|
|
||||||
</div>
|
|
||||||
) : filteredSubscribers.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery ? 'No subscribers found matching your search' : 'No subscribers yet'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Email</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Subscribed Date</TableHead>
|
|
||||||
<TableHead>WP User</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredSubscribers.map((subscriber: any) => (
|
|
||||||
<TableRow key={subscriber.email}>
|
|
||||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
{subscriber.status || 'Active'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{subscriber.subscribed_at
|
|
||||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{subscriber.user_id ? (
|
|
||||||
<span className="text-xs text-blue-600">Yes (ID: {subscriber.user_id})</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">No</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
|
||||||
disabled={deleteSubscriber.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Email Template Settings */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Email Templates"
|
|
||||||
description="Customize newsletter email templates using the email builder"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 border rounded-lg bg-muted/50">
|
|
||||||
<h4 className="font-medium mb-2">Newsletter Welcome Email</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Welcome email sent when someone subscribes to your newsletter
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
|
||||||
>
|
|
||||||
Edit Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border rounded-lg bg-muted/50">
|
|
||||||
<h4 className="font-medium mb-2">New Subscriber Notification (Admin)</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Admin notification when someone subscribes to newsletter
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
|
||||||
>
|
|
||||||
Edit Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
289
admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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 Campaigns() {
|
||||||
|
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: '',
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<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/newsletter/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/newsletter/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/newsletter/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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
192
admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Download, Trash2, Search } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
export default function Subscribers() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: subscribersData, isLoading } = useQuery({
|
||||||
|
queryKey: ['newsletter-subscribers'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/newsletter/subscribers');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSubscriber = useMutation({
|
||||||
|
mutationFn: async (email: string) => {
|
||||||
|
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||||
|
toast.success(__('Subscriber removed successfully'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to remove subscriber'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportSubscribers = () => {
|
||||||
|
if (!subscribersData?.subscribers) return;
|
||||||
|
|
||||||
|
const csv = ['Email,Subscribed Date'].concat(
|
||||||
|
subscribersData.subscribers.map((sub: any) =>
|
||||||
|
`${sub.email},${sub.subscribed_at || 'N/A'}`
|
||||||
|
)
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribers = subscribersData?.subscribers || [];
|
||||||
|
const filteredSubscribers = subscribers.filter((sub: any) =>
|
||||||
|
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Subscribers List')}
|
||||||
|
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
|
||||||
|
>
|
||||||
|
<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={__('Filter subscribers...')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="!pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{__('Export CSV')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribers Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{__('Loading subscribers...')}
|
||||||
|
</div>
|
||||||
|
) : filteredSubscribers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{__('Email')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||||
|
<TableHead>{__('WP User')}</TableHead>
|
||||||
|
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSubscribers.map((subscriber: any) => (
|
||||||
|
<TableRow key={subscriber.email}>
|
||||||
|
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||||
|
{subscriber.status || __('Active')}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{subscriber.subscribed_at
|
||||||
|
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||||
|
: 'N/A'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{subscriber.user_id ? (
|
||||||
|
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
||||||
|
disabled={deleteSubscriber.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Email Template Settings */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Email Templates')}
|
||||||
|
description={__('Customize newsletter email templates using the email builder')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border rounded-lg bg-muted/50">
|
||||||
|
<h4 className="font-medium mb-2">{__('Newsletter Welcome Email')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('Welcome email sent when someone subscribes to your newsletter')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||||
|
>
|
||||||
|
{__('Edit Template')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg bg-muted/50">
|
||||||
|
<h4 className="font-medium mb-2">{__('New Subscriber Notification (Admin)')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('Admin notification when someone subscribes to newsletter')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||||
|
>
|
||||||
|
{__('Edit Template')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
74
admin-spa/src/routes/Marketing/Newsletter/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import Subscribers from './Subscribers';
|
||||||
|
import Campaigns from './Campaigns';
|
||||||
|
|
||||||
|
export default function Newsletter() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [activeTab, setActiveTab] = useState('subscribers');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isEnabled } = useModules();
|
||||||
|
|
||||||
|
// Check for tab query param
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get('tab');
|
||||||
|
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Update URL when tab changes
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
setSearchParams({ tab: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show disabled state if newsletter module is off
|
||||||
|
if (!isEnabled('newsletter')) {
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Newsletter')}
|
||||||
|
description={__('Newsletter module is disabled')}
|
||||||
|
>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||||
|
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.')}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/settings/modules')}>
|
||||||
|
{__('Go to Module Settings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Newsletter')}
|
||||||
|
description={__('Manage subscribers and send email campaigns')}
|
||||||
|
>
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="subscribers" className="space-y-4 mt-6">
|
||||||
|
<Subscribers />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="campaigns" className="space-y-4 mt-6">
|
||||||
|
<Campaigns />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||||
import { Mail, Send, Tag } from 'lucide-react';
|
import { Mail, Tag } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface MarketingCard {
|
interface MarketingCard {
|
||||||
@@ -13,16 +13,10 @@ interface MarketingCard {
|
|||||||
const cards: MarketingCard[] = [
|
const cards: MarketingCard[] = [
|
||||||
{
|
{
|
||||||
title: __('Newsletter'),
|
title: __('Newsletter'),
|
||||||
description: __('Manage subscribers and email templates'),
|
description: __('Manage subscribers and send email campaigns'),
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
to: '/marketing/newsletter',
|
to: '/marketing/newsletter',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: __('Campaigns'),
|
|
||||||
description: __('Create and send email campaigns'),
|
|
||||||
icon: Send,
|
|
||||||
to: '/marketing/campaigns',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: __('Coupons'),
|
title: __('Coupons'),
|
||||||
description: __('Discounts, promotions, and coupon codes'),
|
description: __('Discounts, promotions, and coupon codes'),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
|
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone, HelpCircle } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { useApp } from '@/contexts/AppContext';
|
import { useApp } from '@/contexts/AppContext';
|
||||||
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
|
|||||||
label: __('Settings'),
|
label: __('Settings'),
|
||||||
description: __('Configure your store settings'),
|
description: __('Configure your store settings'),
|
||||||
to: '/settings'
|
to: '/settings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <HelpCircle className="w-5 h-5" />,
|
||||||
|
label: __('Help & Docs'),
|
||||||
|
description: __('Documentation and guides'),
|
||||||
|
to: '/help'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|||||||
interface CustomerSettings {
|
interface CustomerSettings {
|
||||||
auto_register_members: boolean;
|
auto_register_members: boolean;
|
||||||
multiple_addresses_enabled: boolean;
|
multiple_addresses_enabled: boolean;
|
||||||
|
allow_custom_avatar: boolean;
|
||||||
vip_min_spent: number;
|
vip_min_spent: number;
|
||||||
vip_min_orders: number;
|
vip_min_orders: number;
|
||||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||||
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
|
|||||||
const [settings, setSettings] = useState<CustomerSettings>({
|
const [settings, setSettings] = useState<CustomerSettings>({
|
||||||
auto_register_members: false,
|
auto_register_members: false,
|
||||||
multiple_addresses_enabled: true,
|
multiple_addresses_enabled: true,
|
||||||
|
allow_custom_avatar: false,
|
||||||
vip_min_spent: 1000,
|
vip_min_spent: 1000,
|
||||||
vip_min_orders: 10,
|
vip_min_orders: 10,
|
||||||
vip_timeframe: 'all',
|
vip_timeframe: 'all',
|
||||||
@@ -138,6 +140,14 @@ export default function CustomersSettings() {
|
|||||||
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToggleField
|
||||||
|
id="allow_custom_avatar"
|
||||||
|
label={__('Allow custom profile photo')}
|
||||||
|
description={__('Allow customers to upload their own profile photo. When disabled, customer avatars will use Gravatar or default initials.')}
|
||||||
|
checked={settings.allow_custom_avatar}
|
||||||
|
onCheckedChange={(checked) => setSettings({ ...settings, allow_custom_avatar: checked })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function CustomerNotifications() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function EmailConfiguration() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="template" className="space-y-6">
|
<Tabs defaultValue="template" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
||||||
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function PushConfiguration() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="template" className="space-y-6">
|
<Tabs defaultValue="template" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
|
||||||
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function StaffNotifications() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
|
||||||
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
<TabsTrigger value="events">{__('Events')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default function TemplateEditor({
|
|||||||
{/* Body - Scrollable */}
|
{/* Body - Scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="editor" className="flex items-center gap-2">
|
<TabsTrigger value="editor" className="flex items-center gap-2">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
{__('Editor')}
|
{__('Editor')}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ rsync -av --progress \
|
|||||||
--exclude='admin-spa' \
|
--exclude='admin-spa' \
|
||||||
--exclude='examples' \
|
--exclude='examples' \
|
||||||
--exclude='*.sh' \
|
--exclude='*.sh' \
|
||||||
--exclude='*.md' \
|
--exclude='/*.md' \
|
||||||
--exclude='archive' \
|
--exclude='archive' \
|
||||||
--exclude='test-*.php' \
|
--exclude='test-*.php' \
|
||||||
--exclude='check-*.php' \
|
--exclude='check-*.php' \
|
||||||
|
|||||||
36
customer-spa/package-lock.json
generated
36
customer-spa/package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -4927,6 +4928,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/invariant": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6214,6 +6224,26 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-helmet-async": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"react-fast-compare": "^3.2.2",
|
||||||
|
"shallowequal": "^1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.1",
|
"version": "7.66.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||||
@@ -6658,6 +6688,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shallowequal": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
@@ -102,22 +103,44 @@ function AppRoutes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get router config from WordPress
|
||||||
|
const getRouterConfig = () => {
|
||||||
|
const config = (window as any).woonoowCustomer;
|
||||||
|
return {
|
||||||
|
useBrowserRouter: config?.useBrowserRouter ?? true,
|
||||||
|
basePath: config?.basePath || '/store',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Router wrapper that conditionally uses BrowserRouter or HashRouter
|
||||||
|
function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { useBrowserRouter, basePath } = getRouterConfig();
|
||||||
|
|
||||||
|
if (useBrowserRouter) {
|
||||||
|
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HashRouter>{children}</HashRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const themeConfig = getThemeConfig();
|
const themeConfig = getThemeConfig();
|
||||||
const appearanceSettings = getAppearanceSettings();
|
const appearanceSettings = getAppearanceSettings();
|
||||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<HelmetProvider>
|
||||||
<ThemeProvider config={themeConfig}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<HashRouter>
|
<ThemeProvider config={themeConfig}>
|
||||||
<AppRoutes />
|
<RouterProvider>
|
||||||
</HashRouter>
|
<AppRoutes />
|
||||||
|
</RouterProvider>
|
||||||
|
|
||||||
{/* Toast notifications - position from settings */}
|
{/* Toast notifications - position from settings */}
|
||||||
<Toaster position={toastPosition} richColors />
|
<Toaster position={toastPosition} richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
customer-spa/src/components/SEOHead.tsx
Normal file
68
customer-spa/src/components/SEOHead.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
|
||||||
|
interface SEOHeadProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
type?: 'website' | 'product' | 'article';
|
||||||
|
product?: {
|
||||||
|
price?: string;
|
||||||
|
currency?: string;
|
||||||
|
availability?: 'in stock' | 'out of stock';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEOHead Component
|
||||||
|
* Adds dynamic meta tags for social media sharing (Open Graph, Twitter Cards)
|
||||||
|
* Used for link previews on Facebook, Twitter, Slack, etc.
|
||||||
|
*/
|
||||||
|
export function SEOHead({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
type = 'website',
|
||||||
|
product,
|
||||||
|
}: SEOHeadProps) {
|
||||||
|
const config = (window as any).woonoowCustomer;
|
||||||
|
const siteName = config?.siteName || 'Store';
|
||||||
|
const siteUrl = config?.siteUrl || '';
|
||||||
|
|
||||||
|
const fullTitle = title ? `${title} | ${siteName}` : siteName;
|
||||||
|
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic Meta Tags */}
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
{description && <meta name="description" content={description} />}
|
||||||
|
|
||||||
|
{/* Open Graph (Facebook, LinkedIn, etc.) */}
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
<meta property="og:title" content={title || siteName} />
|
||||||
|
{description && <meta property="og:description" content={description} />}
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:url" content={fullUrl} />
|
||||||
|
{image && <meta property="og:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Twitter Card */}
|
||||||
|
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||||
|
<meta name="twitter:title" content={title || siteName} />
|
||||||
|
{description && <meta name="twitter:description" content={description} />}
|
||||||
|
{image && <meta name="twitter:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Product-specific meta tags */}
|
||||||
|
{type === 'product' && product && (
|
||||||
|
<>
|
||||||
|
<meta property="product:price:amount" content={product.price} />
|
||||||
|
<meta property="product:price:currency" content={product.currency} />
|
||||||
|
<meta property="product:availability" content={product.availability} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SEOHead;
|
||||||
@@ -109,3 +109,57 @@ export async function fetchCart(): Promise<Cart> {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply coupon to cart via API
|
||||||
|
*/
|
||||||
|
export async function applyCoupon(couponCode: string): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/apply-coupon`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coupon_code: couponCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to apply coupon');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove coupon from cart via API
|
||||||
|
*/
|
||||||
|
export async function removeCoupon(couponCode: string): Promise<Cart> {
|
||||||
|
const { apiRoot, nonce } = getApiConfig();
|
||||||
|
|
||||||
|
const response = await fetch(`${apiRoot}/cart/remove-coupon`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WP-Nonce': nonce,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
coupon_code: couponCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to remove coupon');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.cart;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,12 +24,19 @@ export interface Cart {
|
|||||||
code: string;
|
code: string;
|
||||||
discount: number;
|
discount: number;
|
||||||
};
|
};
|
||||||
|
coupons?: {
|
||||||
|
code: string;
|
||||||
|
discount: number;
|
||||||
|
type?: string;
|
||||||
|
}[];
|
||||||
|
discount_total?: number;
|
||||||
|
shipping_total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartStore {
|
interface CartStore {
|
||||||
cart: Cart;
|
cart: Cart;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setCart: (cart: Cart) => void;
|
setCart: (cart: Cart) => void;
|
||||||
addItem: (item: CartItem) => void;
|
addItem: (item: CartItem) => void;
|
||||||
@@ -60,7 +67,7 @@ export const useCartStore = create<CartStore>()(
|
|||||||
addItem: (item) =>
|
addItem: (item) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existingItem = state.cart.items.find((i) => i.key === item.key);
|
const existingItem = state.cart.items.find((i) => i.key === item.key);
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// Update quantity if item exists
|
// Update quantity if item exists
|
||||||
return {
|
return {
|
||||||
@@ -74,7 +81,7 @@ export const useCartStore = create<CartStore>()(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new item
|
// Add new item
|
||||||
return {
|
return {
|
||||||
cart: {
|
cart: {
|
||||||
|
|||||||
@@ -1,15 +1,158 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Download } from 'lucide-react';
|
import { Download, Loader2, FileText, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { api } from '@/lib/api/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
|
interface DownloadItem {
|
||||||
|
download_id: string;
|
||||||
|
download_url: string;
|
||||||
|
product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
product_url: string;
|
||||||
|
download_name: string;
|
||||||
|
order_id: number;
|
||||||
|
order_key: string;
|
||||||
|
downloads_remaining: string;
|
||||||
|
access_expires: string | null;
|
||||||
|
file: {
|
||||||
|
name: string;
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function Downloads() {
|
export default function Downloads() {
|
||||||
|
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDownloads = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await api.get<DownloadItem[]>('/account/downloads');
|
||||||
|
setDownloads(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch downloads:', err);
|
||||||
|
setError(err.message || 'Failed to load downloads');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDownloads();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = (downloadUrl: string, fileName: string) => {
|
||||||
|
// Open download in new tab
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
toast.success(`Downloading ${fileName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader2 className="w-12 h-12 text-gray-400 mx-auto mb-4 animate-spin" />
|
||||||
|
<p className="text-gray-600">Loading your downloads...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloads.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 mb-2">No downloads available</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Downloads will appear here after you purchase downloadable products.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
|
||||||
|
|
||||||
<div className="text-center py-12">
|
<div className="space-y-4">
|
||||||
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
{downloads.map((download) => (
|
||||||
<p className="text-gray-600">No downloads available</p>
|
<div
|
||||||
|
key={`${download.download_id}-${download.order_id}`}
|
||||||
|
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">
|
||||||
|
{download.product_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 truncate">
|
||||||
|
{download.download_name || download.file?.name || 'Download'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
|
||||||
|
<span className="bg-gray-100 px-2 py-1 rounded">
|
||||||
|
Order #{download.order_id}
|
||||||
|
</span>
|
||||||
|
{download.downloads_remaining && download.downloads_remaining !== 'unlimited' && (
|
||||||
|
<span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
|
||||||
|
{download.downloads_remaining} downloads left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{download.access_expires && (
|
||||||
|
<span className="bg-orange-100 text-orange-700 px-2 py-1 rounded">
|
||||||
|
Expires: {new Date(download.access_expires).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownload(download.download_url, download.download_name || 'file')}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{downloads.length > 0 && (
|
||||||
|
<p className="text-sm text-gray-500 mt-6 text-center">
|
||||||
|
{downloads.length} {downloads.length === 1 ? 'download' : 'downloads'} available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart, applyCoupon, removeCoupon } from '@/lib/cart/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
|
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Cart() {
|
export default function Cart() {
|
||||||
@@ -24,7 +24,9 @@ export default function Cart() {
|
|||||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [couponCode, setCouponCode] = useState('');
|
||||||
|
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
||||||
|
|
||||||
// Fetch cart from server on mount to sync with WooCommerce
|
// Fetch cart from server on mount to sync with WooCommerce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCart = async () => {
|
const loadCart = async () => {
|
||||||
@@ -37,10 +39,10 @@ export default function Cart() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCart();
|
loadCart();
|
||||||
}, [setCart]);
|
}, [setCart]);
|
||||||
|
|
||||||
// Calculate total from items
|
// Calculate total from items
|
||||||
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ export default function Cart() {
|
|||||||
handleRemoveItem(key);
|
handleRemoveItem(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
try {
|
try {
|
||||||
const updatedCart = await updateCartItemQuantity(key, newQuantity);
|
const updatedCart = await updateCartItemQuantity(key, newQuantity);
|
||||||
@@ -92,6 +94,37 @@ export default function Cart() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyCoupon = async () => {
|
||||||
|
if (!couponCode.trim()) return;
|
||||||
|
|
||||||
|
setIsApplyingCoupon(true);
|
||||||
|
try {
|
||||||
|
const updatedCart = await applyCoupon(couponCode.trim());
|
||||||
|
setCart(updatedCart);
|
||||||
|
setCouponCode('');
|
||||||
|
toast.success('Coupon applied successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to apply coupon:', error);
|
||||||
|
toast.error(error.message || 'Failed to apply coupon');
|
||||||
|
} finally {
|
||||||
|
setIsApplyingCoupon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCoupon = async (code: string) => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const updatedCart = await removeCoupon(code);
|
||||||
|
setCart(updatedCart);
|
||||||
|
toast.success('Coupon removed');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to remove coupon:', error);
|
||||||
|
toast.error(error.message || 'Failed to remove coupon');
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Show loading state while fetching cart
|
// Show loading state while fetching cart
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -162,7 +195,7 @@ export default function Cart() {
|
|||||||
<h3 className="font-semibold text-lg mb-1 truncate">
|
<h3 className="font-semibold text-lg mb-1 truncate">
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Variation Attributes */}
|
{/* Variation Attributes */}
|
||||||
{item.attributes && Object.keys(item.attributes).length > 0 && (
|
{item.attributes && Object.keys(item.attributes).length > 0 && (
|
||||||
<div className="text-sm text-gray-500 mb-1">
|
<div className="text-sm text-gray-500 mb-1">
|
||||||
@@ -177,7 +210,7 @@ export default function Cart() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-gray-600 mb-2">
|
<p className="text-gray-600 mb-2">
|
||||||
{formatPrice(item.price)}
|
{formatPrice(item.price)}
|
||||||
</p>
|
</p>
|
||||||
@@ -237,10 +270,43 @@ export default function Cart() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter coupon code"
|
placeholder="Enter coupon code"
|
||||||
|
value={couponCode}
|
||||||
|
onChange={(e) => setCouponCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleApplyCoupon()}
|
||||||
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={isApplyingCoupon}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm">Apply</Button>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyCoupon}
|
||||||
|
disabled={isApplyingCoupon || !couponCode.trim()}
|
||||||
|
>
|
||||||
|
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Applied Coupons */}
|
||||||
|
{(cart as any).coupons?.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(cart as any).coupons.map((coupon: { code: string; discount: number }) => (
|
||||||
|
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
|
||||||
|
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveCoupon(coupon.code)}
|
||||||
|
className="text-green-600 hover:text-green-800 p-1"
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -262,15 +328,22 @@ export default function Cart() {
|
|||||||
<div className="space-y-3 mb-6">
|
<div className="space-y-3 mb-6">
|
||||||
<div className="flex justify-between text-gray-600">
|
<div className="flex justify-between text-gray-600">
|
||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice((cart as any).subtotal || total)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Show discount if coupons applied */}
|
||||||
|
{(cart as any).discount_total > 0 && (
|
||||||
|
<div className="flex justify-between text-green-600">
|
||||||
|
<span>Discount</span>
|
||||||
|
<span>-{formatPrice((cart as any).discount_total)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between text-gray-600">
|
<div className="flex justify-between text-gray-600">
|
||||||
<span>Shipping</span>
|
<span>Shipping</span>
|
||||||
<span>Calculated at checkout</span>
|
<span>{(cart as any).shipping_total > 0 ? formatPrice((cart as any).shipping_total) : 'Calculated at checkout'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t pt-3 flex justify-between text-lg font-bold">
|
<div className="border-t pt-3 flex justify-between text-lg font-bold">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice((cart as any).total || total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2 } from 'lucide-react';
|
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import { AddressSelector } from '@/components/AddressSelector';
|
import { AddressSelector } from '@/components/AddressSelector';
|
||||||
|
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
|
||||||
|
|
||||||
interface SavedAddress {
|
interface SavedAddress {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,6 +35,10 @@ export default function Checkout() {
|
|||||||
const { cart } = useCartStore();
|
const { cart } = useCartStore();
|
||||||
const { layout, elements } = useCheckoutSettings();
|
const { layout, elements } = useCheckoutSettings();
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [couponCode, setCouponCode] = useState('');
|
||||||
|
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
||||||
|
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
|
||||||
|
const [discountTotal, setDiscountTotal] = useState(0);
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
|
|
||||||
// Check if cart contains only virtual/downloadable products
|
// Check if cart contains only virtual/downloadable products
|
||||||
@@ -189,6 +194,57 @@ export default function Checkout() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const handleApplyCoupon = async () => {
|
||||||
|
if (!couponCode.trim()) return;
|
||||||
|
|
||||||
|
setIsApplyingCoupon(true);
|
||||||
|
try {
|
||||||
|
const updatedCart = await applyCoupon(couponCode.trim());
|
||||||
|
if (updatedCart.coupons) {
|
||||||
|
setAppliedCoupons(updatedCart.coupons);
|
||||||
|
setDiscountTotal(updatedCart.discount_total || 0);
|
||||||
|
}
|
||||||
|
setCouponCode('');
|
||||||
|
toast.success('Coupon applied successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to apply coupon');
|
||||||
|
} finally {
|
||||||
|
setIsApplyingCoupon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCoupon = async (code: string) => {
|
||||||
|
setIsApplyingCoupon(true);
|
||||||
|
try {
|
||||||
|
const updatedCart = await removeCoupon(code);
|
||||||
|
if (updatedCart.coupons) {
|
||||||
|
setAppliedCoupons(updatedCart.coupons);
|
||||||
|
setDiscountTotal(updatedCart.discount_total || 0);
|
||||||
|
}
|
||||||
|
toast.success('Coupon removed');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to remove coupon');
|
||||||
|
} finally {
|
||||||
|
setIsApplyingCoupon(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load cart data including coupons on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCartData = async () => {
|
||||||
|
try {
|
||||||
|
const cartData = await fetchCart();
|
||||||
|
if (cartData.coupons) {
|
||||||
|
setAppliedCoupons(cartData.coupons);
|
||||||
|
setDiscountTotal(cartData.discount_total || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load cart data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCartData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePlaceOrder = async (e: React.FormEvent) => {
|
const handlePlaceOrder = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
@@ -652,10 +708,45 @@ export default function Checkout() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter coupon code"
|
placeholder="Enter coupon code"
|
||||||
|
value={couponCode}
|
||||||
|
onChange={(e) => setCouponCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleApplyCoupon())}
|
||||||
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={isApplyingCoupon}
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" size="sm">Apply</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyCoupon}
|
||||||
|
disabled={isApplyingCoupon || !couponCode.trim()}
|
||||||
|
>
|
||||||
|
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Applied Coupons */}
|
||||||
|
{appliedCoupons.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{appliedCoupons.map((coupon) => (
|
||||||
|
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
|
||||||
|
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveCoupon(coupon.code)}
|
||||||
|
className="text-green-600 hover:text-green-800 p-1"
|
||||||
|
disabled={isApplyingCoupon}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -702,6 +793,13 @@ export default function Checkout() {
|
|||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>{formatPrice(subtotal)}</span>
|
<span>{formatPrice(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Show discount if coupons applied */}
|
||||||
|
{discountTotal > 0 && (
|
||||||
|
<div className="flex justify-between text-sm text-green-600">
|
||||||
|
<span>Discount</span>
|
||||||
|
<span>-{formatPrice(discountTotal)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>Shipping</span>
|
<span>Shipping</span>
|
||||||
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
||||||
@@ -714,7 +812,7 @@ export default function Checkout() {
|
|||||||
)}
|
)}
|
||||||
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice(total - discountTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard';
|
|||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||||
|
|
||||||
export default function Product() {
|
export default function Product() {
|
||||||
@@ -43,20 +44,20 @@ export default function Product() {
|
|||||||
queryKey: ['related-products', product?.id],
|
queryKey: ['related-products', product?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (product.related_ids && product.related_ids.length > 0) {
|
if (product.related_ids && product.related_ids.length > 0) {
|
||||||
const ids = product.related_ids.slice(0, 4).join(',');
|
const ids = product.related_ids.slice(0, 4).join(',');
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch related products:', error);
|
console.error('Failed to fetch related products:', error);
|
||||||
@@ -77,13 +78,13 @@ export default function Product() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) {
|
if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) {
|
||||||
const initialAttributes: Record<string, string> = {};
|
const initialAttributes: Record<string, string> = {};
|
||||||
|
|
||||||
product.attributes.forEach((attr: any) => {
|
product.attributes.forEach((attr: any) => {
|
||||||
if (attr.variation && attr.options && attr.options.length > 0) {
|
if (attr.variation && attr.options && attr.options.length > 0) {
|
||||||
initialAttributes[attr.name] = attr.options[0];
|
initialAttributes[attr.name] = attr.options[0];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(initialAttributes).length > 0) {
|
if (Object.keys(initialAttributes).length > 0) {
|
||||||
setSelectedAttributes(initialAttributes);
|
setSelectedAttributes(initialAttributes);
|
||||||
}
|
}
|
||||||
@@ -95,30 +96,30 @@ export default function Product() {
|
|||||||
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
||||||
const variation = (product.variations as any[]).find(v => {
|
const variation = (product.variations as any[]).find(v => {
|
||||||
if (!v.attributes) return false;
|
if (!v.attributes) return false;
|
||||||
|
|
||||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||||
const normalizedValue = attrValue.toLowerCase().trim();
|
const normalizedValue = attrValue.toLowerCase().trim();
|
||||||
|
|
||||||
// Check all attribute keys in variation (case-insensitive)
|
// Check all attribute keys in variation (case-insensitive)
|
||||||
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
||||||
const vKeyLower = vKey.toLowerCase();
|
const vKeyLower = vKey.toLowerCase();
|
||||||
const attrNameLower = attrName.toLowerCase();
|
const attrNameLower = attrName.toLowerCase();
|
||||||
|
|
||||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||||
vKeyLower === attrNameLower) {
|
vKeyLower === attrNameLower) {
|
||||||
|
|
||||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||||
if (varValueNormalized === normalizedValue) {
|
if (varValueNormalized === normalizedValue) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedVariation(variation || null);
|
setSelectedVariation(variation || null);
|
||||||
} else if (product?.type !== 'variable') {
|
} else if (product?.type !== 'variable') {
|
||||||
setSelectedVariation(null);
|
setSelectedVariation(null);
|
||||||
@@ -135,9 +136,9 @@ export default function Product() {
|
|||||||
// Build complete image gallery including variation images (BEFORE early returns)
|
// Build complete image gallery including variation images (BEFORE early returns)
|
||||||
const allImages = React.useMemo(() => {
|
const allImages = React.useMemo(() => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
const images = [...(product.images || [])];
|
const images = [...(product.images || [])];
|
||||||
|
|
||||||
// Add variation images if they don't exist in main gallery
|
// Add variation images if they don't exist in main gallery
|
||||||
if (product.type === 'variable' && product.variations) {
|
if (product.type === 'variable' && product.variations) {
|
||||||
(product.variations as any[]).forEach(variation => {
|
(product.variations as any[]).forEach(variation => {
|
||||||
@@ -146,7 +147,7 @@ export default function Product() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out any falsy values (false, null, undefined, empty strings)
|
// Filter out any falsy values (false, null, undefined, empty strings)
|
||||||
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||||
}, [product]);
|
}, [product]);
|
||||||
@@ -198,8 +199,8 @@ export default function Product() {
|
|||||||
virtual: product.virtual,
|
virtual: product.virtual,
|
||||||
downloadable: product.downloadable,
|
downloadable: product.downloadable,
|
||||||
// Use selectedAttributes from state (user's selections) for variable products
|
// Use selectedAttributes from state (user's selections) for variable products
|
||||||
attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0
|
attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0
|
||||||
? selectedAttributes
|
? selectedAttributes
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +258,18 @@ export default function Product() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
{/* SEO Meta Tags for Social Sharing */}
|
||||||
|
<SEOHead
|
||||||
|
title={product.name}
|
||||||
|
description={product.short_description?.replace(/<[^>]+>/g, '').slice(0, 160) || product.description?.replace(/<[^>]+>/g, '').slice(0, 160)}
|
||||||
|
image={product.image || product.images?.[0]}
|
||||||
|
type="product"
|
||||||
|
product={{
|
||||||
|
price: currentPrice,
|
||||||
|
currency: (window as any).woonoowCustomer?.currency?.code || 'USD',
|
||||||
|
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="max-w-6xl mx-auto py-8">
|
<div className="max-w-6xl mx-auto py-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
{elements.breadcrumbs && (
|
{elements.breadcrumbs && (
|
||||||
@@ -297,7 +310,7 @@ export default function Product() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dots Navigation - Show based on gallery_style */}
|
{/* Dots Navigation - Show based on gallery_style */}
|
||||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
@@ -306,18 +319,17 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
className={`w-2 h-2 rounded-full transition-all ${
|
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||||
selectedImage === img
|
|
||||||
? 'bg-primary w-6'
|
? 'bg-primary w-6'
|
||||||
: 'bg-gray-300 hover:bg-gray-400'
|
: 'bg-gray-300 hover:bg-gray-400'
|
||||||
}`}
|
}`}
|
||||||
aria-label={`View image ${index + 1}`}
|
aria-label={`View image ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thumbnail Slider - Show based on gallery_style */}
|
{/* Thumbnail Slider - Show based on gallery_style */}
|
||||||
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
@@ -330,7 +342,7 @@ export default function Product() {
|
|||||||
<ChevronLeft className="h-5 w-5" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scrollable Thumbnails */}
|
{/* Scrollable Thumbnails */}
|
||||||
<div
|
<div
|
||||||
ref={thumbnailsRef}
|
ref={thumbnailsRef}
|
||||||
@@ -341,11 +353,10 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${
|
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||||
selectedImage === img
|
|
||||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
@@ -355,7 +366,7 @@ export default function Product() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Arrow */}
|
{/* Right Arrow */}
|
||||||
{allImages.length > 4 && (
|
{allImages.length > 4 && (
|
||||||
<button
|
<button
|
||||||
@@ -434,11 +445,10 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={optIndex}
|
key={optIndex}
|
||||||
onClick={() => handleAttributeChange(attr.name, option)}
|
onClick={() => handleAttributeChange(attr.name, option)}
|
||||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||||
isSelected
|
|
||||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</button>
|
||||||
@@ -490,17 +500,15 @@ export default function Product() {
|
|||||||
Add to Cart
|
Add to Cart
|
||||||
</button>
|
</button>
|
||||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => product && toggleWishlist(product.id)}
|
onClick={() => product && toggleWishlist(product.id)}
|
||||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${
|
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||||
product && isInWishlist(product.id)
|
|
||||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Heart className={`h-5 w-5 ${
|
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||||
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
}`} />
|
||||||
}`} />
|
|
||||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -519,7 +527,7 @@ export default function Product() {
|
|||||||
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Returns */}
|
{/* Returns */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
||||||
@@ -530,7 +538,7 @@ export default function Product() {
|
|||||||
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secure */}
|
{/* Secure */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
||||||
@@ -562,13 +570,13 @@ export default function Product() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share Buttons */}
|
{/* Share Buttons */}
|
||||||
{elements.share_buttons && (
|
{elements.share_buttons && (
|
||||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||||
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||||
@@ -576,9 +584,9 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on Facebook"
|
title="Share on Facebook"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(product.name);
|
const text = encodeURIComponent(product.name);
|
||||||
@@ -587,9 +595,9 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on Twitter"
|
title="Share on Twitter"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(product.name);
|
const text = encodeURIComponent(product.name);
|
||||||
@@ -598,7 +606,7 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on WhatsApp"
|
title="Share on WhatsApp"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -682,160 +690,160 @@ export default function Product() {
|
|||||||
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
||||||
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
||||||
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
||||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||||
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
</svg>
|
</svg>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600 font-medium">
|
|
||||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? '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>
|
|
||||||
</button>
|
|
||||||
{activeTab === 'reviews' && (
|
|
||||||
<div className="p-6 bg-white space-y-6">
|
|
||||||
{/* Review Summary */}
|
|
||||||
<div className="flex items-start gap-8 pb-6 border-b">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
{[5, 4, 3, 2, 1].map((rating) => (
|
|
||||||
<div key={rating} className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
|
||||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-yellow-400"
|
|
||||||
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sample Reviews */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Review 1 */}
|
|
||||||
<div className="border-b pb-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
|
||||||
JD
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">John Doe</span>
|
|
||||||
<span className="text-sm text-gray-500">• 2 days ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
|
||||||
The packaging was also very professional. Highly recommend!
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<svg
|
||||||
{/* Review 2 */}
|
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? 'rotate-180' : ''}`}
|
||||||
<div className="border-b pb-6">
|
fill="none"
|
||||||
<div className="flex items-start gap-4">
|
stroke="currentColor"
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
viewBox="0 0 24 24"
|
||||||
SM
|
>
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
<div className="flex-1">
|
</svg>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
|
||||||
<span className="text-sm text-gray-500">• 1 week ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Great value for money. Works exactly as described. Customer service was also very responsive
|
|
||||||
when I had questions before purchasing.
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review 3 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
|
||||||
MJ
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
|
||||||
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
|
||||||
Will definitely buy again.
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
|
||||||
Load More Reviews
|
|
||||||
</button>
|
</button>
|
||||||
|
{activeTab === 'reviews' && (
|
||||||
|
<div className="p-6 bg-white space-y-6">
|
||||||
|
{/* Review Summary */}
|
||||||
|
<div className="flex items-start gap-8 pb-6 border-b">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => (
|
||||||
|
<div key={rating} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-yellow-400"
|
||||||
|
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Reviews */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Review 1 */}
|
||||||
|
<div className="border-b pb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
JD
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">John Doe</span>
|
||||||
|
<span className="text-sm text-gray-500">• 2 days ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
||||||
|
The packaging was also very professional. Highly recommend!
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review 2 */}
|
||||||
|
<div className="border-b pb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
SM
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
||||||
|
<span className="text-sm text-gray-500">• 1 week ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Great value for money. Works exactly as described. Customer service was also very responsive
|
||||||
|
when I had questions before purchasing.
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review 3 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
MJ
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
||||||
|
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
||||||
|
Will definitely buy again.
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
||||||
|
Load More Reviews
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Related Products */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
@@ -22,25 +23,25 @@ export default function Shop() {
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('');
|
const [sortBy, setSortBy] = useState('');
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
|
||||||
// Map grid columns setting to Tailwind classes (responsive)
|
// Map grid columns setting to Tailwind classes (responsive)
|
||||||
const gridCols = typeof shopLayout.grid_columns === 'object'
|
const gridCols = typeof shopLayout.grid_columns === 'object'
|
||||||
? shopLayout.grid_columns
|
? shopLayout.grid_columns
|
||||||
: { mobile: '2', tablet: '3', desktop: '4' };
|
: { mobile: '2', tablet: '3', desktop: '4' };
|
||||||
|
|
||||||
// Map to actual Tailwind classes (can't use template literals due to purging)
|
// Map to actual Tailwind classes (can't use template literals due to purging)
|
||||||
const mobileClass = {
|
const mobileClass = {
|
||||||
'1': 'grid-cols-1',
|
'1': 'grid-cols-1',
|
||||||
'2': 'grid-cols-2',
|
'2': 'grid-cols-2',
|
||||||
'3': 'grid-cols-3',
|
'3': 'grid-cols-3',
|
||||||
}[gridCols.mobile] || 'grid-cols-2';
|
}[gridCols.mobile] || 'grid-cols-2';
|
||||||
|
|
||||||
const tabletClass = {
|
const tabletClass = {
|
||||||
'2': 'md:grid-cols-2',
|
'2': 'md:grid-cols-2',
|
||||||
'3': 'md:grid-cols-3',
|
'3': 'md:grid-cols-3',
|
||||||
'4': 'md:grid-cols-4',
|
'4': 'md:grid-cols-4',
|
||||||
}[gridCols.tablet] || 'md:grid-cols-3';
|
}[gridCols.tablet] || 'md:grid-cols-3';
|
||||||
|
|
||||||
const desktopClass = {
|
const desktopClass = {
|
||||||
'2': 'lg:grid-cols-2',
|
'2': 'lg:grid-cols-2',
|
||||||
'3': 'lg:grid-cols-3',
|
'3': 'lg:grid-cols-3',
|
||||||
@@ -48,22 +49,22 @@ export default function Shop() {
|
|||||||
'5': 'lg:grid-cols-5',
|
'5': 'lg:grid-cols-5',
|
||||||
'6': 'lg:grid-cols-6',
|
'6': 'lg:grid-cols-6',
|
||||||
}[gridCols.desktop] || 'lg:grid-cols-4';
|
}[gridCols.desktop] || 'lg:grid-cols-4';
|
||||||
|
|
||||||
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
|
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
|
||||||
|
|
||||||
// Masonry column classes
|
// Masonry column classes
|
||||||
const masonryMobileClass = {
|
const masonryMobileClass = {
|
||||||
'1': 'columns-1',
|
'1': 'columns-1',
|
||||||
'2': 'columns-2',
|
'2': 'columns-2',
|
||||||
'3': 'columns-3',
|
'3': 'columns-3',
|
||||||
}[gridCols.mobile] || 'columns-2';
|
}[gridCols.mobile] || 'columns-2';
|
||||||
|
|
||||||
const masonryTabletClass = {
|
const masonryTabletClass = {
|
||||||
'2': 'md:columns-2',
|
'2': 'md:columns-2',
|
||||||
'3': 'md:columns-3',
|
'3': 'md:columns-3',
|
||||||
'4': 'md:columns-4',
|
'4': 'md:columns-4',
|
||||||
}[gridCols.tablet] || 'md:columns-3';
|
}[gridCols.tablet] || 'md:columns-3';
|
||||||
|
|
||||||
const masonryDesktopClass = {
|
const masonryDesktopClass = {
|
||||||
'2': 'lg:columns-2',
|
'2': 'lg:columns-2',
|
||||||
'3': 'lg:columns-3',
|
'3': 'lg:columns-3',
|
||||||
@@ -71,9 +72,9 @@ export default function Shop() {
|
|||||||
'5': 'lg:columns-5',
|
'5': 'lg:columns-5',
|
||||||
'6': 'lg:columns-6',
|
'6': 'lg:columns-6',
|
||||||
}[gridCols.desktop] || 'lg:columns-4';
|
}[gridCols.desktop] || 'lg:columns-4';
|
||||||
|
|
||||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||||
|
|
||||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||||
|
|
||||||
// Fetch products
|
// Fetch products
|
||||||
@@ -99,7 +100,7 @@ export default function Shop() {
|
|||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to local cart store
|
// Add to local cart store
|
||||||
addItem({
|
addItem({
|
||||||
key: `${product.id}`,
|
key: `${product.id}`,
|
||||||
@@ -111,7 +112,7 @@ export default function Shop() {
|
|||||||
virtual: product.virtual,
|
virtual: product.virtual,
|
||||||
downloadable: product.downloadable,
|
downloadable: product.downloadable,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`${product.name} added to cart!`, {
|
toast.success(`${product.name} added to cart!`, {
|
||||||
action: {
|
action: {
|
||||||
label: 'View Cart',
|
label: 'View Cart',
|
||||||
@@ -126,6 +127,11 @@ export default function Shop() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
{/* SEO Meta Tags for Social Sharing */}
|
||||||
|
<SEOHead
|
||||||
|
title="Shop"
|
||||||
|
description="Browse our collection of products"
|
||||||
|
/>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
||||||
|
|||||||
132
docs/_registry.php
Normal file
132
docs/_registry.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WooNooW Documentation Registry
|
||||||
|
*
|
||||||
|
* This file registers all core documentation.
|
||||||
|
* Addons can extend using the 'woonoow_docs_registry' filter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Docs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered documentation
|
||||||
|
*
|
||||||
|
* @return array Documentation registry
|
||||||
|
*/
|
||||||
|
function get_docs_registry() {
|
||||||
|
$docs_dir = dirname(__FILE__);
|
||||||
|
|
||||||
|
// Core WooNooW documentation
|
||||||
|
$docs = [
|
||||||
|
'core' => [
|
||||||
|
'label' => 'WooNooW',
|
||||||
|
'icon' => 'book-open',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'slug' => 'getting-started',
|
||||||
|
'title' => 'Getting Started',
|
||||||
|
'file' => $docs_dir . '/getting-started.md',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'installation',
|
||||||
|
'title' => 'Installation',
|
||||||
|
'file' => $docs_dir . '/installation.md',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'troubleshooting',
|
||||||
|
'title' => 'Troubleshooting',
|
||||||
|
'file' => $docs_dir . '/troubleshooting.md',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'faq',
|
||||||
|
'title' => 'FAQ',
|
||||||
|
'file' => $docs_dir . '/faq.md',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'configuration' => [
|
||||||
|
'label' => 'Configuration',
|
||||||
|
'icon' => 'settings',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'slug' => 'configuration/appearance',
|
||||||
|
'title' => 'Appearance Settings',
|
||||||
|
'file' => $docs_dir . '/configuration/appearance.md',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'configuration/spa-mode',
|
||||||
|
'title' => 'SPA Mode',
|
||||||
|
'file' => $docs_dir . '/configuration/spa-mode.md',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'features' => [
|
||||||
|
'label' => 'Features',
|
||||||
|
'icon' => 'layers',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'slug' => 'features/shop',
|
||||||
|
'title' => 'Shop Page',
|
||||||
|
'file' => $docs_dir . '/features/shop.md',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'features/checkout',
|
||||||
|
'title' => 'Checkout',
|
||||||
|
'file' => $docs_dir . '/features/checkout.md',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: woonoow_docs_registry
|
||||||
|
*
|
||||||
|
* Allows addons to register their own documentation.
|
||||||
|
*
|
||||||
|
* @param array $docs Current documentation registry
|
||||||
|
* @return array Modified documentation registry
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* add_filter('woonoow_docs_registry', function($docs) {
|
||||||
|
* $docs['my-addon'] = [
|
||||||
|
* 'label' => 'My Addon',
|
||||||
|
* 'icon' => 'puzzle',
|
||||||
|
* 'items' => [
|
||||||
|
* [
|
||||||
|
* 'slug' => 'my-addon/getting-started',
|
||||||
|
* 'title' => 'Getting Started',
|
||||||
|
* 'file' => __DIR__ . '/docs/getting-started.md',
|
||||||
|
* ],
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
|
* return $docs;
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
return apply_filters('woonoow_docs_registry', $docs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single documentation item by slug
|
||||||
|
*
|
||||||
|
* @param string $slug Document slug
|
||||||
|
* @return array|null Document data with content, or null if not found
|
||||||
|
*/
|
||||||
|
function get_doc_by_slug($slug) {
|
||||||
|
$registry = get_docs_registry();
|
||||||
|
|
||||||
|
foreach ($registry as $section) {
|
||||||
|
foreach ($section['items'] as $item) {
|
||||||
|
if ($item['slug'] === $slug) {
|
||||||
|
// Read file content
|
||||||
|
if (file_exists($item['file'])) {
|
||||||
|
$item['content'] = file_get_contents($item['file']);
|
||||||
|
} else {
|
||||||
|
$item['content'] = '# Document Not Found\n\nThis document is coming soon.';
|
||||||
|
}
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
133
docs/configuration/appearance.md
Normal file
133
docs/configuration/appearance.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Appearance Settings
|
||||||
|
|
||||||
|
Customize the look and feel of your WooNooW store.
|
||||||
|
|
||||||
|
## Accessing Appearance Settings
|
||||||
|
|
||||||
|
Go to **WooNooW → Appearance** in the WordPress admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Settings
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
|
||||||
|
Upload your store logo for display in the header.
|
||||||
|
|
||||||
|
- **Recommended size**: 200x60 pixels (width x height)
|
||||||
|
- **Formats**: PNG (transparent background recommended), SVG, JPG
|
||||||
|
- **Mobile**: Automatically resized for smaller screens
|
||||||
|
|
||||||
|
### SPA Page
|
||||||
|
|
||||||
|
Select which page hosts the WooNooW SPA. Default is "Store".
|
||||||
|
|
||||||
|
> **Note**: This page should contain the `[woonoow_spa]` shortcode.
|
||||||
|
|
||||||
|
### SPA Mode
|
||||||
|
|
||||||
|
Choose how WooNooW handles your store pages:
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **Full** | All WooCommerce pages redirect to SPA |
|
||||||
|
| **Disabled** | Native WooCommerce templates are used |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
### Primary Color
|
||||||
|
|
||||||
|
The main brand color used for:
|
||||||
|
- Buttons
|
||||||
|
- Links
|
||||||
|
- Active states
|
||||||
|
- Primary actions
|
||||||
|
|
||||||
|
**Default**: `#6366f1` (Indigo)
|
||||||
|
|
||||||
|
### Secondary Color
|
||||||
|
|
||||||
|
Secondary UI elements:
|
||||||
|
- Less prominent buttons
|
||||||
|
- Borders
|
||||||
|
- Subtle backgrounds
|
||||||
|
|
||||||
|
**Default**: `#64748b` (Slate)
|
||||||
|
|
||||||
|
### Accent Color
|
||||||
|
|
||||||
|
Highlight color for:
|
||||||
|
- Sale badges
|
||||||
|
- Notifications
|
||||||
|
- Call-to-action elements
|
||||||
|
|
||||||
|
**Default**: `#f59e0b` (Amber)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Body Font
|
||||||
|
|
||||||
|
Font used for general text content.
|
||||||
|
|
||||||
|
**Options**: System fonts and Google Fonts
|
||||||
|
- Inter
|
||||||
|
- Open Sans
|
||||||
|
- Roboto
|
||||||
|
- Lato
|
||||||
|
- Poppins
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
### Heading Font
|
||||||
|
|
||||||
|
Font used for titles and headings.
|
||||||
|
|
||||||
|
**Options**: Same as body fonts, plus:
|
||||||
|
- Cormorant Garamond (Serif option)
|
||||||
|
- Playfair Display
|
||||||
|
- Merriweather
|
||||||
|
|
||||||
|
### Font Sizes
|
||||||
|
|
||||||
|
Font sizes are responsive and adjust automatically based on screen size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Container Width
|
||||||
|
|
||||||
|
Maximum width of the content area.
|
||||||
|
|
||||||
|
| Option | Width |
|
||||||
|
|--------|-------|
|
||||||
|
| Narrow | 1024px |
|
||||||
|
| Default | 1280px |
|
||||||
|
| Wide | 1536px |
|
||||||
|
| Full | 100% |
|
||||||
|
|
||||||
|
### Header Style
|
||||||
|
|
||||||
|
Configure the header appearance:
|
||||||
|
- **Fixed**: Stays at top when scrolling
|
||||||
|
- **Static**: Scrolls with page
|
||||||
|
|
||||||
|
### Product Grid
|
||||||
|
|
||||||
|
Columns in the shop page grid:
|
||||||
|
- Mobile: 1-2 columns
|
||||||
|
- Tablet: 2-3 columns
|
||||||
|
- Desktop: 3-4 columns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Saving Changes
|
||||||
|
|
||||||
|
1. Make your changes
|
||||||
|
2. Click **Save Changes** button
|
||||||
|
3. Refresh your store page to see updates
|
||||||
|
|
||||||
|
> **Tip**: Open your store in another tab to preview changes quickly.
|
||||||
139
docs/configuration/spa-mode.md
Normal file
139
docs/configuration/spa-mode.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# SPA Mode
|
||||||
|
|
||||||
|
Understanding and configuring WooNooW's SPA (Single Page Application) mode.
|
||||||
|
|
||||||
|
## What is SPA Mode?
|
||||||
|
|
||||||
|
SPA Mode controls how WooNooW handles your WooCommerce pages. It determines whether visitors experience the modern SPA interface or traditional WooCommerce templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Modes
|
||||||
|
|
||||||
|
### Full Mode (Recommended)
|
||||||
|
|
||||||
|
**All WooCommerce pages redirect to the SPA.**
|
||||||
|
|
||||||
|
When a visitor navigates to:
|
||||||
|
- `/shop` → Redirects to `/store/shop`
|
||||||
|
- `/product/example` → Redirects to `/store/product/example`
|
||||||
|
- `/cart` → Redirects to `/store/cart`
|
||||||
|
- `/checkout` → Redirects to `/store/checkout`
|
||||||
|
- `/my-account` → Redirects to `/store/my-account`
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Instant page transitions
|
||||||
|
- Modern, consistent UI
|
||||||
|
- Better mobile experience
|
||||||
|
- Smooth animations
|
||||||
|
|
||||||
|
**Best for**:
|
||||||
|
- New stores
|
||||||
|
- Stores wanting a modern look
|
||||||
|
- Mobile-focused businesses
|
||||||
|
|
||||||
|
### Disabled Mode
|
||||||
|
|
||||||
|
**WooCommerce uses its native templates.**
|
||||||
|
|
||||||
|
WooCommerce pages work normally with your theme's templates. WooNooW admin features still work, but the customer-facing SPA is turned off.
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Keep existing theme customizations
|
||||||
|
- Compatibility with WooCommerce template overrides
|
||||||
|
- Traditional page-by-page navigation
|
||||||
|
|
||||||
|
**Best for**:
|
||||||
|
- Stores with heavy theme customizations
|
||||||
|
- Testing before full rollout
|
||||||
|
- Troubleshooting issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Switching Modes
|
||||||
|
|
||||||
|
### How to Switch
|
||||||
|
|
||||||
|
1. Go to **WooNooW → Appearance → General**
|
||||||
|
2. Find **SPA Mode** setting
|
||||||
|
3. Select your preferred mode
|
||||||
|
4. Click **Save Changes**
|
||||||
|
|
||||||
|
### What Happens When Switching
|
||||||
|
|
||||||
|
**Switching to Full**:
|
||||||
|
- WooCommerce pages start redirecting
|
||||||
|
- SPA loads for shop experience
|
||||||
|
- No data is changed
|
||||||
|
|
||||||
|
**Switching to Disabled**:
|
||||||
|
- Redirects stop immediately
|
||||||
|
- WooCommerce templates take over
|
||||||
|
- No data is changed
|
||||||
|
|
||||||
|
> **Note**: All your products, orders, and settings remain unchanged when switching modes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL Structure
|
||||||
|
|
||||||
|
### Full Mode URLs
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yourstore.com/store/ → Home/Shop
|
||||||
|
https://yourstore.com/store/shop → Shop page
|
||||||
|
https://yourstore.com/store/product/slug → Product page
|
||||||
|
https://yourstore.com/store/cart → Cart
|
||||||
|
https://yourstore.com/store/checkout → Checkout
|
||||||
|
https://yourstore.com/store/my-account → Account
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disabled Mode URLs
|
||||||
|
|
||||||
|
Standard WooCommerce URLs:
|
||||||
|
```
|
||||||
|
https://yourstore.com/shop/ → Shop page
|
||||||
|
https://yourstore.com/product/slug → Product page
|
||||||
|
https://yourstore.com/cart/ → Cart
|
||||||
|
https://yourstore.com/checkout/ → Checkout
|
||||||
|
https://yourstore.com/my-account/ → Account
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEO Considerations
|
||||||
|
|
||||||
|
### Full Mode SEO
|
||||||
|
|
||||||
|
- WooCommerce URLs (`/product/slug`) remain in sitemaps
|
||||||
|
- When users click from search results, they're redirected to SPA
|
||||||
|
- Meta tags are generated dynamically for social sharing
|
||||||
|
- 302 (temporary) redirects preserve link equity
|
||||||
|
|
||||||
|
### Disabled Mode SEO
|
||||||
|
|
||||||
|
- Standard WooCommerce SEO applies
|
||||||
|
- No redirects needed
|
||||||
|
- Works with Yoast SEO, RankMath, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Redirects Not Working
|
||||||
|
|
||||||
|
1. **Flush Permalinks**: Go to Settings → Permalinks → Save Changes
|
||||||
|
2. **Check Store Page**: Ensure the Store page exists and has `[woonoow_spa]`
|
||||||
|
3. **Clear Cache**: Purge all caching layers
|
||||||
|
|
||||||
|
### Blank Pages After Enabling
|
||||||
|
|
||||||
|
1. Verify SPA Mode is set to "Full"
|
||||||
|
2. Clear browser cache
|
||||||
|
3. Check for JavaScript errors in browser console
|
||||||
|
|
||||||
|
### Want to Test Before Enabling
|
||||||
|
|
||||||
|
1. Keep mode as "Disabled"
|
||||||
|
2. Visit `/store/` directly to preview SPA
|
||||||
|
3. Switch to "Full" when satisfied
|
||||||
149
docs/faq.md
Normal file
149
docs/faq.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Frequently Asked Questions
|
||||||
|
|
||||||
|
Quick answers to common questions about WooNooW.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
### What is WooNooW?
|
||||||
|
|
||||||
|
WooNooW is a WooCommerce plugin that transforms your store into a modern Single Page Application (SPA). It provides instant page loads, a beautiful UI, and seamless shopping experience.
|
||||||
|
|
||||||
|
### Do I need WooCommerce?
|
||||||
|
|
||||||
|
Yes. WooNooW is an enhancement layer for WooCommerce. You need WooCommerce installed and activated.
|
||||||
|
|
||||||
|
### Will WooNooW affect my existing products?
|
||||||
|
|
||||||
|
No. WooNooW reads from WooCommerce. Your products, orders, and settings remain untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPA Mode
|
||||||
|
|
||||||
|
### What's the difference between Full and Disabled mode?
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| **Full** | All WooCommerce pages redirect to SPA. Modern, fast experience. |
|
||||||
|
| **Disabled** | WooCommerce pages use native templates. WooNooW admin still works. |
|
||||||
|
|
||||||
|
### Can I switch modes anytime?
|
||||||
|
|
||||||
|
Yes. Go to **WooNooW → Appearance → General** and change the SPA Mode. Changes take effect immediately.
|
||||||
|
|
||||||
|
### Which mode should I use?
|
||||||
|
|
||||||
|
- **Full**: For the best customer experience with instant loads
|
||||||
|
- **Disabled**: If you have theme customizations you want to keep
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
### Does WooNooW work with my theme?
|
||||||
|
|
||||||
|
WooNooW's SPA is independent of your WordPress theme. In Full mode, the SPA uses its own styling. Your theme affects the rest of your site normally.
|
||||||
|
|
||||||
|
### Does WooNooW work with page builders?
|
||||||
|
|
||||||
|
The SPA pages are self-contained. Page builders work on other pages of your site.
|
||||||
|
|
||||||
|
### Which payment gateways are supported?
|
||||||
|
|
||||||
|
WooNooW supports all WooCommerce-compatible payment gateways:
|
||||||
|
- PayPal
|
||||||
|
- Stripe
|
||||||
|
- Bank Transfer (BACS)
|
||||||
|
- Cash on Delivery
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
### Is WooNooW SEO-friendly?
|
||||||
|
|
||||||
|
Yes. WooNooW uses:
|
||||||
|
- Clean URLs (`/store/product/product-name`)
|
||||||
|
- Dynamic meta tags for social sharing
|
||||||
|
- Proper redirects (302) from WooCommerce URLs
|
||||||
|
|
||||||
|
### What about my existing SEO?
|
||||||
|
|
||||||
|
WooCommerce URLs remain the indexed source. WooNooW redirects users to the SPA but preserves SEO value.
|
||||||
|
|
||||||
|
### Will my product pages be indexed?
|
||||||
|
|
||||||
|
Yes. Search engines index the WooCommerce URLs. When users click from search results, they're redirected to the fast SPA experience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Is WooNooW faster than regular WooCommerce?
|
||||||
|
|
||||||
|
Yes, for navigation. After the initial load, page transitions are instant because the SPA doesn't reload the entire page.
|
||||||
|
|
||||||
|
### Will WooNooW slow down my site?
|
||||||
|
|
||||||
|
The initial load is similar to regular WooCommerce. Subsequent navigation is much faster.
|
||||||
|
|
||||||
|
### Does WooNooW work with caching?
|
||||||
|
|
||||||
|
Yes. Use page caching and object caching for best results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Can I customize colors and fonts?
|
||||||
|
|
||||||
|
Yes. Go to **WooNooW → Appearance** to customize:
|
||||||
|
- Primary, secondary, and accent colors
|
||||||
|
- Body and heading fonts
|
||||||
|
- Logo and layout options
|
||||||
|
|
||||||
|
### Can I add custom CSS?
|
||||||
|
|
||||||
|
Currently, use your theme's Additional CSS feature. A custom CSS field may be added in future versions.
|
||||||
|
|
||||||
|
### Can I modify the SPA templates?
|
||||||
|
|
||||||
|
The SPA is built with React. Advanced customizations require development knowledge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Addons
|
||||||
|
|
||||||
|
### What are WooNooW addons?
|
||||||
|
|
||||||
|
Addons extend WooNooW with additional features like loyalty points, advanced analytics, etc.
|
||||||
|
|
||||||
|
### How do I install addons?
|
||||||
|
|
||||||
|
Addons are installed as separate WordPress plugins. They integrate automatically with WooNooW.
|
||||||
|
|
||||||
|
### Do addons work when SPA is disabled?
|
||||||
|
|
||||||
|
Most addon features are for the SPA. When disabled, addon functionality may be limited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### I see a blank page. What do I do?
|
||||||
|
|
||||||
|
1. Check SPA Mode is set to "Full"
|
||||||
|
2. Flush permalinks (**Settings → Permalinks → Save**)
|
||||||
|
3. Clear all caches
|
||||||
|
4. See [Troubleshooting](troubleshooting) for more
|
||||||
|
|
||||||
|
### How do I report a bug?
|
||||||
|
|
||||||
|
Contact support with:
|
||||||
|
- Steps to reproduce the issue
|
||||||
|
- WordPress/WooCommerce/WooNooW versions
|
||||||
|
- Any error messages
|
||||||
|
- Screenshots if applicable
|
||||||
145
docs/features/checkout.md
Normal file
145
docs/features/checkout.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Checkout
|
||||||
|
|
||||||
|
The WooNooW checkout provides a streamlined purchasing experience.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The checkout process includes:
|
||||||
|
|
||||||
|
1. **Cart Review** - Verify items before checkout
|
||||||
|
2. **Customer Information** - Billing and shipping details
|
||||||
|
3. **Payment Method** - Select how to pay
|
||||||
|
4. **Order Confirmation** - Complete the purchase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkout Flow
|
||||||
|
|
||||||
|
### Step 1: Cart
|
||||||
|
|
||||||
|
Before checkout, customers review their cart:
|
||||||
|
- Product list with images
|
||||||
|
- Quantity adjustments
|
||||||
|
- Remove items
|
||||||
|
- Apply coupon codes
|
||||||
|
- See subtotal, shipping, and total
|
||||||
|
|
||||||
|
### Step 2: Customer Details
|
||||||
|
|
||||||
|
Customers provide:
|
||||||
|
- **Email address**
|
||||||
|
- **Billing information**
|
||||||
|
- Name
|
||||||
|
- Address
|
||||||
|
- Phone
|
||||||
|
- **Shipping address** (if different from billing)
|
||||||
|
|
||||||
|
> **Note**: Logged-in customers have their details pre-filled.
|
||||||
|
|
||||||
|
### Step 3: Shipping Method
|
||||||
|
|
||||||
|
If physical products are in the cart:
|
||||||
|
- Available shipping methods are shown
|
||||||
|
- Shipping cost is calculated
|
||||||
|
- Customer selects preferred method
|
||||||
|
|
||||||
|
### Step 4: Payment
|
||||||
|
|
||||||
|
Customers choose their payment method:
|
||||||
|
- Credit/Debit Card (Stripe, PayPal, etc.)
|
||||||
|
- Bank Transfer
|
||||||
|
- Cash on Delivery
|
||||||
|
- Other configured gateways
|
||||||
|
|
||||||
|
### Step 5: Place Order
|
||||||
|
|
||||||
|
After reviewing everything:
|
||||||
|
- Click "Place Order"
|
||||||
|
- Payment is processed
|
||||||
|
- Confirmation page is shown
|
||||||
|
- Email receipt is sent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Guest Checkout
|
||||||
|
|
||||||
|
Allow customers to checkout without creating an account.
|
||||||
|
|
||||||
|
Configure in **WooCommerce → Settings → Accounts & Privacy**.
|
||||||
|
|
||||||
|
### Coupon Codes
|
||||||
|
|
||||||
|
Customers can apply discount codes:
|
||||||
|
1. Enter code in the coupon field
|
||||||
|
2. Click "Apply"
|
||||||
|
3. Discount is reflected in total
|
||||||
|
|
||||||
|
### Order Notes
|
||||||
|
|
||||||
|
Optional field for customers to add special instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Payment Gateways
|
||||||
|
|
||||||
|
### Supported Gateways
|
||||||
|
|
||||||
|
WooNooW supports all WooCommerce payment gateways:
|
||||||
|
|
||||||
|
| Gateway | Type |
|
||||||
|
|---------|------|
|
||||||
|
| Bank Transfer (BACS) | Manual |
|
||||||
|
| Check Payments | Manual |
|
||||||
|
| Cash on Delivery | Manual |
|
||||||
|
| PayPal | Card / PayPal |
|
||||||
|
| Stripe | Card |
|
||||||
|
| Square | Card |
|
||||||
|
|
||||||
|
### Configuring Gateways
|
||||||
|
|
||||||
|
1. Go to **WooNooW → Settings → Payments**
|
||||||
|
2. Enable desired payment methods
|
||||||
|
3. Configure API keys and settings
|
||||||
|
4. Test with sandbox/test mode first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Checkout
|
||||||
|
|
||||||
|
### Order Confirmation Page
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Order number
|
||||||
|
- Order summary
|
||||||
|
- Next steps
|
||||||
|
|
||||||
|
### Confirmation Email
|
||||||
|
|
||||||
|
Automatically sent to customer with:
|
||||||
|
- Order details
|
||||||
|
- Payment confirmation
|
||||||
|
- Shipping information (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Place Order" Button Not Working
|
||||||
|
|
||||||
|
1. Check all required fields are filled
|
||||||
|
2. Verify payment gateway is properly configured
|
||||||
|
3. Check browser console for JavaScript errors
|
||||||
|
|
||||||
|
### Payment Declined
|
||||||
|
|
||||||
|
1. Customer should verify card details
|
||||||
|
2. Check payment gateway dashboard for error details
|
||||||
|
3. Ensure correct API keys are configured
|
||||||
|
|
||||||
|
### Shipping Not Showing
|
||||||
|
|
||||||
|
1. Verify shipping zones are configured in WooCommerce
|
||||||
|
2. Check if products have weight/dimensions set
|
||||||
|
3. Confirm customer's address is in a configured zone
|
||||||
96
docs/features/shop.md
Normal file
96
docs/features/shop.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Shop Page
|
||||||
|
|
||||||
|
The shop page displays your product catalog with browsing and filtering options.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The WooNooW shop page provides:
|
||||||
|
|
||||||
|
- **Product Grid** - Visual display of products
|
||||||
|
- **Search** - Find products by name
|
||||||
|
- **Filters** - Category and sorting options
|
||||||
|
- **Pagination** - Navigate through products
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Product Cards
|
||||||
|
|
||||||
|
Each product displays:
|
||||||
|
- Product image
|
||||||
|
- Product name
|
||||||
|
- Price (with sale price if applicable)
|
||||||
|
- Add to Cart button
|
||||||
|
- Wishlist button (if enabled)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Type in the search box to filter products by name. Search is instant and updates the grid as you type.
|
||||||
|
|
||||||
|
### Category Filter
|
||||||
|
|
||||||
|
Filter products by category using the dropdown. Shows:
|
||||||
|
- All Categories
|
||||||
|
- Individual categories with product count
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
|
||||||
|
Sort products by:
|
||||||
|
- Default sorting
|
||||||
|
- Popularity
|
||||||
|
- Average rating
|
||||||
|
- Latest
|
||||||
|
- Price: Low to High
|
||||||
|
- Price: High to Low
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Grid Layout
|
||||||
|
|
||||||
|
Configure the product grid in **WooNooW → Appearance**:
|
||||||
|
|
||||||
|
| Device | Options |
|
||||||
|
|--------|---------|
|
||||||
|
| Mobile | 1-2 columns |
|
||||||
|
| Tablet | 2-4 columns |
|
||||||
|
| Desktop | 2-6 columns |
|
||||||
|
|
||||||
|
### Product Card Style
|
||||||
|
|
||||||
|
Product cards can display:
|
||||||
|
- **Image** - Product featured image
|
||||||
|
- **Title** - Product name
|
||||||
|
- **Price** - Current price and sale price
|
||||||
|
- **Rating** - Star rating (if reviews enabled)
|
||||||
|
- **Add to Cart** - Quick add button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Clicking a Product
|
||||||
|
|
||||||
|
Clicking a product card navigates to the full product page where customers can:
|
||||||
|
- View all images
|
||||||
|
- Select variations
|
||||||
|
- Read description
|
||||||
|
- Add to cart
|
||||||
|
|
||||||
|
### Back to Shop
|
||||||
|
|
||||||
|
From any product page, use the breadcrumb or browser back button to return to the shop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Lazy Loading
|
||||||
|
|
||||||
|
Product images load as they come into view, improving initial page load time.
|
||||||
|
|
||||||
|
### Infinite Scroll vs Pagination
|
||||||
|
|
||||||
|
Currently uses pagination. Infinite scroll may be added in future versions.
|
||||||
54
docs/getting-started.md
Normal file
54
docs/getting-started.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Getting Started with WooNooW
|
||||||
|
|
||||||
|
Welcome to WooNooW! This guide will help you get up and running quickly.
|
||||||
|
|
||||||
|
## What is WooNooW?
|
||||||
|
|
||||||
|
WooNooW transforms your WooCommerce store into a modern, fast Single Page Application (SPA). It provides:
|
||||||
|
|
||||||
|
- ⚡ **Instant Page Loads** - No page refreshes between navigation
|
||||||
|
- 🎨 **Modern UI** - Beautiful, responsive design out of the box
|
||||||
|
- 🛠 **Easy Customization** - Configure colors, fonts, and layout from admin
|
||||||
|
- 📱 **Mobile-First** - Optimized for all devices
|
||||||
|
|
||||||
|
## Quick Setup (3 Steps)
|
||||||
|
|
||||||
|
### Step 1: Activate the Plugin
|
||||||
|
|
||||||
|
After installing WooNooW, activate it from **Plugins → Installed Plugins**.
|
||||||
|
|
||||||
|
The plugin will automatically:
|
||||||
|
- Create a "Store" page for the SPA
|
||||||
|
- Configure basic settings
|
||||||
|
|
||||||
|
### Step 2: Access Admin Dashboard
|
||||||
|
|
||||||
|
Go to **WooNooW** in your WordPress admin menu.
|
||||||
|
|
||||||
|
You'll see the admin dashboard with:
|
||||||
|
- Orders management
|
||||||
|
- Settings configuration
|
||||||
|
- Appearance customization
|
||||||
|
|
||||||
|
### Step 3: Configure Your Store
|
||||||
|
|
||||||
|
Navigate to **Appearance** settings to:
|
||||||
|
|
||||||
|
1. **Upload your logo**
|
||||||
|
2. **Set brand colors** (primary, secondary, accent)
|
||||||
|
3. **Choose fonts** for headings and body text
|
||||||
|
4. **Configure SPA mode** (Full or Disabled)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Installation Guide](installation) - Detailed installation instructions
|
||||||
|
- [Appearance Settings](configuration/appearance) - Customize your store's look
|
||||||
|
- [SPA Mode](configuration/spa-mode) - Understand Full vs Disabled mode
|
||||||
|
- [Troubleshooting](troubleshooting) - Common issues and solutions
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
If you encounter any issues:
|
||||||
|
1. Check the [Troubleshooting](troubleshooting) guide
|
||||||
|
2. Review the [FAQ](faq)
|
||||||
|
3. Contact support with your WordPress and WooCommerce versions
|
||||||
92
docs/installation.md
Normal file
92
docs/installation.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Installation Guide
|
||||||
|
|
||||||
|
This guide covers installing WooNooW on your WordPress site.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Before installing, ensure your site meets these requirements:
|
||||||
|
|
||||||
|
| Requirement | Minimum | Recommended |
|
||||||
|
|-------------|---------|-------------|
|
||||||
|
| WordPress | 6.0+ | Latest |
|
||||||
|
| WooCommerce | 7.0+ | Latest |
|
||||||
|
| PHP | 7.4+ | 8.1+ |
|
||||||
|
| MySQL | 5.7+ | 8.0+ |
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
### Method 1: WordPress Admin (Recommended)
|
||||||
|
|
||||||
|
1. Go to **Plugins → Add New**
|
||||||
|
2. Click **Upload Plugin**
|
||||||
|
3. Select the `woonoow.zip` file
|
||||||
|
4. Click **Install Now**
|
||||||
|
5. Click **Activate**
|
||||||
|
|
||||||
|
### Method 2: FTP Upload
|
||||||
|
|
||||||
|
1. Extract `woonoow.zip` to get the `woonoow` folder
|
||||||
|
2. Upload to `/wp-content/plugins/`
|
||||||
|
3. Go to **Plugins → Installed Plugins**
|
||||||
|
4. Find WooNooW and click **Activate**
|
||||||
|
|
||||||
|
## Post-Installation
|
||||||
|
|
||||||
|
After activation, WooNooW automatically:
|
||||||
|
|
||||||
|
### 1. Creates Store Page
|
||||||
|
A new "Store" page is created with the SPA shortcode. This is your main storefront.
|
||||||
|
|
||||||
|
### 2. Registers Rewrite Rules
|
||||||
|
URL routes like `/store/shop` and `/store/product/...` are registered.
|
||||||
|
|
||||||
|
> **Note**: If you see 404 errors, go to **Settings → Permalinks** and click **Save Changes** to flush rewrite rules.
|
||||||
|
|
||||||
|
### 3. Sets Default Configuration
|
||||||
|
Basic appearance settings are configured with sensible defaults.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After installation, verify everything works:
|
||||||
|
|
||||||
|
- [ ] Plugin activated without errors
|
||||||
|
- [ ] WooNooW menu appears in admin sidebar
|
||||||
|
- [ ] Store page exists (check **Pages**)
|
||||||
|
- [ ] `/store` URL loads the SPA
|
||||||
|
- [ ] Products display on shop page
|
||||||
|
|
||||||
|
## WooCommerce Compatibility
|
||||||
|
|
||||||
|
WooNooW works alongside WooCommerce:
|
||||||
|
|
||||||
|
| WooCommerce Page | WooNooW Behavior (Full Mode) |
|
||||||
|
|------------------|------------------------------|
|
||||||
|
| `/shop` | Redirects to `/store/shop` |
|
||||||
|
| `/product/...` | Redirects to `/store/product/...` |
|
||||||
|
| `/cart` | Redirects to `/store/cart` |
|
||||||
|
| `/checkout` | Redirects to `/store/checkout` |
|
||||||
|
| `/my-account` | Redirects to `/store/my-account` |
|
||||||
|
|
||||||
|
When SPA Mode is **Disabled**, WooCommerce pages work normally.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
To update WooNooW:
|
||||||
|
|
||||||
|
1. Download the latest version
|
||||||
|
2. Go to **Plugins → Installed Plugins**
|
||||||
|
3. Deactivate WooNooW (optional but recommended)
|
||||||
|
4. Delete the old version
|
||||||
|
5. Install and activate the new version
|
||||||
|
|
||||||
|
Your settings are preserved in the database.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
To completely remove WooNooW:
|
||||||
|
|
||||||
|
1. Deactivate the plugin (restores WooCommerce page content)
|
||||||
|
2. Delete the plugin
|
||||||
|
3. (Optional) Delete WooNooW options from database
|
||||||
|
|
||||||
|
> **Note**: Deactivating restores original WooCommerce shortcodes to Cart, Checkout, and My Account pages.
|
||||||
173
docs/troubleshooting.md
Normal file
173
docs/troubleshooting.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Common issues and their solutions.
|
||||||
|
|
||||||
|
## Blank Pages
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
WooCommerce pages (shop, cart, checkout) show blank content.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Check SPA Mode Setting**
|
||||||
|
- Go to **WooNooW → Appearance → General**
|
||||||
|
- Ensure **SPA Mode** is set to "Full"
|
||||||
|
- If you want native WooCommerce, set to "Disabled"
|
||||||
|
|
||||||
|
**2. Flush Permalinks**
|
||||||
|
- Go to **Settings → Permalinks**
|
||||||
|
- Click **Save Changes** (no changes needed)
|
||||||
|
- This refreshes rewrite rules
|
||||||
|
|
||||||
|
**3. Clear Cache**
|
||||||
|
If using a caching plugin:
|
||||||
|
- Clear page cache
|
||||||
|
- Clear object cache
|
||||||
|
- Purge CDN cache (if applicable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 404 Errors on SPA Routes
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
Visiting `/store/shop` or `/store/product/...` shows a 404 error.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Flush Permalinks**
|
||||||
|
- Go to **Settings → Permalinks**
|
||||||
|
- Click **Save Changes**
|
||||||
|
|
||||||
|
**2. Check Store Page Exists**
|
||||||
|
- Go to **Pages**
|
||||||
|
- Verify "Store" page exists and is published
|
||||||
|
- The page should contain `[woonoow_spa]` shortcode
|
||||||
|
|
||||||
|
**3. Check SPA Page Setting**
|
||||||
|
- Go to **WooNooW → Appearance → General**
|
||||||
|
- Ensure **SPA Page** is set to the Store page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Images Not Loading
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
Products show placeholder images instead of actual images.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Regenerate Thumbnails**
|
||||||
|
- Install "Regenerate Thumbnails" plugin
|
||||||
|
- Run regeneration for all images
|
||||||
|
|
||||||
|
**2. Check Image URLs**
|
||||||
|
- Ensure images have valid URLs
|
||||||
|
- Check for mixed content (HTTP vs HTTPS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slow Performance
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
SPA feels slow or laggy.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Enable Caching**
|
||||||
|
- Install a caching plugin (WP Super Cache, W3 Total Cache)
|
||||||
|
- Enable object caching (Redis/Memcached)
|
||||||
|
|
||||||
|
**2. Optimize Images**
|
||||||
|
- Use WebP format
|
||||||
|
- Compress images before upload
|
||||||
|
- Use lazy loading
|
||||||
|
|
||||||
|
**3. Check Server Resources**
|
||||||
|
- Upgrade hosting if on shared hosting
|
||||||
|
- Consider VPS or managed WordPress hosting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkout Not Working
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
Checkout page won't load or payment fails.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Check Payment Gateway**
|
||||||
|
- Go to **WooCommerce → Settings → Payments**
|
||||||
|
- Verify payment method is enabled
|
||||||
|
- Check API credentials
|
||||||
|
|
||||||
|
**2. Check SSL Certificate**
|
||||||
|
- Checkout requires HTTPS
|
||||||
|
- Verify SSL is properly installed
|
||||||
|
|
||||||
|
**3. Check for JavaScript Errors**
|
||||||
|
- Open browser Developer Tools (F12)
|
||||||
|
- Check Console for errors
|
||||||
|
- Look for blocked scripts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emails Not Sending
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
Order confirmation emails not being received.
|
||||||
|
|
||||||
|
### Solutions
|
||||||
|
|
||||||
|
**1. Check Email Settings**
|
||||||
|
- Go to **WooNooW → Settings → Notifications**
|
||||||
|
- Verify email types are enabled
|
||||||
|
|
||||||
|
**2. Check WordPress Email**
|
||||||
|
- Test with a plugin like "Check & Log Email"
|
||||||
|
- Consider using SMTP plugin (WP Mail SMTP)
|
||||||
|
|
||||||
|
**3. Check Spam Folder**
|
||||||
|
- Emails may be in recipient's spam folder
|
||||||
|
- Add sender to whitelist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Conflicts
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
WooNooW doesn't work after installing another plugin.
|
||||||
|
|
||||||
|
### Steps to Diagnose
|
||||||
|
|
||||||
|
1. **Deactivate other plugins** one by one
|
||||||
|
2. **Switch to default theme** (Twenty Twenty-Three)
|
||||||
|
3. **Check error logs** in `wp-content/debug.log`
|
||||||
|
|
||||||
|
### Common Conflicting Plugins
|
||||||
|
|
||||||
|
- Other WooCommerce template overrides
|
||||||
|
- Page builder plugins (sometimes)
|
||||||
|
- Heavy caching plugins (misconfigured)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting More Help
|
||||||
|
|
||||||
|
If you can't resolve the issue:
|
||||||
|
|
||||||
|
1. **Collect Information**
|
||||||
|
- WordPress version
|
||||||
|
- WooCommerce version
|
||||||
|
- WooNooW version
|
||||||
|
- PHP version
|
||||||
|
- Error messages (from debug.log)
|
||||||
|
|
||||||
|
2. **Enable Debug Mode**
|
||||||
|
Add to `wp-config.php`:
|
||||||
|
```php
|
||||||
|
define('WP_DEBUG', true);
|
||||||
|
define('WP_DEBUG_LOG', true);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Contact Support**
|
||||||
|
Provide the collected information for faster resolution.
|
||||||
@@ -17,7 +17,6 @@ class Assets
|
|||||||
{
|
{
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($hook !== 'toplevel_page_woonoow') {
|
if ($hook !== 'toplevel_page_woonoow') {
|
||||||
@@ -32,7 +31,6 @@ class Assets
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($is_dev) {
|
if ($is_dev) {
|
||||||
@@ -155,11 +153,6 @@ class Assets
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[WooNooW Assets] Dist dir: ' . $dist_dir);
|
|
||||||
error_log('[WooNooW Assets] CSS exists: ' . (file_exists($dist_dir . $css) ? 'yes' : 'no'));
|
|
||||||
error_log('[WooNooW Assets] JS exists: ' . (file_exists($dist_dir . $js) ? 'yes' : 'no'));
|
|
||||||
error_log('[WooNooW Assets] CSS URL: ' . $base_url . $css);
|
|
||||||
error_log('[WooNooW Assets] JS URL: ' . $base_url . $js);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file_exists($dist_dir . $css)) {
|
if (file_exists($dist_dir . $css)) {
|
||||||
@@ -286,7 +279,6 @@ class Assets
|
|||||||
|
|
||||||
// Debug logging (only if WP_DEBUG is enabled)
|
// Debug logging (only if WP_DEBUG is enabled)
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
||||||
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bool) $filtered;
|
return (bool) $filtered;
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ class StandaloneAdmin {
|
|||||||
|
|
||||||
// Debug logging (only in WP_DEBUG mode)
|
// Debug logging (only in WP_DEBUG mode)
|
||||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||||
error_log( '[StandaloneAdmin] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
|
|
||||||
error_log( '[StandaloneAdmin] has manage_woocommerce: ' . ( $has_permission ? 'true' : 'false' ) );
|
|
||||||
error_log( '[StandaloneAdmin] is_authenticated: ' . ( $is_authenticated ? 'true' : 'false' ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get nonce for REST API
|
// Get nonce for REST API
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ class AuthController {
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||||
error_log( '[AuthController::login] Login successful for user ID: ' . $user->ID );
|
|
||||||
error_log( '[AuthController::login] Current user ID: ' . get_current_user_id() );
|
|
||||||
error_log( '[AuthController::login] Cookies set: ' . ( headers_sent() ? 'Headers already sent!' : 'OK' ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return user data and new nonce
|
// Return user data and new nonce
|
||||||
@@ -154,8 +151,6 @@ class AuthController {
|
|||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||||
error_log( '[AuthController::check] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
|
|
||||||
error_log( '[AuthController::check] Cookies: ' . print_r( $_COOKIE, true ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! $is_logged_in ) {
|
if ( ! $is_logged_in ) {
|
||||||
|
|||||||
129
includes/Api/DocsController.php
Normal file
129
includes/Api/DocsController.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Documentation API Controller
|
||||||
|
*
|
||||||
|
* Serves documentation content to the Admin SPA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
|
use WP_REST_Controller;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
class DocsController extends WP_REST_Controller {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namespace for REST routes
|
||||||
|
*/
|
||||||
|
protected $namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base route
|
||||||
|
*/
|
||||||
|
protected $rest_base = 'docs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register routes
|
||||||
|
*/
|
||||||
|
public function register_routes() {
|
||||||
|
// GET /woonoow/v1/docs - List all documentation
|
||||||
|
register_rest_route($this->namespace, '/' . $this->rest_base, [
|
||||||
|
[
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [$this, 'get_docs_registry'],
|
||||||
|
'permission_callback' => [$this, 'check_permissions'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// GET /woonoow/v1/docs/{slug} - Get single document
|
||||||
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<slug>.+)', [
|
||||||
|
[
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [$this, 'get_doc'],
|
||||||
|
'permission_callback' => [$this, 'check_permissions'],
|
||||||
|
'args' => [
|
||||||
|
'slug' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check permissions - any logged in admin user
|
||||||
|
*/
|
||||||
|
public function check_permissions($request) {
|
||||||
|
return current_user_can('manage_options');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get documentation registry
|
||||||
|
*
|
||||||
|
* @return WP_REST_Response
|
||||||
|
*/
|
||||||
|
public function get_docs_registry($request) {
|
||||||
|
require_once WOONOOW_PATH . 'docs/_registry.php';
|
||||||
|
|
||||||
|
$registry = \WooNooW\Docs\get_docs_registry();
|
||||||
|
|
||||||
|
// Transform to frontend format (without file paths)
|
||||||
|
$result = [];
|
||||||
|
foreach ($registry as $section_key => $section) {
|
||||||
|
$items = [];
|
||||||
|
foreach ($section['items'] as $item) {
|
||||||
|
$items[] = [
|
||||||
|
'slug' => $item['slug'],
|
||||||
|
'title' => $item['title'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'key' => $section_key,
|
||||||
|
'label' => $section['label'],
|
||||||
|
'icon' => $section['icon'] ?? 'file-text',
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'sections' => $result,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single document content
|
||||||
|
*
|
||||||
|
* @param WP_REST_Request $request
|
||||||
|
* @return WP_REST_Response|WP_Error
|
||||||
|
*/
|
||||||
|
public function get_doc($request) {
|
||||||
|
$slug = $request->get_param('slug');
|
||||||
|
|
||||||
|
require_once WOONOOW_PATH . 'docs/_registry.php';
|
||||||
|
|
||||||
|
$doc = \WooNooW\Docs\get_doc_by_slug($slug);
|
||||||
|
|
||||||
|
if (!$doc) {
|
||||||
|
return new WP_Error(
|
||||||
|
'doc_not_found',
|
||||||
|
'Documentation not found',
|
||||||
|
['status' => 404]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'doc' => [
|
||||||
|
'slug' => $doc['slug'],
|
||||||
|
'title' => $doc['title'],
|
||||||
|
'content' => $doc['content'],
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -770,16 +770,13 @@ class OrdersController {
|
|||||||
if ( null !== $status && $status !== '' ) {
|
if ( null !== $status && $status !== '' ) {
|
||||||
$order_id = $order->get_id();
|
$order_id = $order->get_id();
|
||||||
add_action( 'shutdown', function() use ( $order_id, $status ) {
|
add_action( 'shutdown', function() use ( $order_id, $status ) {
|
||||||
error_log('[WooNooW] Shutdown hook firing - scheduling email for order #' . $order_id);
|
|
||||||
self::schedule_order_email( $order_id, $status );
|
self::schedule_order_email( $order_id, $status );
|
||||||
error_log('[WooNooW] Email scheduled successfully for order #' . $order_id);
|
|
||||||
}, 999 );
|
}, 999 );
|
||||||
}
|
}
|
||||||
|
|
||||||
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
|
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
// Log the actual error for debugging
|
// Log the actual error for debugging
|
||||||
error_log('[WooNooW] Order update failed: ' . $e->getMessage());
|
|
||||||
|
|
||||||
// Return user-friendly error message
|
// Return user-friendly error message
|
||||||
return new \WP_REST_Response( [
|
return new \WP_REST_Response( [
|
||||||
@@ -797,13 +794,11 @@ class OrdersController {
|
|||||||
public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) {
|
public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) {
|
||||||
// Skip if we're in an API request (we schedule manually there)
|
// Skip if we're in an API request (we schedule manually there)
|
||||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||||
error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule email notification with 15s delay
|
// Schedule email notification with 15s delay
|
||||||
self::schedule_order_email( $order_id, $status_to );
|
self::schedule_order_email( $order_id, $status_to );
|
||||||
error_log('[WooNooW] Order #' . $order_id . ' status changed: ' . $status_from . ' → ' . $status_to . ', email scheduled');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1042,7 +1037,6 @@ class OrdersController {
|
|||||||
$order->apply_coupon( $coupon );
|
$order->apply_coupon( $coupon );
|
||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
error_log( '[WooNooW] Coupon error: ' . $e->getMessage() );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,7 +1228,6 @@ class OrdersController {
|
|||||||
|
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
// Log the actual error for debugging
|
// Log the actual error for debugging
|
||||||
error_log('[WooNooW] Order creation failed: ' . $e->getMessage());
|
|
||||||
|
|
||||||
// Return user-friendly error message
|
// Return user-friendly error message
|
||||||
return new \WP_REST_Response( [
|
return new \WP_REST_Response( [
|
||||||
@@ -2025,7 +2018,6 @@ class OrdersController {
|
|||||||
|
|
||||||
// Check if gateway exists
|
// Check if gateway exists
|
||||||
if ( ! isset( $gateways[ $gateway_id ] ) ) {
|
if ( ! isset( $gateways[ $gateway_id ] ) ) {
|
||||||
error_log( '[WooNooW] Payment gateway not found: ' . $gateway_id );
|
|
||||||
return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) );
|
return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2033,7 +2025,6 @@ class OrdersController {
|
|||||||
|
|
||||||
// Check if gateway has process_payment method
|
// Check if gateway has process_payment method
|
||||||
if ( ! method_exists( $gateway, 'process_payment' ) ) {
|
if ( ! method_exists( $gateway, 'process_payment' ) ) {
|
||||||
error_log( '[WooNooW] Gateway does not have process_payment method: ' . $gateway_id );
|
|
||||||
return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) );
|
return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2045,7 +2036,6 @@ class OrdersController {
|
|||||||
// Set flag for gateways to detect admin context
|
// Set flag for gateways to detect admin context
|
||||||
add_filter( 'woonoow/is_admin_order', '__return_true' );
|
add_filter( 'woonoow/is_admin_order', '__return_true' );
|
||||||
|
|
||||||
error_log( '[WooNooW] Processing payment for order #' . $order->get_id() . ' with gateway: ' . $gateway_id );
|
|
||||||
|
|
||||||
// Call gateway's process_payment method
|
// Call gateway's process_payment method
|
||||||
$result = $gateway->process_payment( $order->get_id() );
|
$result = $gateway->process_payment( $order->get_id() );
|
||||||
@@ -2061,11 +2051,9 @@ class OrdersController {
|
|||||||
|
|
||||||
if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
|
if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
|
||||||
$order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) );
|
$order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) );
|
||||||
error_log( '[WooNooW] Payment processing succeeded for order #' . $order->get_id() );
|
|
||||||
} elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) {
|
} elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) {
|
||||||
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
|
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
|
||||||
$order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) );
|
$order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) );
|
||||||
error_log( '[WooNooW] Payment processing failed for order #' . $order->get_id() . ': ' . $message );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$order->save();
|
$order->save();
|
||||||
@@ -2074,7 +2062,6 @@ class OrdersController {
|
|||||||
return $result;
|
return $result;
|
||||||
|
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
error_log( '[WooNooW] Payment processing exception for order #' . $order->get_id() . ': ' . $e->getMessage() );
|
|
||||||
$order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) );
|
$order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) );
|
||||||
$order->save();
|
$order->save();
|
||||||
|
|
||||||
|
|||||||
@@ -212,12 +212,10 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Debug: Log what we're saving
|
// Debug: Log what we're saving
|
||||||
error_log(sprintf('[WooNooW] Saving gateway %s settings: %s', $gateway_id, json_encode($settings)));
|
|
||||||
|
|
||||||
$result = PaymentGatewaysProvider::save_gateway_settings($gateway_id, $settings);
|
$result = PaymentGatewaysProvider::save_gateway_settings($gateway_id, $settings);
|
||||||
|
|
||||||
if (is_wp_error($result)) {
|
if (is_wp_error($result)) {
|
||||||
error_log(sprintf('[WooNooW] Save failed: %s', $result->get_error_message()));
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +226,6 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||||
|
|
||||||
// Debug: Log success
|
// Debug: Log success
|
||||||
error_log(sprintf('[WooNooW] Gateway %s settings saved successfully', $gateway_id));
|
|
||||||
|
|
||||||
return rest_ensure_response([
|
return rest_ensure_response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -236,7 +233,6 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
'gateway' => $gateway,
|
'gateway' => $gateway,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log(sprintf('[WooNooW] Save exception: %s', $e->getMessage()));
|
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'save_gateway_failed',
|
'save_gateway_failed',
|
||||||
$e->getMessage(),
|
$e->getMessage(),
|
||||||
@@ -268,12 +264,10 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Debug: Log what we're trying to do
|
// Debug: Log what we're trying to do
|
||||||
error_log(sprintf('[WooNooW] Toggling gateway %s to %s', $gateway_id, $enabled ? 'enabled' : 'disabled'));
|
|
||||||
|
|
||||||
$result = PaymentGatewaysProvider::toggle_gateway($gateway_id, $enabled);
|
$result = PaymentGatewaysProvider::toggle_gateway($gateway_id, $enabled);
|
||||||
|
|
||||||
if (is_wp_error($result)) {
|
if (is_wp_error($result)) {
|
||||||
error_log(sprintf('[WooNooW] Toggle failed: %s', $result->get_error_message()));
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +278,6 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||||
|
|
||||||
// Debug: Log what we got back
|
// Debug: Log what we got back
|
||||||
error_log(sprintf('[WooNooW] Gateway %s after toggle: enabled=%s', $gateway_id, $gateway['enabled'] ? 'true' : 'false'));
|
|
||||||
|
|
||||||
return rest_ensure_response([
|
return rest_ensure_response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -292,7 +285,6 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
'gateway' => $gateway,
|
'gateway' => $gateway,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log(sprintf('[WooNooW] Toggle exception: %s', $e->getMessage()));
|
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'toggle_gateway_failed',
|
'toggle_gateway_failed',
|
||||||
$e->getMessage(),
|
$e->getMessage(),
|
||||||
@@ -333,7 +325,6 @@ class PaymentsController extends WP_REST_Controller {
|
|||||||
$option_key = 'woonoow_payment_gateway_order_' . $category;
|
$option_key = 'woonoow_payment_gateway_order_' . $category;
|
||||||
update_option($option_key, $order, false);
|
update_option($option_key, $order, false);
|
||||||
|
|
||||||
error_log(sprintf('[WooNooW] Saved %s gateway order: %s', $category, implode(', ', $order)));
|
|
||||||
|
|
||||||
return rest_ensure_response([
|
return rest_ensure_response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use WooNooW\Api\NewsletterController;
|
|||||||
use WooNooW\Api\ModulesController;
|
use WooNooW\Api\ModulesController;
|
||||||
use WooNooW\Api\ModuleSettingsController;
|
use WooNooW\Api\ModuleSettingsController;
|
||||||
use WooNooW\Api\CampaignsController;
|
use WooNooW\Api\CampaignsController;
|
||||||
|
use WooNooW\Api\DocsController;
|
||||||
use WooNooW\Frontend\ShopController;
|
use WooNooW\Frontend\ShopController;
|
||||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||||
use WooNooW\Frontend\AccountController;
|
use WooNooW\Frontend\AccountController;
|
||||||
@@ -165,6 +166,10 @@ class Routes {
|
|||||||
$module_settings_controller = new ModuleSettingsController();
|
$module_settings_controller = new ModuleSettingsController();
|
||||||
$module_settings_controller->register_routes();
|
$module_settings_controller->register_routes();
|
||||||
|
|
||||||
|
// Documentation controller
|
||||||
|
$docs_controller = new DocsController();
|
||||||
|
$docs_controller->register_routes();
|
||||||
|
|
||||||
// Frontend controllers (customer-facing)
|
// Frontend controllers (customer-facing)
|
||||||
ShopController::register_routes();
|
ShopController::register_routes();
|
||||||
FrontendCartController::register_routes();
|
FrontendCartController::register_routes();
|
||||||
|
|||||||
@@ -454,7 +454,6 @@ class ShippingController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
|
|
||||||
} catch ( \Exception $e ) {
|
} catch ( \Exception $e ) {
|
||||||
error_log( sprintf( '[WooNooW] Toggle exception: %s', $e->getMessage() ) );
|
|
||||||
return new WP_REST_Response(
|
return new WP_REST_Response(
|
||||||
array(
|
array(
|
||||||
'error' => 'toggle_failed',
|
'error' => 'toggle_failed',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CustomerSettingsProvider {
|
|||||||
// General
|
// General
|
||||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||||
|
'allow_custom_avatar' => get_option('woonoow_allow_custom_avatar', 'no') === 'yes',
|
||||||
|
|
||||||
// VIP Customer Qualification
|
// VIP Customer Qualification
|
||||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||||
@@ -49,8 +50,10 @@ class CustomerSettingsProvider {
|
|||||||
update_option('woonoow_multiple_addresses_enabled', $value);
|
update_option('woonoow_multiple_addresses_enabled', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('allow_custom_avatar', $settings)) {
|
||||||
|
$value = !empty($settings['allow_custom_avatar']) ? 'yes' : 'no';
|
||||||
|
update_option('woonoow_allow_custom_avatar', $value);
|
||||||
|
}
|
||||||
// VIP settings
|
// VIP settings
|
||||||
if (isset($settings['vip_min_spent'])) {
|
if (isset($settings['vip_min_spent'])) {
|
||||||
update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
|
|||||||
*/
|
*/
|
||||||
class NavigationRegistry {
|
class NavigationRegistry {
|
||||||
const NAV_OPTION = 'wnw_nav_tree';
|
const NAV_OPTION = 'wnw_nav_tree';
|
||||||
const NAV_VERSION = '1.0.8'; // Added Modules to Settings menu
|
const NAV_VERSION = '1.0.9'; // Added Help menu
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize hooks
|
* Initialize hooks
|
||||||
@@ -186,6 +186,13 @@ class NavigationRegistry {
|
|||||||
'icon' => 'settings',
|
'icon' => 'settings',
|
||||||
'children' => self::get_settings_children(),
|
'children' => self::get_settings_children(),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'key' => 'help',
|
||||||
|
'label' => __('Help', 'woonoow'),
|
||||||
|
'path' => '/help',
|
||||||
|
'icon' => 'help-circle',
|
||||||
|
'children' => [], // Empty array = no submenu bar
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $tree;
|
return $tree;
|
||||||
|
|||||||
@@ -391,24 +391,15 @@ class PaymentGatewaysProvider {
|
|||||||
|
|
||||||
$new_settings = array_merge($current_settings, $settings);
|
$new_settings = array_merge($current_settings, $settings);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
error_log(sprintf('[WooNooW] Saving gateway %s settings: %s', $gateway_id, json_encode($settings)));
|
|
||||||
error_log(sprintf('[WooNooW] Current enabled: %s, New enabled: %s',
|
|
||||||
isset($current_settings['enabled']) ? $current_settings['enabled'] : 'not set',
|
|
||||||
isset($new_settings['enabled']) ? $new_settings['enabled'] : 'not set'
|
|
||||||
));
|
|
||||||
|
|
||||||
// Update gateway settings directly
|
// Update gateway settings directly
|
||||||
$gateway->settings = $new_settings;
|
$gateway->settings = $new_settings;
|
||||||
|
|
||||||
// Save to database using WooCommerce's method
|
// Save to database using WooCommerce's method
|
||||||
$saved = update_option($gateway->get_option_key(), $gateway->settings, 'yes');
|
$saved = update_option($gateway->get_option_key(), $gateway->settings, 'yes');
|
||||||
error_log(sprintf('[WooNooW] update_option returned: %s', $saved ? 'true' : 'false'));
|
|
||||||
|
|
||||||
// Update the enabled property specifically (WooCommerce does this)
|
// Update the enabled property specifically (WooCommerce does this)
|
||||||
if (isset($new_settings['enabled'])) {
|
if (isset($new_settings['enabled'])) {
|
||||||
$gateway->enabled = $new_settings['enabled'];
|
$gateway->enabled = $new_settings['enabled'];
|
||||||
error_log(sprintf('[WooNooW] Set gateway->enabled to: %s', $gateway->enabled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-enable HTTP requests
|
// Re-enable HTTP requests
|
||||||
|
|||||||
@@ -23,106 +23,56 @@ class Installer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update WooNooW pages
|
* Create WooNooW SPA page only
|
||||||
* Smart detection: reuses existing WooCommerce pages if they exist
|
* No longer modifies WooCommerce pages - we use template overrides instead
|
||||||
*/
|
*/
|
||||||
private static function create_pages() {
|
private static function create_pages() {
|
||||||
$pages = [
|
// Only create the main SPA page (Store)
|
||||||
'shop' => [
|
// WooCommerce pages are NOT modified - we use template overrides
|
||||||
'title' => 'Shop',
|
|
||||||
'content' => '[woonoow_shop]',
|
|
||||||
'wc_option' => 'woocommerce_shop_page_id',
|
|
||||||
],
|
|
||||||
'cart' => [
|
|
||||||
'title' => 'Cart',
|
|
||||||
'content' => '[woonoow_cart]',
|
|
||||||
'wc_option' => 'woocommerce_cart_page_id',
|
|
||||||
],
|
|
||||||
'checkout' => [
|
|
||||||
'title' => 'Checkout',
|
|
||||||
'content' => '[woonoow_checkout]',
|
|
||||||
'wc_option' => 'woocommerce_checkout_page_id',
|
|
||||||
],
|
|
||||||
'account' => [
|
|
||||||
'title' => 'My Account',
|
|
||||||
'content' => '[woonoow_account]',
|
|
||||||
'wc_option' => 'woocommerce_myaccount_page_id',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($pages as $key => $page_data) {
|
$spa_page_id = get_option('woonoow_store_page_id');
|
||||||
$page_id = null;
|
|
||||||
|
// Check if SPA page already exists
|
||||||
|
if ($spa_page_id && get_post($spa_page_id)) {
|
||||||
|
return; // Already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for existing "Store" page
|
||||||
|
$existing_page = get_page_by_title('Store', OBJECT, 'page');
|
||||||
|
if ($existing_page) {
|
||||||
|
$spa_page_id = $existing_page->ID;
|
||||||
|
|
||||||
// Strategy 1: Check if WooCommerce already has a page set
|
// Update with SPA shortcode if needed
|
||||||
if (isset($page_data['wc_option'])) {
|
if (!has_shortcode($existing_page->post_content, 'woonoow_spa')) {
|
||||||
$wc_page_id = get_option($page_data['wc_option']);
|
update_post_meta($spa_page_id, '_woonoow_original_content', $existing_page->post_content);
|
||||||
if ($wc_page_id && get_post($wc_page_id)) {
|
wp_update_post([
|
||||||
$page_id = $wc_page_id;
|
'ID' => $spa_page_id,
|
||||||
error_log("WooNooW: Found existing WooCommerce {$page_data['title']} page (ID: {$page_id})");
|
'post_content' => '[woonoow_spa]',
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Check if WooNooW already created a page
|
|
||||||
if (!$page_id) {
|
|
||||||
$woonoow_page_id = get_option('woonoow_' . $key . '_page_id');
|
|
||||||
if ($woonoow_page_id && get_post($woonoow_page_id)) {
|
|
||||||
$page_id = $woonoow_page_id;
|
|
||||||
error_log("WooNooW: Found existing WooNooW {$page_data['title']} page (ID: {$page_id})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Search for page by title
|
|
||||||
if (!$page_id) {
|
|
||||||
$existing_page = get_page_by_title($page_data['title'], OBJECT, 'page');
|
|
||||||
if ($existing_page) {
|
|
||||||
$page_id = $existing_page->ID;
|
|
||||||
error_log("WooNooW: Found existing {$page_data['title']} page by title (ID: {$page_id})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If page exists, update its content with our shortcode
|
|
||||||
if ($page_id) {
|
|
||||||
$current_post = get_post($page_id);
|
|
||||||
|
|
||||||
// Only update if it doesn't already have our shortcode
|
|
||||||
if (!has_shortcode($current_post->post_content, 'woonoow_' . $key)) {
|
|
||||||
// Backup original content
|
|
||||||
update_post_meta($page_id, '_woonoow_original_content', $current_post->post_content);
|
|
||||||
|
|
||||||
// Update with our shortcode
|
|
||||||
wp_update_post([
|
|
||||||
'ID' => $page_id,
|
|
||||||
'post_content' => $page_data['content'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
error_log("WooNooW: Updated {$page_data['title']} page with WooNooW shortcode");
|
|
||||||
} else {
|
|
||||||
error_log("WooNooW: {$page_data['title']} page already has WooNooW shortcode");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No existing page found, create new one
|
|
||||||
$page_id = wp_insert_post([
|
|
||||||
'post_title' => $page_data['title'],
|
|
||||||
'post_content' => $page_data['content'],
|
|
||||||
'post_status' => 'publish',
|
|
||||||
'post_type' => 'page',
|
|
||||||
'post_author' => get_current_user_id(),
|
|
||||||
'comment_status' => 'closed',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($page_id && !is_wp_error($page_id)) {
|
|
||||||
error_log("WooNooW: Created new {$page_data['title']} page (ID: {$page_id})");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Create new SPA page
|
||||||
|
$spa_page_id = wp_insert_post([
|
||||||
|
'post_title' => 'Store',
|
||||||
|
'post_content' => '[woonoow_spa]',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_author' => get_current_user_id() ?: 1,
|
||||||
|
'comment_status' => 'closed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save SPA page ID
|
||||||
|
if ($spa_page_id && !is_wp_error($spa_page_id)) {
|
||||||
|
update_option('woonoow_store_page_id', $spa_page_id);
|
||||||
|
|
||||||
// Store page ID and update WooCommerce settings
|
// Also set it in appearance settings
|
||||||
if ($page_id && !is_wp_error($page_id)) {
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
update_option('woonoow_' . $key . '_page_id', $page_id);
|
if (!isset($appearance_settings['general'])) {
|
||||||
|
$appearance_settings['general'] = [];
|
||||||
if (isset($page_data['wc_option'])) {
|
|
||||||
update_option($page_data['wc_option'], $page_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
$appearance_settings['general']['spa_page'] = $spa_page_id;
|
||||||
|
update_option('woonoow_appearance_settings', $appearance_settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +112,6 @@ class Installer {
|
|||||||
// Remove backup
|
// Remove backup
|
||||||
delete_post_meta($page_id, '_woonoow_original_content');
|
delete_post_meta($page_id, '_woonoow_original_content');
|
||||||
|
|
||||||
error_log("WooNooW: Restored original content for page ID: {$page_id}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class EmailManager {
|
|||||||
*/
|
*/
|
||||||
public function send_order_processing_email($order_id, $order = null) {
|
public function send_order_processing_email($order_id, $order = null) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] send_order_processing_email triggered for order #' . $order_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$order) {
|
if (!$order) {
|
||||||
@@ -128,7 +127,6 @@ class EmailManager {
|
|||||||
|
|
||||||
if (!$order) {
|
if (!$order) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Order not found for ID: ' . $order_id);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,13 +134,11 @@ class EmailManager {
|
|||||||
// Check if event is enabled
|
// Check if event is enabled
|
||||||
if (!$this->is_event_enabled('order_processing', 'email', 'customer')) {
|
if (!$this->is_event_enabled('order_processing', 'email', 'customer')) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] order_processing email is disabled in settings');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Sending order_processing email for order #' . $order_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
@@ -331,6 +327,7 @@ class EmailManager {
|
|||||||
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
if ($spa_page_id > 0) {
|
if ($spa_page_id > 0) {
|
||||||
$spa_url = get_permalink($spa_page_id);
|
$spa_url = get_permalink($spa_page_id);
|
||||||
@@ -339,9 +336,15 @@ class EmailManager {
|
|||||||
$spa_url = home_url('/');
|
$spa_url = home_url('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SPA reset password URL with hash router format
|
// Build SPA reset password URL
|
||||||
// Format: /store/#/reset-password?key=KEY&login=LOGIN
|
// Use path format for BrowserRouter (SEO), hash format for HashRouter (legacy)
|
||||||
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
if ($use_browser_router) {
|
||||||
|
// Path format: /store/reset-password?key=KEY&login=LOGIN
|
||||||
|
$reset_link = trailingslashit($spa_url) . 'reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||||
|
} else {
|
||||||
|
// Hash 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
|
// Create a pseudo WC_Customer for template rendering
|
||||||
$customer = null;
|
$customer = null;
|
||||||
@@ -390,7 +393,6 @@ class EmailManager {
|
|||||||
|
|
||||||
if (!$email) {
|
if (!$email) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Password reset email rendering failed for user: ' . $user->user_login);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -404,7 +406,6 @@ class EmailManager {
|
|||||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||||
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Password reset email sent to ' . $email['to'] . ' - Result: ' . ($sent ? 'success' : 'failed'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log email sent
|
// Log email sent
|
||||||
@@ -477,7 +478,6 @@ class EmailManager {
|
|||||||
// This allows the plugin to work out-of-the-box with default templates
|
// This allows the plugin to work out-of-the-box with default templates
|
||||||
if ($channel_id === 'email') {
|
if ($channel_id === 'email') {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Event not configured, using default: enabled');
|
|
||||||
}
|
}
|
||||||
return true; // Enable by default
|
return true; // Enable by default
|
||||||
}
|
}
|
||||||
@@ -495,7 +495,6 @@ class EmailManager {
|
|||||||
*/
|
*/
|
||||||
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
|
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] send_email called - Event: ' . $event_id . ', Recipient: ' . $recipient_type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get email renderer
|
// Get email renderer
|
||||||
@@ -506,13 +505,11 @@ class EmailManager {
|
|||||||
|
|
||||||
if (!$email) {
|
if (!$email) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Email rendering failed for event: ' . $event_id);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] Email rendered successfully - To: ' . $email['to'] . ', Subject: ' . $email['subject']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email via wp_mail
|
// Send email via wp_mail
|
||||||
@@ -524,7 +521,6 @@ class EmailManager {
|
|||||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||||
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailManager] wp_mail called - Result: ' . ($sent ? 'success' : 'failed'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log email sent
|
// Log email sent
|
||||||
|
|||||||
@@ -86,13 +86,11 @@ class EmailRenderer {
|
|||||||
|
|
||||||
if (!$template) {
|
if (!$template) {
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailRenderer] No template found for event: ' . $event_id . ', recipient: ' . $recipient_type);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
error_log('[EmailRenderer] Template found - Subject: ' . ($template['subject'] ?? 'no subject'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get design template preference
|
// Get design template preference
|
||||||
@@ -259,7 +257,17 @@ class EmailRenderer {
|
|||||||
// Generate login URL (pointing to SPA login instead of wp-login)
|
// Generate login URL (pointing to SPA login instead of wp-login)
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
$login_url = $spa_page_id ? get_permalink($spa_page_id) . '#/login' : wp_login_url();
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
|
if ($spa_page_id) {
|
||||||
|
$spa_url = get_permalink($spa_page_id);
|
||||||
|
// Use path format for BrowserRouter, hash format for HashRouter
|
||||||
|
$login_url = $use_browser_router
|
||||||
|
? trailingslashit($spa_url) . 'login'
|
||||||
|
: $spa_url . '#/login';
|
||||||
|
} else {
|
||||||
|
$login_url = wp_login_url();
|
||||||
|
}
|
||||||
|
|
||||||
$variables = array_merge($variables, [
|
$variables = array_merge($variables, [
|
||||||
'customer_id' => $data->get_id(),
|
'customer_id' => $data->get_id(),
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ class PushNotificationHandler {
|
|||||||
self::queue_notification($subscription_id, $payload);
|
self::queue_notification($subscription_id, $payload);
|
||||||
$sent++;
|
$sent++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('Push notification error: ' . $e->getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,18 +194,29 @@ class Assets {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine SPA base path for BrowserRouter
|
||||||
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
||||||
|
$base_path = $spa_page ? '/' . $spa_page->post_name : '/store';
|
||||||
|
|
||||||
|
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||||
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
$config = [
|
$config = [
|
||||||
'apiUrl' => rest_url('woonoow/v1'),
|
'apiUrl' => rest_url('woonoow/v1'),
|
||||||
'apiRoot' => rest_url('woonoow/v1'),
|
'apiRoot' => rest_url('woonoow/v1'),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'siteUrl' => get_site_url(),
|
'siteUrl' => get_site_url(),
|
||||||
'siteTitle' => get_bloginfo('name'),
|
'siteTitle' => get_bloginfo('name'),
|
||||||
|
'siteName' => get_bloginfo('name'),
|
||||||
'storeName' => get_bloginfo('name'),
|
'storeName' => get_bloginfo('name'),
|
||||||
'storeLogo' => $logo_url,
|
'storeLogo' => $logo_url,
|
||||||
'user' => $user_data,
|
'user' => $user_data,
|
||||||
'theme' => $theme_settings,
|
'theme' => $theme_settings,
|
||||||
'currency' => $currency_settings,
|
'currency' => $currency_settings,
|
||||||
'appearanceSettings' => $appearance_settings,
|
'appearanceSettings' => $appearance_settings,
|
||||||
|
'basePath' => $base_path,
|
||||||
|
'useBrowserRouter' => $use_browser_router,
|
||||||
];
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ class TemplateOverride
|
|||||||
*/
|
*/
|
||||||
public static function init()
|
public static function init()
|
||||||
{
|
{
|
||||||
|
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
|
||||||
|
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
||||||
|
|
||||||
|
// Flush rewrite rules when relevant settings change
|
||||||
|
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
|
||||||
|
$old_general = $old_value['general'] ?? [];
|
||||||
|
$new_general = $new_value['general'] ?? [];
|
||||||
|
|
||||||
|
// Only flush if spa_mode, spa_page, or use_browser_router changed
|
||||||
|
$needs_flush =
|
||||||
|
($old_general['spa_mode'] ?? '') !== ($new_general['spa_mode'] ?? '') ||
|
||||||
|
($old_general['spa_page'] ?? '') !== ($new_general['spa_page'] ?? '') ||
|
||||||
|
($old_general['use_browser_router'] ?? true) !== ($new_general['use_browser_router'] ?? true);
|
||||||
|
|
||||||
|
if ($needs_flush) {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||||
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||||
|
|
||||||
@@ -46,6 +65,44 @@ class TemplateOverride
|
|||||||
add_action('get_header', [__CLASS__, 'remove_theme_header']);
|
add_action('get_header', [__CLASS__, 'remove_theme_header']);
|
||||||
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
|
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register rewrite rules for BrowserRouter SEO
|
||||||
|
* Catches all /store/* routes and serves the SPA page
|
||||||
|
*/
|
||||||
|
public static function register_spa_rewrite_rules()
|
||||||
|
{
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
|
||||||
|
// Check if BrowserRouter is enabled (default: true for new installs)
|
||||||
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
|
if (!$spa_page_id || !$use_browser_router) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$spa_page = get_post($spa_page_id);
|
||||||
|
if (!$spa_page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$spa_slug = $spa_page->post_name;
|
||||||
|
|
||||||
|
// Rewrite /store/anything to serve the SPA page
|
||||||
|
// React Router handles the path after that
|
||||||
|
add_rewrite_rule(
|
||||||
|
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
|
||||||
|
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
|
||||||
|
'top'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register query var for the SPA path
|
||||||
|
add_filter('query_vars', function($vars) {
|
||||||
|
$vars[] = 'woonoow_spa_path';
|
||||||
|
return $vars;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
|
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
|
||||||
@@ -98,13 +155,20 @@ class TemplateOverride
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect WooCommerce pages to SPA routes
|
* Redirect WooCommerce pages to SPA routes
|
||||||
* Maps: /shop → /store/#/, /cart → /store/#/cart, etc.
|
* Maps: /shop → /store/, /cart → /store/cart, etc.
|
||||||
*/
|
*/
|
||||||
public static function redirect_wc_pages_to_spa()
|
public static function redirect_wc_pages_to_spa()
|
||||||
{
|
{
|
||||||
// Get SPA page URL
|
// Get SPA settings
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
|
// Only redirect when SPA mode is 'full'
|
||||||
|
if ($spa_mode !== 'full') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$spa_page_id) {
|
if (!$spa_page_id) {
|
||||||
return; // No SPA page configured
|
return; // No SPA page configured
|
||||||
@@ -118,33 +182,44 @@ class TemplateOverride
|
|||||||
|
|
||||||
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
$spa_url = trailingslashit(get_permalink($spa_page_id));
|
||||||
|
|
||||||
|
// Helper function to build route URL based on router type
|
||||||
|
$build_route = function($path) use ($spa_url, $use_browser_router) {
|
||||||
|
if ($use_browser_router) {
|
||||||
|
// Path format: /store/cart
|
||||||
|
return $spa_url . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
// Hash format: /store/#/cart
|
||||||
|
return rtrim($spa_url, '/') . '#/' . ltrim($path, '/');
|
||||||
|
};
|
||||||
|
|
||||||
// Check which WC page we're on and redirect
|
// Check which WC page we're on and redirect
|
||||||
if (is_shop()) {
|
if (is_shop()) {
|
||||||
wp_redirect($spa_url . '#/', 302);
|
wp_redirect($build_route('shop'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_product()) {
|
if (is_product()) {
|
||||||
global $product;
|
// Use get_queried_object() which returns the WP_Post, then get slug
|
||||||
if ($product) {
|
$product_post = get_queried_object();
|
||||||
$slug = $product->get_slug();
|
if ($product_post && isset($product_post->post_name)) {
|
||||||
wp_redirect($spa_url . '#/products/' . $slug, 302);
|
$slug = $product_post->post_name;
|
||||||
|
wp_redirect($build_route('product/' . $slug), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_cart()) {
|
if (is_cart()) {
|
||||||
wp_redirect($spa_url . '#/cart', 302);
|
wp_redirect($build_route('cart'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_checkout() && !is_order_received_page()) {
|
if (is_checkout() && !is_order_received_page()) {
|
||||||
wp_redirect($spa_url . '#/checkout', 302);
|
wp_redirect($build_route('checkout'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_account_page()) {
|
if (is_account_page()) {
|
||||||
wp_redirect($spa_url . '#/account', 302);
|
wp_redirect($build_route('my-account'), 302);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +256,15 @@ class TemplateOverride
|
|||||||
*/
|
*/
|
||||||
public static function use_spa_template($template)
|
public static function use_spa_template($template)
|
||||||
{
|
{
|
||||||
|
// Check spa_mode from appearance settings FIRST
|
||||||
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
|
// If SPA is disabled, return original template immediately
|
||||||
|
if ($spa_mode === 'disabled') {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if current page is a designated SPA page
|
// Check if current page is a designated SPA page
|
||||||
if (self::is_spa_page()) {
|
if (self::is_spa_page()) {
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
@@ -189,89 +273,8 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: Check SPA mode settings
|
// For spa_mode = 'full', override WooCommerce pages
|
||||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
if ($spa_mode === 'full') {
|
||||||
$mode = isset($settings['mode']) ? $settings['mode'] : '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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if current URL is a SPA route (for direct access)
|
|
||||||
$request_uri = $_SERVER['REQUEST_URI'];
|
|
||||||
$spa_routes = ['/shop', '/product/', '/cart', '/checkout', '/my-account'];
|
|
||||||
$is_spa_route = false;
|
|
||||||
|
|
||||||
foreach ($spa_routes as $route) {
|
|
||||||
if (strpos($request_uri, $route) !== false) {
|
|
||||||
$is_spa_route = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's a SPA route in full mode, use SPA template
|
|
||||||
if ($mode === 'full' && $is_spa_route) {
|
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
|
||||||
if (file_exists($spa_template)) {
|
|
||||||
// Set status to 200 to prevent 404
|
|
||||||
status_header(200);
|
|
||||||
return $spa_template;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode 3: Checkout-Only (partial SPA)
|
|
||||||
if ($mode === 'checkout_only') {
|
|
||||||
$checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [
|
|
||||||
'checkout' => true,
|
|
||||||
'thankyou' => true,
|
|
||||||
'account' => true,
|
|
||||||
'cart' => false,
|
|
||||||
];
|
|
||||||
|
|
||||||
$should_override = false;
|
|
||||||
|
|
||||||
if (!empty($checkout_pages['checkout']) && is_checkout() && !is_order_received_page()) {
|
|
||||||
$should_override = true;
|
|
||||||
}
|
|
||||||
if (!empty($checkout_pages['thankyou']) && is_order_received_page()) {
|
|
||||||
$should_override = true;
|
|
||||||
}
|
|
||||||
if (!empty($checkout_pages['account']) && is_account_page()) {
|
|
||||||
$should_override = true;
|
|
||||||
}
|
|
||||||
if (!empty($checkout_pages['cart']) && is_cart()) {
|
|
||||||
$should_override = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($should_override) {
|
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
|
||||||
if (file_exists($spa_template)) {
|
|
||||||
return $spa_template;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode 2: Full SPA
|
|
||||||
if ($mode === 'full') {
|
|
||||||
// Override all WooCommerce pages
|
// Override all WooCommerce pages
|
||||||
if (is_woocommerce() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
if (is_woocommerce() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
||||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
@@ -280,6 +283,16 @@ class TemplateOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For spa_mode = 'checkout_only'
|
||||||
|
if ($spa_mode === 'checkout_only') {
|
||||||
|
if (is_checkout() || is_order_received_page() || is_account_page() || is_cart()) {
|
||||||
|
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||||
|
if (file_exists($spa_template)) {
|
||||||
|
return $spa_template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $template;
|
return $template;
|
||||||
}
|
}
|
||||||
@@ -342,19 +355,16 @@ class TemplateOverride
|
|||||||
*/
|
*/
|
||||||
private static function should_use_spa()
|
private static function should_use_spa()
|
||||||
{
|
{
|
||||||
// Check if frontend mode is enabled
|
// Check spa_mode from appearance settings
|
||||||
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
if ($mode === 'disabled') {
|
// Only use SPA when mode is 'full'
|
||||||
|
if ($spa_mode !== 'full') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For full SPA mode, always use SPA
|
// For full SPA mode, use SPA on WooCommerce pages
|
||||||
if ($mode === 'full_spa') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For shortcode mode, check if we're on WooCommerce pages
|
|
||||||
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -400,12 +410,13 @@ class TemplateOverride
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get SPA page ID from appearance settings
|
// Get SPA settings from appearance
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
// Check if current page matches the SPA page
|
// Only return true if spa_mode is 'full' AND we're on the SPA page
|
||||||
if ($spa_page_id && $post->ID == $spa_page_id) {
|
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,38 +433,20 @@ class TemplateOverride
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
// Check spa_mode from appearance settings
|
||||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
// Check if we're on a WooCommerce page in full mode
|
// Check if we're on a WooCommerce page in full mode
|
||||||
if ($mode === 'full') {
|
if ($spa_mode === 'full') {
|
||||||
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
|
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also remove for pages with shortcodes (even in disabled mode)
|
// When SPA is disabled, don't remove theme elements
|
||||||
global $post;
|
if ($spa_mode === 'disabled') {
|
||||||
if (
|
return false;
|
||||||
$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;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user