Compare commits

..

17 Commits

Author SHA1 Message Date
Dwindi Ramadhana
0f542ad452 feat: Multiple fixes and features
1. Add allow_custom_avatar toggle to Customer Settings
2. Implement coupon apply/remove in Cart and Checkout pages
3. Update Cart interface with coupons array and discount_total
4. Implement Downloads page to fetch from /account/downloads API
2026-01-04 20:03:33 +07:00
Dwindi Ramadhana
befacf9d29 fix: Remove old Newsletter.tsx (conflicting with Newsletter/index.tsx)
The old file was being resolved by Vite instead of the new
Newsletter/index.tsx folder, preventing the tabs from appearing.
2026-01-04 19:11:28 +07:00
Dwindi Ramadhana
d9878c8b20 feat: Refactor Newsletter with horizontal tabs (Subscribers | Campaigns)
- Created Newsletter/index.tsx as tabs container
- Extracted Newsletter/Subscribers.tsx (from old Newsletter.tsx)
- Moved Campaigns to Newsletter/Campaigns.tsx
- Updated App.tsx routes (campaigns now under newsletter)
- Removed separate Campaigns card from Marketing index
- Follows Customer Notifications tab pattern for consistency
2026-01-04 19:06:18 +07:00
Dwindi Ramadhana
d65259db8a fix: Simplify Help page layout (remove sticky)
- Sticky not possible when page is inside overflow-auto container
- Using standard flexbox layout where sidebar and content scroll together
- Separate mobile (fixed overlay) and desktop (inline) sidebars
- Clean, simple layout matching typical documentation patterns
2026-01-04 12:37:40 +07:00
Dwindi Ramadhana
54a1ec1c88 fix: Separate mobile/desktop sidebar components
- Mobile: fixed overlay sidebar with proper z-index
- Desktop: sticky sidebar with correct top offset
- Extracted SidebarContent component to avoid duplication
- Matches App.tsx submenu bar positioning logic
2026-01-04 12:33:46 +07:00
Dwindi Ramadhana
3a8c436839 fix: Sidebar positioning - remove inset-y-0 conflict
- Fixed sidebar to not use inset-y-0 (was overriding top offset)
- Mobile: fixed positioning with sidebarTopClass
- Desktop: lg:sticky for proper sticky behavior
2026-01-04 12:30:46 +07:00
Dwindi Ramadhana
bfb961ccbe fix: Help page scroll and sidebar positioning
- Remove internal overflow (use wp-admin page scroll)
- Sidebar sticky under topbar with correct positioning
- Standalone mode: top-16 (below 64px header)
- WP Admin mode: top-[calc(7rem+32px)] (header+topnav+wp-admin bar)
- Uses useApp() to detect mode
2026-01-04 12:27:51 +07:00
Dwindi Ramadhana
f49dde9484 feat: Add Help to main navigation (no submenu bar)
- Added Help item to NavigationRegistry::get_base_tree
- Empty children array means no submenu bar displayed
- Incremented NAV_VERSION to 1.0.9 to trigger cache rebuild
- Help icon: help-circle
2026-01-04 12:01:18 +07:00
Dwindi Ramadhana
b64a979a61 fix: Use correct WOONOOW_PATH constant in DocsController
WOONOOW_PLUGIN_DIR was undefined, causing 500 errors.
The actual constant defined in woonoow.php is WOONOOW_PATH.
2026-01-04 11:55:15 +07:00
Dwindi Ramadhana
0e38b0eb5f fix: Documentation API authentication and build script
- Added X-WP-Nonce header to docs API fetch calls in Help page
- Fixed build-production.sh to include docs/ folder (changed --exclude='*.md' to --exclude='/*.md')
- This allows root-level docs like README.md to be excluded while keeping docs/ folder
2026-01-04 11:53:33 +07:00
Dwindi Ramadhana
68c3423f50 feat: Add in-app documentation system
Phase 1: Core Documentation
- Created docs/ folder with 8 markdown documentation files
- Getting Started, Installation, Troubleshooting, FAQ
- Configuration docs (Appearance, SPA Mode)
- Feature docs (Shop, Checkout)
- PHP registry with filter hook for addon extensibility

Phase 2: Documentation Viewer
- DocsController.php with REST API endpoints
- GET /woonoow/v1/docs - List all docs (with addon hook)
- GET /woonoow/v1/docs/{slug} - Get document content
- Admin SPA /help route with sidebar navigation
- Markdown rendering with react-markdown
- Added Help & Docs to More page for mobile access

Filter Hook: woonoow_docs_registry
Addons can register their own documentation sections.
2026-01-04 11:43:32 +07:00
Dwindi Ramadhana
1206117df1 fix: plugin activation no longer modifies WooCommerce pages
- Removed shortcode replacement for Cart, Checkout, My Account pages
- WooCommerce pages now keep their original [woocommerce_*] shortcodes
- Plugin only creates dedicated SPA page (/store) with [woonoow_spa]
- Auto-sets spa_page in appearance settings

This aligns with template override approach - WC pages render normally
when SPA is disabled, and redirect to SPA when mode is 'full'.
2026-01-04 11:15:52 +07:00
Dwindi Ramadhana
7c2f21f7a2 fix: SPA disabled mode now returns original template immediately
- Added spa_mode check at the BEGINNING of use_spa_template()
- When spa_mode = 'disabled', returns original template immediately
- Removed legacy woonoow_customer_spa_settings checks
- Simplified template override logic
2026-01-04 11:08:10 +07:00
Dwindi Ramadhana
7c15850c8f fix: SPA disabled mode now renders WooCommerce templates properly
- Updated should_use_spa() to check correct setting (woonoow_appearance_settings['general']['spa_mode'])
- Updated is_spa_page() to also check spa_mode
- Updated should_remove_theme_elements() to use appearance settings
- When spa_mode = 'disabled', WooCommerce templates render normally
2026-01-04 10:57:14 +07:00
Dwindi Ramadhana
670bd7d351 fix: PHP errors and clean up error_log statements
- Fixed redirect_wc_pages_to_spa: added spa_mode check (only redirect when 'full')
- Fixed PHP fatal error: use get_queried_object() instead of global $product
- Removed all error_log debug statements from codebase
- Fixed broken syntax in PaymentGatewaysProvider.php after error_log removal
2026-01-04 10:49:47 +07:00
Dwindi Ramadhana
75a82cf16c feat: add dynamic meta tags for social sharing (Phase 4-5)
Phase 4: Dynamic Meta Tags
- Added react-helmet-async dependency
- Created SEOHead component with Open Graph and Twitter Card support
- Added HelmetProvider wrapper to App.tsx
- Integrated SEOHead in Product page (title, description, image, product info)
- Integrated SEOHead in Shop page (basic meta tags)

Phase 5: Auto-Flush Permalinks
- Enhanced settings change handler to only flush when spa_mode,
  spa_page, or use_browser_router changes
- Plugin already flushes on activation (Installer.php)

This enables proper link previews when sharing product URLs
on Facebook, Twitter, Slack, etc.
2026-01-04 10:40:10 +07:00
Dwindi Ramadhana
45fcbf9d29 feat: migrate from HashRouter to BrowserRouter for SEO
Phase 1: WordPress Rewrite Rules
- Add rewrite rule for /store/* to serve SPA page
- Add use_browser_router setting toggle (default: true)
- Flush rewrite rules on settings change

Phase 2: React Router Migration
- Add BrowserRouter with basename from WordPress config
- Pass basePath and useBrowserRouter to frontend
- Conditional router based on setting

Phase 3: Hash Route Migration
- Update EmailManager.php reset password URL
- Update EmailRenderer.php login URL
- Update TemplateOverride.php WC redirects
- All routes now use path format by default

This enables proper SEO indexing as search engines
can now crawl individual product/page URLs.
2026-01-03 20:01:32 +07:00
56 changed files with 4688 additions and 786 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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) => (

View 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>
);
}

View 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>
</>
);
}

View 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;
}

View File

@@ -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 &gt; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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'),

View File

@@ -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'
}
];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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' \

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider config={themeConfig}>
<HashRouter>
<RouterProvider>
<AppRoutes />
</HashRouter>
</RouterProvider>
{/* Toast notifications - position from settings */}
<Toaster position={toastPosition} richColors />
</ThemeProvider>
</QueryClientProvider>
</HelmetProvider>
);
}

View 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;

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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() {
@@ -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,8 +319,7 @@ 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'
}`}
@@ -341,8 +353,7 @@ 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'
}`}
@@ -434,8 +445,7 @@ 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'
}`}
@@ -492,14 +502,12 @@ 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>

View File

@@ -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
View 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;
}

View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View File

@@ -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;

View File

@@ -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

View File

@@ -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 ) {

View 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);
}
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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();

View File

@@ -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',

View File

@@ -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']));

View File

@@ -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;

View File

@@ -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

View File

@@ -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})");
}
}
// Strategy 3: Search for page by title
if (!$page_id) {
$existing_page = get_page_by_title($page_data['title'], OBJECT, 'page');
// Search for existing "Store" page
$existing_page = get_page_by_title('Store', OBJECT, 'page');
if ($existing_page) {
$page_id = $existing_page->ID;
error_log("WooNooW: Found existing {$page_data['title']} page by title (ID: {$page_id})");
}
}
$spa_page_id = $existing_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
// 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' => $page_id,
'post_content' => $page_data['content'],
'ID' => $spa_page_id,
'post_content' => '[woonoow_spa]',
]);
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'],
// 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(),
'post_author' => get_current_user_id() ?: 1,
'comment_status' => 'closed',
]);
if ($page_id && !is_wp_error($page_id)) {
error_log("WooNooW: Created new {$page_data['title']} page (ID: {$page_id})");
}
}
// 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}");
}
}
}

View File

@@ -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
// 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

View File

@@ -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(),

View File

@@ -229,7 +229,6 @@ class PushNotificationHandler {
self::queue_notification($subscription_id, $payload);
$sent++;
} catch (\Exception $e) {
error_log('Push notification error: ' . $e->getMessage());
}
}

View File

@@ -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,
];
?>

View File

@@ -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;