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",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"recharts": "^3.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -257,10 +257,10 @@ import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import CampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
import Help from '@/routes/Help';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
function AddonRoute({ config }: { config: any }) {
|
||||
@@ -579,9 +579,11 @@ function AppRoutes() {
|
||||
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/campaigns" element={<CampaignsList />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
|
||||
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
||||
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
||||
|
||||
{/* Help - Main menu route with no submenu */}
|
||||
<Route path="/help" element={<Help />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{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 { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Mail, Send, Tag } from 'lucide-react';
|
||||
import { Mail, Tag } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface MarketingCard {
|
||||
@@ -13,16 +13,10 @@ interface MarketingCard {
|
||||
const cards: MarketingCard[] = [
|
||||
{
|
||||
title: __('Newsletter'),
|
||||
description: __('Manage subscribers and email templates'),
|
||||
description: __('Manage subscribers and send email campaigns'),
|
||||
icon: Mail,
|
||||
to: '/marketing/newsletter',
|
||||
},
|
||||
{
|
||||
title: __('Campaigns'),
|
||||
description: __('Create and send email campaigns'),
|
||||
icon: Send,
|
||||
to: '/marketing/campaigns',
|
||||
},
|
||||
{
|
||||
title: __('Coupons'),
|
||||
description: __('Discounts, promotions, and coupon codes'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, 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 { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { useApp } from '@/contexts/AppContext';
|
||||
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
|
||||
label: __('Settings'),
|
||||
description: __('Configure your store 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 {
|
||||
auto_register_members: boolean;
|
||||
multiple_addresses_enabled: boolean;
|
||||
allow_custom_avatar: boolean;
|
||||
vip_min_spent: number;
|
||||
vip_min_orders: number;
|
||||
vip_timeframe: 'all' | '30' | '90' | '365';
|
||||
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
|
||||
const [settings, setSettings] = useState<CustomerSettings>({
|
||||
auto_register_members: false,
|
||||
multiple_addresses_enabled: true,
|
||||
allow_custom_avatar: false,
|
||||
vip_min_spent: 1000,
|
||||
vip_min_orders: 10,
|
||||
vip_timeframe: 'all',
|
||||
@@ -138,6 +140,14 @@ export default function CustomersSettings() {
|
||||
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>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function CustomerNotifications() {
|
||||
}
|
||||
>
|
||||
<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="events">{__('Events')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function EmailConfiguration() {
|
||||
}
|
||||
>
|
||||
<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="connection">{__('Connection Settings')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function PushConfiguration() {
|
||||
}
|
||||
>
|
||||
<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="connection">{__('Connection Settings')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function StaffNotifications() {
|
||||
}
|
||||
>
|
||||
<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="events">{__('Events')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function TemplateEditor({
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<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">
|
||||
<Edit className="h-4 w-4" />
|
||||
{__('Editor')}
|
||||
|
||||
@@ -40,7 +40,7 @@ rsync -av --progress \
|
||||
--exclude='admin-spa' \
|
||||
--exclude='examples' \
|
||||
--exclude='*.sh' \
|
||||
--exclude='*.md' \
|
||||
--exclude='/*.md' \
|
||||
--exclude='archive' \
|
||||
--exclude='test-*.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",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -4927,6 +4928,15 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -6214,6 +6224,26 @@
|
||||
"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": {
|
||||
"version": "7.66.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||
@@ -6658,6 +6688,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { HelmetProvider } from 'react-helmet-async';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
// 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() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<RouterProvider>
|
||||
<AppRoutes />
|
||||
</RouterProvider>
|
||||
|
||||
{/* Toast notifications - position from settings */}
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
{/* Toast notifications - position from settings */}
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</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();
|
||||
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,6 +24,13 @@ export interface Cart {
|
||||
code: string;
|
||||
discount: number;
|
||||
};
|
||||
coupons?: {
|
||||
code: string;
|
||||
discount: number;
|
||||
type?: string;
|
||||
}[];
|
||||
discount_total?: number;
|
||||
shipping_total?: number;
|
||||
}
|
||||
|
||||
interface CartStore {
|
||||
|
||||
@@ -1,15 +1,158 @@
|
||||
import React from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useEffect } from '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() {
|
||||
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 (
|
||||
<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">No downloads available</p>
|
||||
<div className="space-y-4">
|
||||
{downloads.map((download) => (
|
||||
<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>
|
||||
|
||||
{downloads.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-6 text-center">
|
||||
{downloads.length} {downloads.length === 1 ? 'download' : 'downloads'} available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart, applyCoupon, removeCoupon } from '@/lib/cart/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
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';
|
||||
|
||||
export default function Cart() {
|
||||
@@ -24,6 +24,8 @@ export default function Cart() {
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [couponCode, setCouponCode] = useState('');
|
||||
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
||||
|
||||
// Fetch cart from server on mount to sync with WooCommerce
|
||||
useEffect(() => {
|
||||
@@ -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
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -237,10 +270,43 @@ export default function Cart() {
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
@@ -262,15 +328,22 @@ export default function Cart() {
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice((cart as any).subtotal || total)}</span>
|
||||
</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">
|
||||
<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 className="border-t pt-3 flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice((cart as any).total || total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
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 { apiClient } from '@/lib/api/client';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { AddressSelector } from '@/components/AddressSelector';
|
||||
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
|
||||
|
||||
interface SavedAddress {
|
||||
id: number;
|
||||
@@ -34,6 +35,10 @@ export default function Checkout() {
|
||||
const { cart } = useCartStore();
|
||||
const { layout, elements } = useCheckoutSettings();
|
||||
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;
|
||||
|
||||
// Check if cart contains only virtual/downloadable products
|
||||
@@ -189,6 +194,57 @@ export default function Checkout() {
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
setIsProcessing(true);
|
||||
@@ -652,10 +708,45 @@ export default function Checkout() {
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
@@ -702,6 +793,13 @@ export default function Checkout() {
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</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">
|
||||
<span>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">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice(total - discountTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||
|
||||
export default function Product() {
|
||||
@@ -105,8 +106,8 @@ export default function Product() {
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
|
||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||
if (varValueNormalized === normalizedValue) {
|
||||
@@ -257,6 +258,18 @@ export default function Product() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
@@ -306,11 +319,10 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
selectedImage === img
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
@@ -341,11 +353,10 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
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 ${
|
||||
selectedImage === 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 ${selectedImage === img
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
@@ -434,11 +445,10 @@ export default function Product() {
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
||||
isSelected
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? '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'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
@@ -492,15 +502,13 @@ export default function Product() {
|
||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||
<button
|
||||
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 ${
|
||||
product && isInWishlist(product.id)
|
||||
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)
|
||||
? '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'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${
|
||||
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||
</button>
|
||||
)}
|
||||
@@ -576,7 +584,7 @@ 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"
|
||||
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
|
||||
onClick={() => {
|
||||
@@ -587,7 +595,7 @@ 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"
|
||||
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
|
||||
onClick={() => {
|
||||
@@ -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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,160 +690,160 @@ export default function Product() {
|
||||
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
||||
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
||||
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||
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">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[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">
|
||||
<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>
|
||||
<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 className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||
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">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[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">
|
||||
<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>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||
</span>
|
||||
</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
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Related Products */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||
|
||||
export default function Shop() {
|
||||
@@ -126,6 +127,11 @@ export default function Shop() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* SEO Meta Tags for Social Sharing */}
|
||||
<SEOHead
|
||||
title="Shop"
|
||||
description="Browse our collection of products"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<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
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||
}
|
||||
|
||||
if ($hook !== 'toplevel_page_woonoow') {
|
||||
@@ -32,7 +31,6 @@ class Assets
|
||||
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
if ($is_dev) {
|
||||
@@ -155,11 +153,6 @@ class Assets
|
||||
|
||||
// Debug logging
|
||||
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)) {
|
||||
@@ -286,7 +279,6 @@ class Assets
|
||||
|
||||
// Debug logging (only if WP_DEBUG is enabled)
|
||||
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
|
||||
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
|
||||
}
|
||||
|
||||
return (bool) $filtered;
|
||||
|
||||
@@ -53,9 +53,6 @@ class StandaloneAdmin {
|
||||
|
||||
// Debug logging (only in WP_DEBUG mode)
|
||||
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
|
||||
|
||||
@@ -60,9 +60,6 @@ class AuthController {
|
||||
|
||||
// Debug logging
|
||||
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
|
||||
@@ -154,8 +151,6 @@ class AuthController {
|
||||
|
||||
// Debug logging
|
||||
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 ) {
|
||||
|
||||
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 !== '' ) {
|
||||
$order_id = $order->get_id();
|
||||
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 );
|
||||
error_log('[WooNooW] Email scheduled successfully for order #' . $order_id);
|
||||
}, 999 );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
|
||||
} catch ( \Throwable $e ) {
|
||||
// Log the actual error for debugging
|
||||
error_log('[WooNooW] Order update failed: ' . $e->getMessage());
|
||||
|
||||
// Return user-friendly error message
|
||||
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 ) {
|
||||
// Skip if we're in an API request (we schedule manually there)
|
||||
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
|
||||
error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule email notification with 15s delay
|
||||
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 );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
error_log( '[WooNooW] Coupon error: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,7 +1228,6 @@ class OrdersController {
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
// Log the actual error for debugging
|
||||
error_log('[WooNooW] Order creation failed: ' . $e->getMessage());
|
||||
|
||||
// Return user-friendly error message
|
||||
return new \WP_REST_Response( [
|
||||
@@ -2025,7 +2018,6 @@ class OrdersController {
|
||||
|
||||
// Check if gateway exists
|
||||
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 ) );
|
||||
}
|
||||
|
||||
@@ -2033,7 +2025,6 @@ class OrdersController {
|
||||
|
||||
// Check if gateway has process_payment method
|
||||
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 ) );
|
||||
}
|
||||
|
||||
@@ -2045,7 +2036,6 @@ class OrdersController {
|
||||
// Set flag for gateways to detect admin context
|
||||
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
|
||||
$result = $gateway->process_payment( $order->get_id() );
|
||||
@@ -2061,11 +2051,9 @@ class OrdersController {
|
||||
|
||||
if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
|
||||
$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' ) {
|
||||
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
|
||||
$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();
|
||||
@@ -2074,7 +2062,6 @@ class OrdersController {
|
||||
return $result;
|
||||
|
||||
} 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->save();
|
||||
|
||||
|
||||
@@ -212,12 +212,10 @@ class PaymentsController extends WP_REST_Controller {
|
||||
|
||||
try {
|
||||
// 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);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
error_log(sprintf('[WooNooW] Save failed: %s', $result->get_error_message()));
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -228,7 +226,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||
|
||||
// Debug: Log success
|
||||
error_log(sprintf('[WooNooW] Gateway %s settings saved successfully', $gateway_id));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
@@ -236,7 +233,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
'gateway' => $gateway,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log(sprintf('[WooNooW] Save exception: %s', $e->getMessage()));
|
||||
return new WP_Error(
|
||||
'save_gateway_failed',
|
||||
$e->getMessage(),
|
||||
@@ -268,12 +264,10 @@ class PaymentsController extends WP_REST_Controller {
|
||||
|
||||
try {
|
||||
// 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);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
error_log(sprintf('[WooNooW] Toggle failed: %s', $result->get_error_message()));
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -284,7 +278,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
|
||||
|
||||
// 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([
|
||||
'success' => true,
|
||||
@@ -292,7 +285,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
'gateway' => $gateway,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log(sprintf('[WooNooW] Toggle exception: %s', $e->getMessage()));
|
||||
return new WP_Error(
|
||||
'toggle_gateway_failed',
|
||||
$e->getMessage(),
|
||||
@@ -333,7 +325,6 @@ class PaymentsController extends WP_REST_Controller {
|
||||
$option_key = 'woonoow_payment_gateway_order_' . $category;
|
||||
update_option($option_key, $order, false);
|
||||
|
||||
error_log(sprintf('[WooNooW] Saved %s gateway order: %s', $category, implode(', ', $order)));
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
|
||||
@@ -24,6 +24,7 @@ use WooNooW\Api\NewsletterController;
|
||||
use WooNooW\Api\ModulesController;
|
||||
use WooNooW\Api\ModuleSettingsController;
|
||||
use WooNooW\Api\CampaignsController;
|
||||
use WooNooW\Api\DocsController;
|
||||
use WooNooW\Frontend\ShopController;
|
||||
use WooNooW\Frontend\CartController as FrontendCartController;
|
||||
use WooNooW\Frontend\AccountController;
|
||||
@@ -165,6 +166,10 @@ class Routes {
|
||||
$module_settings_controller = new ModuleSettingsController();
|
||||
$module_settings_controller->register_routes();
|
||||
|
||||
// Documentation controller
|
||||
$docs_controller = new DocsController();
|
||||
$docs_controller->register_routes();
|
||||
|
||||
// Frontend controllers (customer-facing)
|
||||
ShopController::register_routes();
|
||||
FrontendCartController::register_routes();
|
||||
|
||||
@@ -454,7 +454,6 @@ class ShippingController extends WP_REST_Controller {
|
||||
);
|
||||
|
||||
} catch ( \Exception $e ) {
|
||||
error_log( sprintf( '[WooNooW] Toggle exception: %s', $e->getMessage() ) );
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'error' => 'toggle_failed',
|
||||
|
||||
@@ -21,6 +21,7 @@ class CustomerSettingsProvider {
|
||||
// General
|
||||
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
|
||||
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
|
||||
'allow_custom_avatar' => get_option('woonoow_allow_custom_avatar', 'no') === 'yes',
|
||||
|
||||
// VIP Customer Qualification
|
||||
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
|
||||
@@ -49,8 +50,10 @@ class CustomerSettingsProvider {
|
||||
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
|
||||
if (isset($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 {
|
||||
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
|
||||
@@ -186,6 +186,13 @@ class NavigationRegistry {
|
||||
'icon' => 'settings',
|
||||
'children' => self::get_settings_children(),
|
||||
],
|
||||
[
|
||||
'key' => 'help',
|
||||
'label' => __('Help', 'woonoow'),
|
||||
'path' => '/help',
|
||||
'icon' => 'help-circle',
|
||||
'children' => [], // Empty array = no submenu bar
|
||||
],
|
||||
];
|
||||
|
||||
return $tree;
|
||||
|
||||
@@ -391,24 +391,15 @@ class PaymentGatewaysProvider {
|
||||
|
||||
$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
|
||||
$gateway->settings = $new_settings;
|
||||
|
||||
// Save to database using WooCommerce's method
|
||||
$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)
|
||||
if (isset($new_settings['enabled'])) {
|
||||
$gateway->enabled = $new_settings['enabled'];
|
||||
error_log(sprintf('[WooNooW] Set gateway->enabled to: %s', $gateway->enabled));
|
||||
}
|
||||
|
||||
// Re-enable HTTP requests
|
||||
|
||||
@@ -23,106 +23,56 @@ class Installer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update WooNooW pages
|
||||
* Smart detection: reuses existing WooCommerce pages if they exist
|
||||
* Create WooNooW SPA page only
|
||||
* No longer modifies WooCommerce pages - we use template overrides instead
|
||||
*/
|
||||
private static function create_pages() {
|
||||
$pages = [
|
||||
'shop' => [
|
||||
'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',
|
||||
],
|
||||
];
|
||||
// Only create the main SPA page (Store)
|
||||
// WooCommerce pages are NOT modified - we use template overrides
|
||||
|
||||
foreach ($pages as $key => $page_data) {
|
||||
$page_id = null;
|
||||
$spa_page_id = get_option('woonoow_store_page_id');
|
||||
|
||||
// Strategy 1: Check if WooCommerce already has a page set
|
||||
if (isset($page_data['wc_option'])) {
|
||||
$wc_page_id = get_option($page_data['wc_option']);
|
||||
if ($wc_page_id && get_post($wc_page_id)) {
|
||||
$page_id = $wc_page_id;
|
||||
error_log("WooNooW: Found existing WooCommerce {$page_data['title']} page (ID: {$page_id})");
|
||||
}
|
||||
}
|
||||
// Check if SPA page already exists
|
||||
if ($spa_page_id && get_post($spa_page_id)) {
|
||||
return; // Already exists
|
||||
}
|
||||
|
||||
// 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})");
|
||||
}
|
||||
}
|
||||
// Search for existing "Store" page
|
||||
$existing_page = get_page_by_title('Store', OBJECT, 'page');
|
||||
if ($existing_page) {
|
||||
$spa_page_id = $existing_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',
|
||||
// Update with SPA shortcode if needed
|
||||
if (!has_shortcode($existing_page->post_content, 'woonoow_spa')) {
|
||||
update_post_meta($spa_page_id, '_woonoow_original_content', $existing_page->post_content);
|
||||
wp_update_post([
|
||||
'ID' => $spa_page_id,
|
||||
'post_content' => '[woonoow_spa]',
|
||||
]);
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
|
||||
// Store page ID and update WooCommerce settings
|
||||
if ($page_id && !is_wp_error($page_id)) {
|
||||
update_option('woonoow_' . $key . '_page_id', $page_id);
|
||||
// Save SPA page ID
|
||||
if ($spa_page_id && !is_wp_error($spa_page_id)) {
|
||||
update_option('woonoow_store_page_id', $spa_page_id);
|
||||
|
||||
if (isset($page_data['wc_option'])) {
|
||||
update_option($page_data['wc_option'], $page_id);
|
||||
}
|
||||
// Also set it in appearance settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
if (!isset($appearance_settings['general'])) {
|
||||
$appearance_settings['general'] = [];
|
||||
}
|
||||
$appearance_settings['general']['spa_page'] = $spa_page_id;
|
||||
update_option('woonoow_appearance_settings', $appearance_settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +112,6 @@ class Installer {
|
||||
// Remove backup
|
||||
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) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] send_order_processing_email triggered for order #' . $order_id);
|
||||
}
|
||||
|
||||
if (!$order) {
|
||||
@@ -128,7 +127,6 @@ class EmailManager {
|
||||
|
||||
if (!$order) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Order not found for ID: ' . $order_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -136,13 +134,11 @@ class EmailManager {
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_processing', 'email', 'customer')) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] order_processing email is disabled in settings');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Sending order_processing email for order #' . $order_id);
|
||||
}
|
||||
|
||||
// Send email
|
||||
@@ -331,6 +327,7 @@ class EmailManager {
|
||||
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
if ($spa_page_id > 0) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
@@ -339,9 +336,15 @@ class EmailManager {
|
||||
$spa_url = home_url('/');
|
||||
}
|
||||
|
||||
// Build SPA reset password URL with hash router format
|
||||
// Format: /store/#/reset-password?key=KEY&login=LOGIN
|
||||
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||
// Build SPA reset password URL
|
||||
// Use path format for BrowserRouter (SEO), hash format for HashRouter (legacy)
|
||||
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
|
||||
$customer = null;
|
||||
@@ -390,7 +393,6 @@ class EmailManager {
|
||||
|
||||
if (!$email) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Password reset email rendering failed for user: ' . $user->user_login);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -404,7 +406,6 @@ class EmailManager {
|
||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Password reset email sent to ' . $email['to'] . ' - Result: ' . ($sent ? 'success' : 'failed'));
|
||||
}
|
||||
|
||||
// Log email sent
|
||||
@@ -477,7 +478,6 @@ class EmailManager {
|
||||
// This allows the plugin to work out-of-the-box with default templates
|
||||
if ($channel_id === 'email') {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Event not configured, using default: enabled');
|
||||
}
|
||||
return true; // Enable by default
|
||||
}
|
||||
@@ -495,7 +495,6 @@ class EmailManager {
|
||||
*/
|
||||
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] send_email called - Event: ' . $event_id . ', Recipient: ' . $recipient_type);
|
||||
}
|
||||
|
||||
// Get email renderer
|
||||
@@ -506,13 +505,11 @@ class EmailManager {
|
||||
|
||||
if (!$email) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Email rendering failed for event: ' . $event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] Email rendered successfully - To: ' . $email['to'] . ', Subject: ' . $email['subject']);
|
||||
}
|
||||
|
||||
// Send email via wp_mail
|
||||
@@ -524,7 +521,6 @@ class EmailManager {
|
||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailManager] wp_mail called - Result: ' . ($sent ? 'success' : 'failed'));
|
||||
}
|
||||
|
||||
// Log email sent
|
||||
|
||||
@@ -86,13 +86,11 @@ class EmailRenderer {
|
||||
|
||||
if (!$template) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailRenderer] No template found for event: ' . $event_id . ', recipient: ' . $recipient_type);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[EmailRenderer] Template found - Subject: ' . ($template['subject'] ?? 'no subject'));
|
||||
}
|
||||
|
||||
// Get design template preference
|
||||
@@ -259,7 +257,17 @@ class EmailRenderer {
|
||||
// Generate login URL (pointing to SPA login instead of wp-login)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$login_url = $spa_page_id ? get_permalink($spa_page_id) . '#/login' : wp_login_url();
|
||||
$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, [
|
||||
'customer_id' => $data->get_id(),
|
||||
|
||||
@@ -229,7 +229,6 @@ class PushNotificationHandler {
|
||||
self::queue_notification($subscription_id, $payload);
|
||||
$sent++;
|
||||
} 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 = [
|
||||
'apiUrl' => rest_url('woonoow/v1'),
|
||||
'apiRoot' => rest_url('woonoow/v1'),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'siteUrl' => get_site_url(),
|
||||
'siteTitle' => get_bloginfo('name'),
|
||||
'siteName' => get_bloginfo('name'),
|
||||
'storeName' => get_bloginfo('name'),
|
||||
'storeLogo' => $logo_url,
|
||||
'user' => $user_data,
|
||||
'theme' => $theme_settings,
|
||||
'currency' => $currency_settings,
|
||||
'appearanceSettings' => $appearance_settings,
|
||||
'basePath' => $base_path,
|
||||
'useBrowserRouter' => $use_browser_router,
|
||||
];
|
||||
|
||||
?>
|
||||
|
||||
@@ -13,6 +13,25 @@ class TemplateOverride
|
||||
*/
|
||||
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)
|
||||
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||
|
||||
@@ -47,6 +66,44 @@ class TemplateOverride
|
||||
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)
|
||||
* Let WooCommerce handle the cart operation properly, we just redirect afterward
|
||||
@@ -98,13 +155,20 @@ class TemplateOverride
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
// Get SPA page URL
|
||||
// Get SPA settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$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) {
|
||||
return; // No SPA page configured
|
||||
@@ -118,33 +182,44 @@ class TemplateOverride
|
||||
|
||||
$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
|
||||
if (is_shop()) {
|
||||
wp_redirect($spa_url . '#/', 302);
|
||||
wp_redirect($build_route('shop'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_product()) {
|
||||
global $product;
|
||||
if ($product) {
|
||||
$slug = $product->get_slug();
|
||||
wp_redirect($spa_url . '#/products/' . $slug, 302);
|
||||
// Use get_queried_object() which returns the WP_Post, then get slug
|
||||
$product_post = get_queried_object();
|
||||
if ($product_post && isset($product_post->post_name)) {
|
||||
$slug = $product_post->post_name;
|
||||
wp_redirect($build_route('product/' . $slug), 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_cart()) {
|
||||
wp_redirect($spa_url . '#/cart', 302);
|
||||
wp_redirect($build_route('cart'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_checkout() && !is_order_received_page()) {
|
||||
wp_redirect($spa_url . '#/checkout', 302);
|
||||
wp_redirect($build_route('checkout'), 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_account_page()) {
|
||||
wp_redirect($spa_url . '#/account', 302);
|
||||
wp_redirect($build_route('my-account'), 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +256,15 @@ class TemplateOverride
|
||||
*/
|
||||
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
|
||||
if (self::is_spa_page()) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
@@ -189,89 +273,8 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: Check SPA mode settings
|
||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$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') {
|
||||
// For spa_mode = 'full', override WooCommerce pages
|
||||
if ($spa_mode === 'full') {
|
||||
// Override all WooCommerce pages
|
||||
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';
|
||||
@@ -281,6 +284,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;
|
||||
}
|
||||
|
||||
@@ -342,19 +355,16 @@ class TemplateOverride
|
||||
*/
|
||||
private static function should_use_spa()
|
||||
{
|
||||
// Check if frontend mode is enabled
|
||||
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
||||
// Check spa_mode from appearance settings
|
||||
$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;
|
||||
}
|
||||
|
||||
// For full SPA mode, always use SPA
|
||||
if ($mode === 'full_spa') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For shortcode mode, check if we're on WooCommerce pages
|
||||
// For full SPA mode, use SPA on WooCommerce pages
|
||||
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page()) {
|
||||
return true;
|
||||
}
|
||||
@@ -400,12 +410,13 @@ class TemplateOverride
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get SPA page ID from appearance settings
|
||||
// Get SPA settings from appearance
|
||||
$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
|
||||
if ($spa_page_id && $post->ID == $spa_page_id) {
|
||||
// Only return true if spa_mode is 'full' AND we're on the SPA page
|
||||
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -422,38 +433,20 @@ class TemplateOverride
|
||||
return true;
|
||||
}
|
||||
|
||||
$settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||
// Check spa_mode from appearance settings
|
||||
$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
|
||||
if ($mode === 'full') {
|
||||
if ($spa_mode === 'full') {
|
||||
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove for pages with shortcodes (even in disabled mode)
|
||||
global $post;
|
||||
if (
|
||||
$post && (
|
||||
has_shortcode($post->post_content, 'woonoow_shop') ||
|
||||
has_shortcode($post->post_content, 'woonoow_cart') ||
|
||||
has_shortcode($post->post_content, 'woonoow_checkout') ||
|
||||
has_shortcode($post->post_content, 'woonoow_account')
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special check for Shop page (archive)
|
||||
if (function_exists('is_shop') && is_shop()) {
|
||||
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// When SPA is disabled, don't remove theme elements
|
||||
if ($spa_mode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user