fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout

This commit is contained in:
Dwindi Ramadhana
2026-02-05 00:09:40 +07:00
parent a0b5f8496d
commit 5f08c18ec7
77 changed files with 7027 additions and 4546 deletions

View File

@@ -0,0 +1,212 @@
# Newsletter Module Audit Report
**Date**: 2026-02-01
**Auditor**: Antigravity AI
**Scope**: Full trace of Newsletter module including broadcast, subscribers, templates, events, and multi-channel support
---
## 1. Module Architecture Overview
```mermaid
flowchart TD
subgraph Frontend
NF[NewsletterForm.tsx]
end
subgraph API
NC[NewsletterController.php]
CC[CampaignsController - via CampaignManager]
end
subgraph Core
CM[CampaignManager.php]
NS[NewsletterSettings.php]
end
subgraph Notifications
ER[EventRegistry.php]
NM[NotificationManager.php]
ER --> NM
end
subgraph Admin SPA
SUB[Subscribers.tsx]
CAMP[Campaigns.tsx]
end
NF -->|POST /subscribe| NC
NC -->|triggers| ER
CM -->|uses| NM
SUB -->|GET /subscribers| NC
CAMP -->|CRUD| CM
```
---
## 2. Components Traced
| Component | File | Status |
|-----------|------|--------|
| Subscriber API | `NewsletterController.php` | ✅ Working |
| Subscriber UI | `Subscribers.tsx` | ✅ Working |
| Campaign Manager | `CampaignManager.php` | ✅ Built (CPT-based) |
| Campaign UI | `Campaigns.tsx` | ✅ Working |
| Settings Schema | `NewsletterSettings.php` | ✅ Complete |
| Frontend Form | `NewsletterForm.tsx` | ⚠️ Missing GDPR |
| Unsubscribe | Token-based URL | ✅ Secure |
| Email Events | `EventRegistry.php` | ✅ 3 events registered |
---
## 3. Defects Found
### 🔴 Critical
#### 3.1 Double Opt-in NOT Implemented
**Location**: `NewsletterController.php` (Line 130-189)
**Issue**: `NewsletterSettings.php` defines a `double_opt_in` toggle (Line 46-51), but the subscribe function **ignores it completely**.
**Impact**: GDPR non-compliance in EU regions
**Expected**: When enabled, subscribers should receive confirmation email before being marked active
#### 3.2 Dead Code: `send_welcome_email()`
**Location**: `NewsletterController.php` (Lines 192-203)
**Issue**: This method is **never called**. Welcome emails are now sent via the notification system (`woonoow/notification/event`).
**Impact**: Code bloat, potential confusion
**Recommendation**: Delete this dead method
---
### 🟠 High Priority
#### 3.3 No Multi-Channel Support (WhatsApp/Telegram/SMS)
**Issue**: Only `email` and `push` channels exist in `NotificationManager.php`
**Impact**: Users cannot broadcast newsletters via WhatsApp, Telegram, or SMS
**Current State**:
- `allowed_platforms` in `NotificationsController.php` (Line 832) lists `telegram`, `whatsapp` for **social links** (not messaging)
- No actual message delivery integration exists
**Recommendation**: Implement channel bridge pattern for:
1. **WhatsApp Business API** (or Twilio WhatsApp)
2. **Telegram Bot API**
3. **SMS Gateway** (Twilio, Vonage, etc.)
#### 3.4 Subscriber Storage Not Scalable
**Location**: `NewsletterController.php` (Line 141)
**Issue**: Subscribers stored in `wp_options` as serialized array
**Impact**: Performance degrades with 1000+ subscribers (Options table not designed for large arrays)
**Note**: `NEWSLETTER_CAMPAIGN_PLAN.md` mentions custom table but `wp_woonoow_subscribers` table is **not created**
**Recommendation**:
```php
// Create migration for custom table
CREATE TABLE wp_woonoow_subscribers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NULL,
status ENUM('pending', 'active', 'unsubscribed') DEFAULT 'pending',
consent TINYINT(1) DEFAULT 0,
subscribed_at DATETIME,
unsubscribed_at DATETIME NULL,
ip_address VARCHAR(45),
INDEX idx_status (status),
INDEX idx_email (email)
);
```
---
### 🟡 Medium Priority
#### 3.5 GDPR Consent Checkbox Missing in Frontend
**Location**: `NewsletterForm.tsx`
**Issue**: Settings schema has `gdpr_consent` and `consent_text` fields, but the frontend form doesn't render this checkbox
**Impact**: GDPR non-compliance
**Recommendation**: Add consent checkbox:
```tsx
{settings.gdpr_consent && (
<label className="flex items-start gap-2">
<input type="checkbox" required />
<span className="text-xs">{settings.consent_text}</span>
</label>
)}
```
#### 3.6 No Audience Segmentation
**Issue**: All campaigns go to ALL active subscribers
**File**: `CampaignManager.php` (Line 393-410)
**Impact**: Cannot target specific user groups (e.g., "Subscribed in last 30 days", "WP Users only")
**Recommendation**: Add filter options to `get_subscribers()`:
- By date range
- By user_id (registered vs guest)
- By custom tags (future feature)
#### 3.7 No Open/Click Tracking
**Issue**: No analytics for campaign performance
**Impact**: Cannot measure engagement or ROI
**Recommendation** (Phase 3):
- Add tracking pixel for opens
- Wrap links for click tracking
- Store in `wp_woonoow_campaign_events` table
---
## 4. Gaps Between Plan and Implementation
| Feature | Plan Status | Implementation Status |
|---------|-------------|----------------------|
| Subscribers Table | "Create migration" | ❌ Not created |
| Double Opt-in | Schema defined | ❌ Not enforced |
| Campaign Scheduling | Cron registered | ✅ Working |
| GDPR Consent | Settings exist | ❌ UI not integrated |
| Multi-channel | Not planned | ❌ Not implemented |
| A/B Testing | Phase 3 | ❌ Not started |
| Analytics | Phase 3 | ❌ Not started |
---
## 5. Recommendations Summary
### Immediate Actions (Bug Fixes)
1. ~~Delete~~ or implement `send_welcome_email()` dead code
2. Connect `double_opt_in` setting to subscribe flow
3. Add GDPR checkbox to `NewsletterForm.tsx`
### Short-term (1-2 weeks)
4. Create `wp_woonoow_subscribers` table for scalability
5. Add audience segmentation to campaign targeting
### Medium-term (Future Phases)
6. Implement WhatsApp/Telegram channel bridges
7. Add open/click tracking for analytics
---
## 6. Security Audit
| Area | Status | Notes |
|------|--------|-------|
| Unsubscribe Token | ✅ Secure | HMAC-SHA256 with auth salt |
| Email Validation | ✅ Validated | `is_email()` + custom validation |
| CSRF Protection | ✅ Via REST nonce | API uses WP nonces |
| IP Logging | ✅ Stored | For GDPR data export if needed |
| Rate Limiting | ⚠️ None | Could be abused for spam subscriptions |
**Recommendation**: Add rate limiting to `/newsletter/subscribe` endpoint (e.g., 5 requests per IP per hour)
---
## 7. Conclusion
The Newsletter module is **functionally complete** for basic use cases. The campaign system is well-architected using WordPress Custom Post Types, and the integration with the notification system is clean.
**Critical gaps** exist around GDPR compliance (double opt-in, consent checkbox) and scalability (options-based storage). Multi-channel support (WhatsApp/Telegram) is **not implemented** and would require significant new development.
**Priority Order**:
1. GDPR fixes (double opt-in + consent checkbox)
2. Custom subscribers table
3. Audience segmentation
4. Multi-channel bridges (optional, significant scope)

View File

@@ -53,6 +53,8 @@ import { __ } from '@/lib/i18n';
import { ThemeToggle } from '@/components/ThemeToggle';
import { initializeWindowAPI } from '@/lib/windowAPI';
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
function useFullscreen() {
const [on, setOn] = useState<boolean>(() => {
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
@@ -261,6 +263,7 @@ import SettingsPayments from '@/routes/Settings/Payments';
import SettingsShipping from '@/routes/Settings/Shipping';
import SettingsTax from '@/routes/Settings/Tax';
import SettingsCustomers from '@/routes/Settings/Customers';
import SettingsSecurity from '@/routes/Settings/Security';
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
import SettingsNotifications from '@/routes/Settings/Notifications';
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
@@ -287,7 +290,9 @@ import AppearanceAccount from '@/routes/Appearance/Account';
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
import AppearancePages from '@/routes/Appearance/Pages';
import MarketingIndex from '@/routes/Marketing';
import Newsletter from '@/routes/Marketing/Newsletter';
import NewsletterLayout from '@/routes/Marketing/Newsletter';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import MorePage from '@/routes/More';
import Help from '@/routes/Help';
@@ -620,6 +625,7 @@ function AppRoutes() {
<Route path="/settings/shipping" element={<SettingsShipping />} />
<Route path="/settings/tax" element={<SettingsTax />} />
<Route path="/settings/customers" element={<SettingsCustomers />} />
<Route path="/settings/security" element={<SettingsSecurity />} />
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
<Route path="/settings/checkout" element={<SettingsIndex />} />
@@ -653,8 +659,17 @@ function AppRoutes() {
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<Newsletter />} />
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
<Route index element={<Navigate to="subscribers" replace />} />
<Route path="subscribers" element={<NewsletterSubscribers />} />
<Route path="campaigns" element={<NewsletterCampaignsList />} />
<Route path="campaigns/:id" element={<CampaignEdit />} />
</Route>
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
{/* Help - Main menu route with no submenu */}
<Route path="/help" element={<Help />} />

View File

@@ -89,7 +89,7 @@ export function BlockRenderer({
return (
<div style={cardStyles[block.cardType]}>
<div
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600 [&_.text-link]:text-purple-600 [&_.text-link]:underline"
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
@@ -97,17 +97,17 @@ export function BlockRenderer({
);
case 'button': {
const buttonStyle: React.CSSProperties = block.style === 'solid'
? {
display: 'inline-block',
background: 'var(--wn-primary, #7f54b3)',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
}
: {
// Different styles based on button type
let buttonStyle: React.CSSProperties;
if (block.style === 'link') {
// Plain link style - just underlined text
buttonStyle = {
color: 'var(--wn-primary, #7f54b3)',
textDecoration: 'underline',
};
} else if (block.style === 'outline') {
buttonStyle = {
display: 'inline-block',
background: 'transparent',
color: 'var(--wn-secondary, #7f54b3)',
@@ -117,18 +117,33 @@ export function BlockRenderer({
textDecoration: 'none',
fontWeight: 600,
};
} else {
// Solid style (default)
buttonStyle = {
display: 'inline-block',
background: 'var(--wn-primary, #7f54b3)',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
}
const containerStyle: React.CSSProperties = {
textAlign: block.align || 'center',
};
if (block.widthMode === 'full') {
buttonStyle.display = 'block';
buttonStyle.width = '100%';
buttonStyle.textAlign = 'center';
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
// Width modes don't apply to plain links
if (block.style !== 'link') {
if (block.widthMode === 'full') {
buttonStyle.display = 'block';
buttonStyle.width = '100%';
buttonStyle.textAlign = 'center';
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
}
}
return (

View File

@@ -101,7 +101,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
};
const openEditDialog = (block: EmailBlock) => {
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
setEditingBlockId(block.id);
if (block.type === 'card') {
@@ -123,7 +122,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
setEditingAlign(block.align);
}
console.log('[EmailBuilder] Setting editDialogOpen to true');
setEditDialogOpen(true);
};

View File

@@ -2,7 +2,7 @@ export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
export type ButtonStyle = 'solid' | 'outline';
export type ButtonStyle = 'solid' | 'outline' | 'link';
export type ContentWidth = 'fit' | 'full' | 'custom';

View File

@@ -0,0 +1,10 @@
import { Navigate, useParams } from 'react-router-dom';
/**
* Legacy redirect for campaign details
* Redirects /marketing/campaigns/:id -> /marketing/newsletter/campaigns/:id
*/
export function LegacyCampaignRedirect() {
const { id } = useParams();
return <Navigate to={`/marketing/newsletter/campaigns/${id}`} replace />;
}

View File

@@ -20,7 +20,7 @@ function fmt(d: Date): string {
}
export default function DateRange({ value, onChange }: Props) {
const [preset, setPreset] = useState<string>(() => "last7");
const [preset, setPreset] = useState<string>(() => "last30");
const [start, setStart] = useState<string | undefined>(value?.date_start);
const [end, setEnd] = useState<string | undefined>(value?.date_end);
@@ -32,8 +32,8 @@ export default function DateRange({ value, onChange }: Props) {
return {
today: { date_start: todayStr, date_end: todayStr },
last7: { date_start: fmt(last7), date_end: todayStr },
last30:{ date_start: fmt(last30), date_end: todayStr },
custom:{ date_start: start, date_end: end },
last30: { date_start: fmt(last30), date_end: todayStr },
custom: { date_start: start, date_end: end },
};
}, [start, end]);
@@ -41,7 +41,7 @@ export default function DateRange({ value, onChange }: Props) {
if (preset === "custom") {
onChange?.({ date_start: start, date_end: end, preset });
} else {
const pr = (presets as any)[preset] || presets.last7;
const pr = (presets as any)[preset] || presets.last30;
onChange?.({ ...pr, preset });
setStart(pr.date_start);
setEnd(pr.date_end);
@@ -53,7 +53,7 @@ export default function DateRange({ value, onChange }: Props) {
<div className="flex flex-col lg:flex-row gap-2 w-full">
<Select value={preset} onValueChange={(v) => setPreset(v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={__("Last 7 days")} />
<SelectValue placeholder={__("Last 30 days")} />
</SelectTrigger>
<SelectContent position="popper" className="z-[1000]">
<SelectGroup>

View File

@@ -86,7 +86,6 @@ export function RichTextEditor({
const currentContent = editor.getHTML();
// Only update if content is different (avoid infinite loops)
if (content !== currentContent) {
console.log('RichTextEditor: Updating content', { content, currentContent });
editor.commands.setContent(content);
}
}
@@ -113,7 +112,7 @@ export function RichTextEditor({
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
const [isEditingButton, setIsEditingButton] = useState(false);
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
@@ -388,12 +387,12 @@ export function RichTextEditor({
</div>
</div>
)}
{/* Customer Variables */}
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
{/* Subscriber/Customer Variables */}
{variables.some(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('_name') && !v.startsWith('order') && !v.startsWith('site') && !v.startsWith('store'))) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Subscriber')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
{variables.filter(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
<button
key={variable}
type="button"
@@ -425,11 +424,11 @@ export function RichTextEditor({
</div>
)}
{/* Store/Site Variables */}
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
<button
key={variable}
type="button"
@@ -501,13 +500,14 @@ export function RichTextEditor({
<div className="space-y-2">
<Label htmlFor="btn-style">{__('Button Style')}</Label>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
<SelectItem value="link">{__('Plain Link')}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -7,7 +7,7 @@ export interface ButtonOptions {
declare module '@tiptap/core' {
interface Commands<ReturnType> {
button: {
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType;
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' | 'link' }) => ReturnType;
};
}
}
@@ -70,20 +70,27 @@ export const ButtonExtension = Node.create<ButtonOptions>({
renderHTML({ HTMLAttributes }) {
const { text, href, style } = HTMLAttributes;
// Simple link styling - no fancy button appearance in editor
// The actual button styling happens in email rendering (EmailRenderer.php)
// In editor, just show as a styled link (differentiable from regular links)
// Different styling based on button style
let inlineStyle: string;
if (style === 'link') {
// Plain link - just underlined text, no button-like appearance
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer;';
} else {
// Solid/Outline buttons - show as styled link with background hint
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;';
}
return [
'a',
mergeAttributes(this.options.HTMLAttributes, {
href,
class: 'button-node',
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
class: style === 'link' ? 'link-node' : 'button-node',
style: inlineStyle,
'data-button': '',
'data-text': text,
'data-href': href,
'data-style': style,
title: `Button: ${text}${href}`,
title: style === 'link' ? `Link: ${text}` : `Button: ${text}${href}`,
}),
text,
];

View File

@@ -1,6 +1,7 @@
/* Import design tokens for UI sizing and control defaults */
@import './components/ui/tokens.css';
/* stylelint-disable at-rule-no-unknown */
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -82,12 +83,15 @@
}
/* Override WordPress common.css focus/active styles */
/* Override WordPress common.css focus/active styles */
/* Reverting this override as it causes issues with our custom button styles
a:focus,
a:active {
outline: none !important;
box-shadow: none !important;
color: inherit !important;
}
*/
}
/* ============================================
@@ -258,12 +262,8 @@
display: none !important;
}
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
.print-a4 {}
.print-letter {}
.print-4x6 {}
/* Optional page presets (opt-in by adding the class to a wrapper before printing)
These classes are used dynamically and styled via @media print rules below */
@media print {
@@ -302,7 +302,7 @@
color: white !important;
}
.print-letter {}
/* Letter format - extend as needed */
/* Thermal label (4x6in) with minimal margins */
.print-4x6 {

View File

@@ -8,11 +8,27 @@ export function htmlToMarkdown(html: string): string {
let markdown = html;
// Headings
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
// Store aligned headings for preservation
const alignedHeadings: { [key: string]: string } = {};
let headingIndex = 0;
// Process headings with potential style attributes
for (let level = 1; level <= 4; level++) {
const hashes = '#'.repeat(level);
markdown = markdown.replace(new RegExp(`<h${level}([^>]*)>(.*?)</h${level}>`, 'gis'), (match, attrs, content) => {
// Check for text-align in style attribute
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
if (alignMatch) {
const align = alignMatch[1].toLowerCase();
const placeholder = `[[HEADING${headingIndex}]]`;
alignedHeadings[placeholder] = `<h${level} style="text-align: ${align};">${content}</h${level}>`;
headingIndex++;
return placeholder + '\n\n';
}
// No alignment, convert to markdown
return `${hashes} ${content}\n\n`;
});
}
// Bold
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
@@ -100,6 +116,11 @@ export function htmlToMarkdown(html: string): string {
markdown = markdown.replace(placeholder, html);
});
// Restore aligned headings
Object.entries(alignedHeadings).forEach(([placeholder, html]) => {
markdown = markdown.replace(placeholder, html);
});
// Clean up excessive newlines
markdown = markdown.replace(/\n{3,}/g, '\n\n');

View File

@@ -96,15 +96,22 @@ export function markdownToHtml(markdown: string): string {
});
// Parse [button:style](url)Text[/button] (new syntax)
// Buttons are inline in TipTap, so don't wrap in <p>
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
if (style === 'link') {
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
}
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
});
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
if (style === 'link') {
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
}
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
});
// Parse remaining markdown
@@ -153,8 +160,11 @@ export function parseMarkdownBasics(text: string): string {
// Allow whitespace and newlines between parts
// Include data-button attributes for TipTap recognition
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
const trimmedText = text.trim();
if (style === 'link') {
return `<a href="${url}" class="text-link" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="link">${trimmedText}</a>`;
}
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
});

View File

@@ -8,10 +8,12 @@ import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Plus, X } from 'lucide-react';
import { Plus, X, Upload, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { useModules } from '@/hooks/useModules';
import { MediaUploader } from '@/components/MediaUploader';
import { __ } from '@/lib/i18n';
interface SocialLink {
id: string;
@@ -36,18 +38,37 @@ interface ContactData {
show_address: boolean;
}
interface PaymentMethod {
id: string;
url: string;
label: string;
}
export default function AppearanceFooter() {
const { isEnabled, isLoading: modulesLoading } = useModules();
const [loading, setLoading] = useState(true);
const [columns, setColumns] = useState('4');
const [style, setStyle] = useState('detailed');
const [copyrightText, setCopyrightText] = useState('© 2024 WooNooW. All rights reserved.');
const [copyright, setCopyright] = useState({
enabled: true,
text: '© 2024 WooNooW. All rights reserved.',
});
const [payment, setPayment] = useState<{
enabled: boolean;
title: string;
methods: PaymentMethod[];
}>({
enabled: true,
title: 'We accept',
methods: []
});
// Legacy elements toggle (only for newsletter, social, menu, contact)
const [elements, setElements] = useState({
newsletter: true,
social: true,
payment: true,
copyright: true,
menu: true,
contact: true,
});
@@ -70,11 +91,8 @@ export default function AppearanceFooter() {
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
];
// Only keeping newsletter_description, titles are now managed per column
const [labels, setLabels] = useState({
contact_title: 'Contact',
menu_title: 'Quick Links',
social_title: 'Follow Us',
newsletter_title: 'Newsletter',
newsletter_description: 'Subscribe to get updates',
});
@@ -87,8 +105,30 @@ export default function AppearanceFooter() {
if (footer) {
if (footer.columns) setColumns(footer.columns);
if (footer.style) setStyle(footer.style);
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
if (footer.elements) setElements(footer.elements);
// Handle new structure vs backward compatibility
if (footer.copyright) {
setCopyright(footer.copyright);
} else if (footer.copyright_text) {
// Migration fallback
setCopyright({
enabled: footer.elements?.copyright ?? true,
text: footer.copyright_text
});
}
if (footer.payment) {
setPayment(footer.payment);
} else if (footer.elements?.payment) {
// Migration fallback
setPayment(prev => ({ ...prev, enabled: footer.elements.payment }));
}
if (footer.elements) {
const { payment, copyright, ...rest } = footer.elements;
setElements(prev => ({ ...prev, ...rest }));
}
if (footer.social_links) setSocialLinks(footer.social_links);
if (footer.sections && footer.sections.length > 0) {
setSections(footer.sections);
@@ -96,7 +136,11 @@ export default function AppearanceFooter() {
setSections(defaultSections);
}
if (footer.contact_data) setContactData(footer.contact_data);
if (footer.labels) setLabels(footer.labels);
// Only sync description if it exists
if (footer.labels?.newsletter_description) {
setLabels({ newsletter_description: footer.labels.newsletter_description });
}
} else {
setSections(defaultSections);
}
@@ -152,7 +196,7 @@ export default function AppearanceFooter() {
...sections,
{
id: Date.now().toString(),
title: 'New Section',
title: 'New Column',
type: 'custom',
content: '',
visible: true,
@@ -168,12 +212,34 @@ export default function AppearanceFooter() {
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const addPaymentMethod = () => {
setPayment({
...payment,
methods: [...payment.methods, { id: Date.now().toString(), url: '', label: '' }]
});
};
const removePaymentMethod = (id: string) => {
setPayment({
...payment,
methods: payment.methods.filter(m => m.id !== id)
});
};
const updatePaymentMethod = (id: string, field: keyof PaymentMethod, value: string) => {
setPayment({
...payment,
methods: payment.methods.map(m => m.id === id ? { ...m, [field]: value } : m)
});
};
const handleSave = async () => {
try {
const payload = {
columns,
style,
copyrightText,
copyright,
payment,
elements,
socialLinks,
sections,
@@ -227,177 +293,127 @@ export default function AppearanceFooter() {
</SettingsSection>
</SettingsCard>
{/* Labels */}
{/* Content & Contact */}
<SettingsCard
title="Section Labels"
description="Customize footer section headings and text"
title="Content & Contact"
description="Manage footer content and contact details"
>
<SettingsSection label="Contact Title" htmlFor="contact-title">
<Input
id="contact-title"
value={labels.contact_title}
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
placeholder="Contact"
/>
</SettingsSection>
<SettingsSection label="Menu Title" htmlFor="menu-title">
<Input
id="menu-title"
value={labels.menu_title}
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
placeholder="Quick Links"
/>
</SettingsSection>
<SettingsSection label="Social Title" htmlFor="social-title">
<Input
id="social-title"
value={labels.social_title}
onChange={(e) => setLabels({ ...labels, social_title: e.target.value })}
placeholder="Follow Us"
/>
</SettingsSection>
<SettingsSection label="Newsletter Title" htmlFor="newsletter-title">
<Input
id="newsletter-title"
value={labels.newsletter_title}
onChange={(e) => setLabels({ ...labels, newsletter_title: e.target.value })}
placeholder="Newsletter"
/>
</SettingsSection>
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
<Input
id="newsletter-desc"
value={labels.newsletter_description}
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
placeholder="Subscribe to get updates"
/>
</SettingsSection>
</SettingsCard>
{/* Contact Data */}
<SettingsCard
title="Contact Information"
description="Manage contact details from Store Identity"
>
<SettingsSection label="Email" htmlFor="contact-email">
<Input
id="contact-email"
type="email"
value={contactData.email}
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
placeholder="info@store.com"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_email}
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Phone" htmlFor="contact-phone">
<Input
id="contact-phone"
type="tel"
value={contactData.phone}
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
placeholder="(123) 456-7890"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_phone}
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Address" htmlFor="contact-address">
<Textarea
id="contact-address"
value={contactData.address}
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
placeholder="123 Main St, City, State 12345"
rows={2}
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_address}
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
</SettingsCard>
{/* Content */}
<SettingsCard
title="Content"
description="Customize footer content"
>
<SettingsSection label="Copyright Text" htmlFor="copyright">
<Textarea
id="copyright"
value={copyrightText}
onChange={(e) => setCopyrightText(e.target.value)}
rows={2}
placeholder="© 2024 Your Store. All rights reserved."
/>
</SettingsSection>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Social Media Links</Label>
<Button onClick={addSocialLink} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Link
</Button>
</div>
<div className="space-y-3">
{socialLinks.map((link) => (
<div key={link.id} className="flex gap-2">
<Input
placeholder="Platform (e.g., Facebook)"
value={link.platform}
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
className="flex-1"
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Contact Information</h3>
<SettingsSection label="Email" htmlFor="contact-email">
<Input
id="contact-email"
type="email"
value={contactData.email}
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
placeholder="info@store.com"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_email}
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
/>
<Input
placeholder="URL"
value={link.url}
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
className="flex-1"
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Phone" htmlFor="contact-phone">
<Input
id="contact-phone"
type="tel"
value={contactData.phone}
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
placeholder="(123) 456-7890"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_phone}
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
/>
<Button
onClick={() => removeSocialLink(link.id)}
variant="ghost"
size="icon"
>
<X className="h-4 w-4" />
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Address" htmlFor="contact-address">
<Textarea
id="contact-address"
value={contactData.address}
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
placeholder="123 Main St, City, State 12345"
rows={2}
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_address}
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
</div>
<div className="border-t pt-6">
<h3 className="text-lg font-medium mb-4">General Content</h3>
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
<Input
id="newsletter-desc"
value={labels.newsletter_description}
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
placeholder="Subscribe to get updates"
/>
</SettingsSection>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Social Media Links</Label>
<Button onClick={addSocialLink} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Link
</Button>
</div>
))}
<div className="space-y-3">
{socialLinks.map((link) => (
<div key={link.id} className="flex gap-2">
<Input
placeholder="Platform (e.g., Facebook)"
value={link.platform}
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
className="flex-1"
/>
<Input
placeholder="URL"
value={link.url}
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
className="flex-1"
/>
<Button
onClick={() => removeSocialLink(link.id)}
variant="ghost"
size="icon"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</div>
</div>
</SettingsCard>
{/* Custom Sections Builder */}
{/* Custom Columns (was Custom Sections) */}
<SettingsCard
title="Custom Sections"
description="Build custom footer sections with flexible content"
title="Custom Columns"
description="Build footer columns with flexible content"
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Footer Sections</Label>
<Label>Footer Columns</Label>
<Button onClick={addSection} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Section
Add Column
</Button>
</div>
@@ -405,7 +421,7 @@ export default function AppearanceFooter() {
<div key={section.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<Input
placeholder="Section Title"
placeholder="Column Title"
value={section.title}
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
className="flex-1 mr-2"
@@ -458,11 +474,122 @@ export default function AppearanceFooter() {
{sections.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No custom sections yet. Click "Add Section" to create one.
No custom columns yet. Click "Add Column" to create one.
</p>
)}
</div>
</SettingsCard>
{/* Payment Methods */}
<SettingsCard
title="Payment Methods"
description="Configure accepted payment methods display"
>
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label>Show Payment Methods</Label>
</div>
<Switch
checked={payment.enabled}
onCheckedChange={(checked) => setPayment({ ...payment, enabled: checked })}
/>
</div>
{payment.enabled && (
<div className="space-y-4">
<SettingsSection label="Section Title" htmlFor="payment-title">
<Input
id="payment-title"
value={payment.title}
onChange={(e) => setPayment({ ...payment, title: e.target.value })}
placeholder="We accept"
/>
</SettingsSection>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Payment Logos</Label>
<Button onClick={addPaymentMethod} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Method
</Button>
</div>
<div className="grid gap-3">
{payment.methods.map((method) => (
<div key={method.id} className="flex gap-3 items-center border p-3 rounded-lg">
<div className="shrink-0">
<MediaUploader
onSelect={(url) => updatePaymentMethod(method.id, 'url', url)}
>
{method.url ? (
<div className="w-12 h-8 border rounded overflow-hidden relative group cursor-pointer">
<img src={method.url} alt={method.label} className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
<Upload className="w-3 h-3 text-white" />
</div>
</div>
) : (
<div className="w-12 h-8 border rounded bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80">
<Upload className="w-4 h-4 text-muted-foreground" />
</div>
)}
</MediaUploader>
</div>
<Input
placeholder="Label (e.g., Visa)"
value={method.label}
onChange={(e) => updatePaymentMethod(method.id, 'label', e.target.value)}
className="flex-1"
/>
<Button
onClick={() => removePaymentMethod(method.id)}
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{payment.methods.length === 0 && (
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/20 rounded-lg border border-dashed">
No payment methods added.
</div>
)}
</div>
</div>
</div>
)}
</SettingsCard>
{/* Copyright Section */}
<SettingsCard
title="Copyright"
description="Configure copyright notice"
>
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label>Show Copyright</Label>
</div>
<Switch
checked={copyright.enabled}
onCheckedChange={(checked) => setCopyright({ ...copyright, enabled: checked })}
/>
</div>
{copyright.enabled && (
<SettingsSection label="Copyright Text" htmlFor="copyright-text">
<Textarea
id="copyright-text"
value={copyright.text}
onChange={(e) => setCopyright({ ...copyright, text: e.target.value })}
rows={2}
placeholder="© 2024 Your Store. All rights reserved."
/>
</SettingsSection>
)}
</SettingsCard>
</SettingsLayout>
);
}

View File

@@ -29,6 +29,7 @@ export default function AppearanceGeneral() {
const [customHeading, setCustomHeading] = useState('');
const [customBody, setCustomBody] = useState('');
const [fontScale, setFontScale] = useState([1.0]);
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
const fontPairs = {
modern: { name: 'Modern & Clean', fonts: 'Inter' },
@@ -65,6 +66,9 @@ export default function AppearanceGeneral() {
setCustomBody(general.typography.custom?.body || '');
setFontScale([general.typography.scale || 1.0]);
}
if (general.container_width) {
setContainerWidth(general.container_width);
}
if (general.colors) {
setColors({
primary: general.colors.primary || '#1a1a1a',
@@ -110,6 +114,7 @@ export default function AppearanceGeneral() {
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
scale: fontScale[0],
},
containerWidth,
colors,
});
@@ -207,6 +212,36 @@ export default function AppearanceGeneral() {
<strong>Tip:</strong> You can set this page as your homepage in Settings Reading
</p>
</SettingsSection>
<SettingsSection label="Container Width" htmlFor="container-width">
<RadioGroup value={containerWidth} onValueChange={(value: any) => setContainerWidth(value)}>
<div className="flex items-start space-x-3">
<RadioGroupItem value="boxed" id="width-boxed" />
<div className="space-y-1">
<Label htmlFor="width-boxed" className="font-medium cursor-pointer">
Boxed
</Label>
<p className="text-sm text-muted-foreground">
Content centered with max-width (recommended)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="fullwidth" id="width-full" />
<div className="space-y-1">
<Label htmlFor="width-full" className="font-medium cursor-pointer">
Full Width
</Label>
<p className="text-sm text-muted-foreground">
Content fills entire screen width
</p>
</div>
</div>
</RadioGroup>
<p className="text-sm text-muted-foreground mt-2">
Default width for all pages (can be overridden per page)
</p>
</SettingsSection>
</div>
</SettingsCard>

View File

@@ -52,7 +52,9 @@ interface CanvasRendererProps {
onDuplicateSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
containerWidth?: 'boxed' | 'fullwidth' | 'default';
}
const SECTION_TYPES = [
@@ -84,7 +86,9 @@ export function CanvasRenderer({
onDuplicateSection,
onMoveSection,
onReorderSections,
onDeviceModeChange,
containerWidth = 'default',
}: CanvasRendererProps) {
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
@@ -149,7 +153,9 @@ export function CanvasRenderer({
<div
className={cn(
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
deviceMode === 'desktop' ? 'max-w-4xl' : 'max-w-sm'
deviceMode === 'mobile' ? 'max-w-sm' : (
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
)
)}
>
{sections.length === 0 ? (

View File

@@ -173,7 +173,7 @@ export function CanvasSection({
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent className="z-[60]">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
<AlertDialogDescription>

View File

@@ -16,6 +16,7 @@ import {
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Slider } from '@/components/ui/slider';
import {
@@ -51,6 +52,7 @@ interface PageItem {
title: string;
url?: string;
isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth';
}
interface InspectorPanelProps {
@@ -69,6 +71,7 @@ interface InspectorPanelProps {
onSetAsSpaLanding?: () => void;
onUnsetSpaLanding?: () => void;
onDeletePage?: () => void;
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
}
// Section field configurations
@@ -191,6 +194,7 @@ export function InspectorPanel({
onSetAsSpaLanding,
onUnsetSpaLanding,
onDeletePage,
onContainerWidthChange,
}: InspectorPanelProps) {
if (isCollapsed) {
return (
@@ -273,6 +277,31 @@ export function InspectorPanel({
</div>
)}
{/* Container Width */}
{!isTemplate && page && onContainerWidthChange && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('Container Width')}</Label>
<RadioGroup
value={page.containerWidth || 'boxed'}
onValueChange={(val: any) => onContainerWidthChange(val)}
className="gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="boxed" id="cw-boxed" />
<Label htmlFor="cw-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed')}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="fullwidth" id="cw-full" />
<Label htmlFor="cw-full" className="text-sm font-normal cursor-pointer">{__('Full Width')}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="default" id="cw-default" />
<Label htmlFor="cw-default" className="text-sm font-normal cursor-pointer text-gray-500">{__('Default (SPA Settings)')}</Label>
</div>
</RadioGroup>
</div>
)}
{/* Danger Zone */}
{!isTemplate && page && onDeletePage && (
<div className="pt-2 border-t mt-2">

View File

@@ -93,6 +93,12 @@ export default function AppearancePages() {
enabled: !!currentPage,
});
// Fetch global settings for defaults
const { data: globalSettings } = useQuery({
queryKey: ['appearance-settings'],
queryFn: async () => api.get('/appearance/settings'),
});
// Update store when page data loads
useEffect(() => {
if (pageData?.structure?.sections) {
@@ -106,6 +112,10 @@ export default function AppearancePages() {
if (pageData?.is_front_page !== undefined && currentPage) {
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
}
// Sync containerWidth
if (pageData?.container_width && currentPage) {
setCurrentPage({ ...currentPage, containerWidth: pageData.container_width });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
@@ -296,7 +306,13 @@ export default function AppearancePages() {
onDuplicateSection={duplicateSection}
onMoveSection={moveSection}
onReorderSections={reorderSections}
onDeviceModeChange={setDeviceMode}
containerWidth={
currentPage?.containerWidth && currentPage.containerWidth !== 'default'
? currentPage.containerWidth
: ((globalSettings as any)?.data?.general?.container_width || 'boxed')
}
/>
) : (
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
@@ -356,6 +372,12 @@ export default function AppearancePages() {
}}
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
onDeletePage={handleDeletePage}
onContainerWidthChange={(width) => {
if (currentPage) {
setCurrentPage({ ...currentPage, containerWidth: width });
markAsSaved(); // Mark as changed so save button enables
}
}}
/>
)
}

View File

@@ -62,6 +62,7 @@ export interface PageItem {
url?: string;
isFrontPage?: boolean;
isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth';
}
interface PageEditorState {
@@ -422,7 +423,10 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
},
body: JSON.stringify({ sections })
body: JSON.stringify({
sections,
container_width: currentPage.containerWidth
})
});
set({

View File

@@ -11,7 +11,14 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ErrorCard } from '@/components/ErrorCard';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit } from 'lucide-react';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { formatMoney } from '@/lib/currency';
export default function CustomersIndex() {
@@ -212,9 +219,8 @@ export default function CustomersIndex() {
</td>
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
<td className="p-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
{customer.role === 'customer' ? __('Member') : __('Guest')}
</span>
</td>
@@ -225,14 +231,37 @@ export default function CustomersIndex() {
<td className="p-3 text-sm text-muted-foreground">
{new Date(customer.registered).toLocaleDateString()}
</td>
<td className="p-3">
<button
onClick={() => navigate(`/customers/${customer.id}/edit`)}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Edit className="w-4 h-4" />
{__('Edit')}
</button>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}`)}>
<Eye className="mr-2 h-4 w-4" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this customer?'))) {
deleteMutation.mutate([customer.id]);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

@@ -1,12 +1,11 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import {
ArrowLeft,
Send,
@@ -21,6 +20,7 @@ import { __ } from '@/lib/i18n';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@@ -181,22 +181,25 @@ export default function CampaignEdit() {
if (!isNew && isLoading) {
return (
<SettingsLayout title={__('Loading...')} description="">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</SettingsLayout>
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<SettingsLayout
title={isNew ? __('New Campaign') : __('Edit Campaign')}
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
>
{/* Back button */}
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium">
{isNew ? __('New Campaign') : __('Edit Campaign')}
</h2>
<p className="text-sm text-muted-foreground">
{isNew ? __('Create a new email campaign') : campaign?.title || ''}
</p>
</div>
<Button variant="ghost" onClick={() => navigate('/marketing/newsletter/campaigns')}>
<ArrowLeft className="mr-2 h-4 w-4" />
{__('Back to Campaigns')}
</Button>
@@ -245,15 +248,14 @@ export default function CampaignEdit() {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="content">{__('Email Content')}</Label>
<Textarea
id="content"
placeholder={__('Write your newsletter content here...\n\nYou can use:\n- {site_name} - Your store name\n- {current_date} - Today\'s date\n- {subscriber_email} - Subscriber\'s email')}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[300px] font-mono text-sm"
<RichTextEditor
content={content}
onChange={setContent}
placeholder={__('Write your newsletter content here...')}
variables={['site_name', 'current_date', 'subscriber_email', 'current_year', 'store_name', 'unsubscribe_url']}
/>
<p className="text-xs text-muted-foreground">
{__('Use HTML for rich formatting. The design wrapper will be applied from your campaign email template.')}
{__('Use the toolbar to format text. The design wrapper will be applied from your campaign email template.')}
</p>
</div>
</div>
@@ -323,11 +325,13 @@ export default function CampaignEdit() {
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Email Preview')}</DialogTitle>
<DialogDescription>{__('Preview how your email will look to subscribers')}</DialogDescription>
</DialogHeader>
<div className="border rounded-lg bg-white p-4">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: previewHtml }}
<div className="border rounded-lg overflow-hidden bg-gray-100">
<iframe
srcDoc={previewHtml}
className="w-full min-h-[600px] bg-white"
title={__('Email Preview')}
/>
</div>
</DialogContent>
@@ -338,8 +342,9 @@ export default function CampaignEdit() {
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Send Test Email')}</DialogTitle>
<DialogDescription>{__('Send a test email to verify your campaign before sending to all subscribers')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input
@@ -395,6 +400,6 @@ export default function CampaignEdit() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsLayout>
</div>
);
}

View File

@@ -1,8 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -44,6 +42,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
interface Campaign {
id: number;
@@ -131,14 +130,18 @@ export default function CampaignsList() {
};
return (
<SettingsLayout
<SettingsCard
title={__('Campaigns')}
description={__('Create and send email campaigns to your newsletter subscribers')}
>
<SettingsCard
title={__('All Campaigns')}
description={`${campaigns.length} ${__('campaigns total')}`}
>
<div className="space-y-4">
{/* Header with count */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">{__('All Campaigns')}</h3>
<p className="text-sm text-muted-foreground">{campaigns.length} {__('campaigns total')}</p>
</div>
</div>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@@ -151,10 +154,7 @@ export default function CampaignsList() {
className="!pl-9"
/>
</div>
<Button onClick={() => navigate('/marketing/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('New Campaign')}
</Button>
{/* New Campaign button removed - available in sidebar */}
</div>
{/* Campaigns Table */}
@@ -168,7 +168,7 @@ export default function CampaignsList() {
<div className="space-y-4">
<Send className="h-12 w-12 mx-auto opacity-50" />
<p>{__('No campaigns yet')}</p>
<Button onClick={() => navigate('/marketing/campaigns/new')}>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('Create your first campaign')}
</Button>
@@ -228,8 +228,8 @@ export default function CampaignsList() {
{campaign.sent_at
? formatDate(campaign.sent_at)
: campaign.scheduled_at
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
: formatDate(campaign.created_at)
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
: formatDate(campaign.created_at)
}
</TableCell>
<TableCell className="text-right">
@@ -240,7 +240,7 @@ export default function CampaignsList() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/marketing/campaigns/${campaign.id}`)}>
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
@@ -266,7 +266,7 @@ export default function CampaignsList() {
</div>
)}
</div>
</SettingsCard>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
@@ -288,6 +288,6 @@ export default function CampaignsList() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsLayout>
</SettingsCard>
);
}

View File

@@ -8,10 +8,18 @@ import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFABConfig } from '@/hooks/useFABConfig';
import { CouponFilterSheet } from './components/CouponFilterSheet';
import { CouponCard } from './components/CouponCard';
@@ -264,7 +272,7 @@ export default function CouponsIndex() {
</td>
<td className="p-3">
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
{coupon.code}
{coupon.code}
</Link>
{coupon.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
@@ -289,13 +297,32 @@ export default function CouponsIndex() {
)}
</td>
<td className="p-3 text-center">
<button
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
>
<Edit className="w-4 h-4" />
{__('Edit')}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/coupons/${coupon.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this coupon?'))) {
deleteMutation.mutate(coupon.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

@@ -1,7 +1,6 @@
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 {
@@ -131,138 +130,131 @@ export default function Campaigns() {
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>
{/* 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>
{/* 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>
</SettingsCard>
) : (
<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>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>

View File

@@ -3,7 +3,8 @@ 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 { Checkbox } from '@/components/ui/checkbox';
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
@@ -16,6 +17,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export default function Subscribers() {
const [searchQuery, setSearchQuery] = useState('');
@@ -66,91 +73,147 @@ export default function Subscribers() {
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
);
// Checkbox logic
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
const toggleAll = () => {
if (selectedIds.length === filteredSubscribers.length) {
setSelectedIds([]);
} else {
setSelectedIds(filteredSubscribers.map((s: any) => s.email));
}
};
const toggleRow = (email: string) => {
setSelectedIds(prev =>
prev.includes(email) ? prev.filter(e => e !== email) : [...prev, email]
);
};
const handleBulkDelete = async () => {
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
for (const email of selectedIds) {
await deleteSubscriber.mutateAsync(email);
}
setSelectedIds([]);
};
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>
)}
{/* 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>
</SettingsCard>
<div className="flex gap-2">
{selectedIds.length > 0 && (
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')} ({selectedIds.length})
</Button>
)}
<Button onClick={exportSubscribers} variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
{__('Export CSV')}
</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 className="w-12 p-3">
<Checkbox
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<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="p-3">
<Checkbox
checked={selectedIds.includes(subscriber.email)}
onCheckedChange={() => toggleRow(subscriber.email)}
aria-label={__('Select subscriber')}
/>
</TableCell>
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
deleteSubscriber.mutate(subscriber.email);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Remove')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
{/* Email Template Settings */}
<SettingsCard
@@ -187,6 +250,6 @@ export default function Subscribers() {
</div>
</div>
</SettingsCard>
</div>
</div >
);
}

View File

@@ -1,41 +1,24 @@
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 React, { useState } from 'react';
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Mail } from 'lucide-react';
import { Mail, Users, Send } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { useModules } from '@/hooks/useModules';
import Subscribers from './Subscribers';
import Campaigns from './Campaigns';
import { cn } from '@/lib/utils'; // Assuming cn exists, widely used in ShadCN
export default function Newsletter() {
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('subscribers');
export default function NewsletterLayout() {
const navigate = useNavigate();
const location = useLocation();
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="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
<p className="text-muted-foreground mt-2">{__('Newsletter module is disabled')}</p>
</div>
<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>
@@ -46,29 +29,78 @@ export default function Newsletter() {
{__('Go to Module Settings')}
</Button>
</div>
</SettingsLayout>
</div>
);
}
const navItems = [
{
id: 'subscribers',
label: __('Subscribers'),
icon: Users,
path: '/marketing/newsletter/subscribers',
isActive: (path: string) => path.includes('/subscribers')
},
{
id: 'campaigns',
label: __('Campaigns'),
icon: Send,
path: '/marketing/newsletter/campaigns',
isActive: (path: string) => path.includes('/campaigns')
}
];
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>
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
<p className="text-muted-foreground mt-2">{__('Manage subscribers and send email campaigns')}</p>
</div>
<TabsContent value="subscribers" className="space-y-4 mt-6">
<Subscribers />
</TabsContent>
<div className="flex flex-col lg:flex-row gap-6">
{/* Sidebar Navigation */}
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.isActive(location.pathname);
return (
<Link
key={item.id}
to={item.path}
className={cn(
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
// Focus styles matching ShadCN buttons (ring only on keyboard focus)
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
)}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
<TabsContent value="campaigns" className="space-y-4 mt-6">
<Campaigns />
</TabsContent>
</Tabs>
</SettingsLayout>
<div className="pt-4 border-t">
<Button
className="w-full justify-start"
variant="outline"
onClick={() => navigate('/marketing/newsletter/campaigns/new')}
>
<span className="mr-2">+</span>
{__('New Campaign')}
</Button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { Mail, Tag } from 'lucide-react';
import { __ } from '@/lib/i18n';
@@ -29,10 +28,12 @@ export default function Marketing() {
const navigate = useNavigate();
return (
<SettingsLayout
title={__('Marketing')}
description={__('Newsletter, campaigns, and promotions')}
>
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
<p className="text-muted-foreground mt-2">{__('Newsletter, campaigns, and promotions')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<button
@@ -52,6 +53,6 @@ export default function Marketing() {
</button>
))}
</div>
</SettingsLayout>
</div>
);
}

View File

@@ -39,7 +39,7 @@ function StatusBadge({ status }: { status?: string }) {
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
}
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed'];
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed', 'draft'];
export default function OrderShow() {
const { id } = useParams<{ id: string }>();
@@ -315,6 +315,69 @@ export default function OrderShow() {
</div>
)}
{/* Related Items (Subscription & Licenses) */}
{(order.related_subscription || (order.related_licenses && order.related_licenses.length > 0)) && (
<div className="rounded border overflow-hidden">
<div className="px-4 py-3 border-b font-medium">{__('Related Items')}</div>
<div className="p-4 space-y-4">
{/* Related Subscription */}
{order.related_subscription && (
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-muted-foreground" />
{__('Subscription')}
</div>
<div className="text-xs text-muted-foreground mt-1">
{order.related_subscription.billing_schedule} <span className="capitalize">{order.related_subscription.status}</span>
</div>
</div>
<Link to={`/subscriptions/${order.related_subscription.id}`}>
<Button variant="outline" size="sm" className="h-8">
#{order.related_subscription.id}
</Button>
</Link>
</div>
)}
{/* Separator if both exist */}
{order.related_subscription && order.related_licenses && order.related_licenses.length > 0 && (
<div className="border-t"></div>
)}
{/* Related Licenses */}
{order.related_licenses && order.related_licenses.length > 0 && (
<div className="space-y-3">
{order.related_licenses.map((lic: any) => (
<div key={lic.id} className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium flex items-center gap-2">
<Ticket className="w-4 h-4 text-muted-foreground" />
{__('License Key')}
</div>
<div className="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded mt-1.5 inline-block break-all select-all">
{lic.license_key}
</div>
<div className="text-xs text-muted-foreground mt-1 truncate">
{lic.product_name}
</div>
</div>
<div className="text-right">
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border uppercase ${lic.status === 'active' ? 'bg-green-100 text-green-800 border-green-200' :
lic.status === 'expired' ? 'bg-red-100 text-red-800 border-red-200' :
'bg-gray-100 text-gray-700 border-gray-200'
}`}>
{lic.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Items */}
<div className="rounded border overflow-hidden">
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react';
import { Filter, PackageOpen, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
@@ -19,6 +19,13 @@ import {
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import {
Select,
@@ -94,8 +101,8 @@ export default function Orders() {
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -432,7 +439,7 @@ export default function Orders() {
/>
</td>
<td className="p-3">
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
<Link className="font-medium hover:underline" to={`/orders/${row.id}`}>#{row.number}</Link>
</td>
<td className="p-3 min-w-32">
<span title={row.date ?? ""}>
@@ -454,9 +461,36 @@ export default function Orders() {
decimals: store.decimals,
})}
</td>
<td className="p-3 text-center space-x-2">
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}`)}>
<Eye className="mr-2 h-4 w-4" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit Order')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setSelectedIds([row.id]);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
@@ -27,6 +27,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Link, useNavigate } from 'react-router-dom';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import { Skeleton } from '@/components/ui/skeleton';
@@ -62,8 +69,8 @@ export default function Products() {
const [type, setType] = useState<string | undefined>(initial.type || undefined);
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'title' | 'id' | 'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -412,9 +419,37 @@ export default function Products() {
</span>
</td>
<td className="p-3 text-right">
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
{__('Edit')}
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => nav(`/products/${product.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
{product.permalink && (
<DropdownMenuItem onClick={() => window.open(product.permalink, '_blank')}>
<Eye className="mr-2 h-4 w-4" />
{__('View')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setSelectedIds([product.id]);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

@@ -218,7 +218,7 @@ export default function EditTemplate() {
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
store_url: window.location.origin,
site_url: window.location.origin,
store_email: 'store@example.com',
};
@@ -298,7 +298,7 @@ export default function EditTemplate() {
current_year: new Date().getFullYear().toString(),
site_name: 'My WordPress Store',
store_name: 'My WordPress Store',
store_url: '#',
site_url: '#',
store_email: 'store@example.com',
support_email: 'support@example.com',
// Account-related URLs and variables
@@ -310,6 +310,9 @@ export default function EditTemplate() {
user_temp_password: '••••••••',
customer_first_name: 'John',
customer_last_name: 'Doe',
// Campaign/Newsletter variables
content: '<p>This is sample content that would be replaced with your actual campaign content.</p>',
campaign_title: 'Newsletter Campaign',
};
Object.keys(sampleData).forEach((key) => {
@@ -393,6 +396,7 @@ export default function EditTemplate() {
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
.text-link { color: ${primaryColor}; text-decoration: underline; }
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
@@ -597,7 +601,7 @@ export default function EditTemplate() {
{__('Send a test email with sample data to verify the template looks correct.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input

View File

@@ -124,7 +124,7 @@ export default function TemplateEditor({
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
store_url: window.location.origin,
site_url: window.location.origin,
store_email: 'store@example.com',
};

View File

@@ -0,0 +1,298 @@
import React, { useState, useEffect } from 'react';
import { __ } from '@/lib/i18n';
import { Shield, AlertTriangle, ExternalLink } from 'lucide-react';
import { SettingsLayout } from './components/SettingsLayout';
import { SettingsCard } from './components/SettingsCard';
import { ToggleField } from './components/ToggleField';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
interface SecuritySettings {
enable_checkout_rate_limit: boolean;
rate_limit_orders: number;
rate_limit_minutes: number;
captcha_provider: 'none' | 'recaptcha' | 'turnstile';
recaptcha_site_key: string;
recaptcha_secret_key: string;
turnstile_site_key: string;
turnstile_secret_key: string;
}
export default function SecuritySettings() {
const [settings, setSettings] = useState<SecuritySettings>({
enable_checkout_rate_limit: true,
rate_limit_orders: 5,
rate_limit_minutes: 10,
captcha_provider: 'none',
recaptcha_site_key: '',
recaptcha_secret_key: '',
turnstile_site_key: '',
turnstile_secret_key: '',
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
setIsLoading(true);
const response = await fetch(
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/security-settings`,
{
credentials: 'include',
headers: {
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
},
}
);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setSettings(data);
} catch (error) {
console.error('Error:', error);
setMessage('Failed to load settings');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
try {
setIsSaving(true);
setMessage('');
const response = await fetch(
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/security-settings`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
},
body: JSON.stringify(settings),
}
);
if (!response.ok) throw new Error('Failed to save');
const data = await response.json();
setMessage(data.message || 'Settings saved successfully');
if (data.settings) setSettings(data.settings);
} catch (error) {
console.error('Error:', error);
setMessage('Failed to save settings');
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<SettingsLayout
title={__('Security Settings')}
description={__('Configure checkout security and spam protection')}
isLoading={true}
>
<div className="animate-pulse h-64 bg-muted rounded-lg"></div>
</SettingsLayout>
);
}
return (
<SettingsLayout
title={__('Security Settings')}
description={__('Configure checkout security and spam protection')}
onSave={handleSave}
saveLabel={__('Save Changes')}
>
{message && (
<div className={`p-4 rounded-lg ${message.includes('success') ? 'bg-green-50 text-green-900' : 'bg-red-50 text-red-900'}`}>
{message}
</div>
)}
{/* Rate Limiting */}
<SettingsCard
title={__('Rate Limiting')}
description={__('Prevent order bombing by limiting orders per IP')}
>
<div className="space-y-6">
<ToggleField
id="enable_checkout_rate_limit"
label={__('Enable checkout rate limiting')}
description={__('Limit the number of orders that can be placed from a single IP address within a time window.')}
checked={settings.enable_checkout_rate_limit}
onCheckedChange={(checked) => setSettings({ ...settings, enable_checkout_rate_limit: checked })}
/>
{settings.enable_checkout_rate_limit && (
<div className="grid grid-cols-2 gap-4 pl-6 border-l-2 border-muted">
<div className="space-y-2">
<Label htmlFor="rate_limit_orders">{__('Maximum orders')}</Label>
<Input
id="rate_limit_orders"
type="number"
min={1}
max={100}
value={settings.rate_limit_orders}
onChange={(e) => setSettings({ ...settings, rate_limit_orders: parseInt(e.target.value) || 5 })}
/>
<p className="text-sm text-muted-foreground">
{__('Number of orders allowed per IP')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rate_limit_minutes">{__('Time window (minutes)')}</Label>
<Input
id="rate_limit_minutes"
type="number"
min={1}
max={1440}
value={settings.rate_limit_minutes}
onChange={(e) => setSettings({ ...settings, rate_limit_minutes: parseInt(e.target.value) || 10 })}
/>
<p className="text-sm text-muted-foreground">
{__('Reset period in minutes')}
</p>
</div>
</div>
)}
</div>
</SettingsCard>
{/* CAPTCHA */}
<SettingsCard
title={__('CAPTCHA Protection')}
description={__('Add invisible bot protection to checkout')}
>
<div className="space-y-6">
<div className="flex gap-3 p-4 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-900 dark:text-amber-100">
<p className="font-medium mb-1">{__('Invisible CAPTCHA')}</p>
<p className="text-amber-700 dark:text-amber-300">
{__('Both options use invisible verification - no user interaction required. They detect bots automatically in the background.')}
</p>
</div>
</div>
<RadioGroup
value={settings.captcha_provider}
onValueChange={(value: SecuritySettings['captcha_provider']) =>
setSettings({ ...settings, captcha_provider: value })
}
className="space-y-4"
>
<div className="flex items-start space-x-3">
<RadioGroupItem value="none" id="captcha_none" className="mt-1" />
<div>
<Label htmlFor="captcha_none" className="font-medium cursor-pointer">
{__('None')}
</Label>
<p className="text-sm text-muted-foreground">
{__('No CAPTCHA verification')}
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="recaptcha" id="captcha_recaptcha" className="mt-1" />
<div className="flex-1">
<Label htmlFor="captcha_recaptcha" className="font-medium cursor-pointer">
{__('Google reCAPTCHA v3')}
</Label>
<p className="text-sm text-muted-foreground mb-3">
{__('Invisible verification by Google')}
<a
href="https://www.google.com/recaptcha/admin/create"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-primary hover:underline inline-flex items-center gap-1"
>
{__('Get API keys')} <ExternalLink className="w-3 h-3" />
</a>
</p>
{settings.captcha_provider === 'recaptcha' && (
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="recaptcha_site_key">{__('Site Key')}</Label>
<Input
id="recaptcha_site_key"
type="text"
placeholder="6Le..."
value={settings.recaptcha_site_key}
onChange={(e) => setSettings({ ...settings, recaptcha_site_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="recaptcha_secret_key">{__('Secret Key')}</Label>
<Input
id="recaptcha_secret_key"
type="password"
placeholder="6Le..."
value={settings.recaptcha_secret_key}
onChange={(e) => setSettings({ ...settings, recaptcha_secret_key: e.target.value })}
/>
</div>
</div>
)}
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="turnstile" id="captcha_turnstile" className="mt-1" />
<div className="flex-1">
<Label htmlFor="captcha_turnstile" className="font-medium cursor-pointer">
{__('Cloudflare Turnstile')}
</Label>
<p className="text-sm text-muted-foreground mb-3">
{__('Privacy-focused invisible verification by Cloudflare')}
<a
href="https://dash.cloudflare.com/?to=/:account/turnstile"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-primary hover:underline inline-flex items-center gap-1"
>
{__('Get API keys')} <ExternalLink className="w-3 h-3" />
</a>
</p>
{settings.captcha_provider === 'turnstile' && (
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="turnstile_site_key">{__('Site Key')}</Label>
<Input
id="turnstile_site_key"
type="text"
placeholder="0x..."
value={settings.turnstile_site_key}
onChange={(e) => setSettings({ ...settings, turnstile_site_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="turnstile_secret_key">{__('Secret Key')}</Label>
<Input
id="turnstile_secret_key"
type="password"
placeholder="0x..."
value={settings.turnstile_secret_key}
onChange={(e) => setSettings({ ...settings, turnstile_secret_key: e.target.value })}
/>
</div>
</div>
)}
</div>
</div>
</RadioGroup>
</div>
</SettingsCard>
</SettingsLayout>
);
}

View File

@@ -48,6 +48,7 @@ interface Subscription {
end_date: string | null;
last_payment_date: string | null;
payment_method: string;
payment_method_title?: string;
pause_count: number;
failed_payment_count: number;
cancel_reason: string | null;
@@ -65,6 +66,7 @@ const statusColors: Record<string, string> = {
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
'draft': 'bg-gray-100 text-gray-600',
};
const statusLabels: Record<string, string> = {
@@ -74,6 +76,7 @@ const statusLabels: Record<string, string> = {
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
'draft': __('Draft'),
};
const orderTypeLabels: Record<string, string> = {
@@ -83,6 +86,22 @@ const orderTypeLabels: Record<string, string> = {
'resubscribe': __('Resubscribe'),
};
const formatPrice = (amount: string | number) => {
const val = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(val)) return amount;
// Simple formatting using browser's locale but keeping currency from store
try {
return new Intl.NumberFormat(window.WNW_STORE?.locale || 'en-US', {
style: 'currency',
currency: window.WNW_STORE?.currency || 'USD',
minimumFractionDigits: window.WNW_STORE?.decimals || 2,
}).format(val);
} catch (e) {
return (window.WNW_STORE?.currency_symbol || '$') + val;
}
};
async function fetchSubscription(id: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
@@ -257,7 +276,7 @@ export default function SubscriptionDetail() {
{subscription.billing_schedule}
</p>
<p className="text-lg font-semibold mt-1">
{window.WNW_STORE?.currency_symbol}{subscription.recurring_amount}
{formatPrice(subscription.recurring_amount)}
</p>
</div>
</div>
@@ -317,7 +336,7 @@ export default function SubscriptionDetail() {
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{subscription.payment_method || __('Not set')}
{subscription.payment_method_title || subscription.payment_method || __('Not set')}
</div>
</div>
<div>
@@ -368,29 +387,32 @@ export default function SubscriptionDetail() {
</TableCell>
</TableRow>
) : (
subscription.orders?.map((order) => (
<TableRow key={order.id}>
<TableCell>
<Link
to={`/orders/${order.order_id}`}
className="text-primary hover:underline font-medium"
>
#{order.order_id}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">
{orderTypeLabels[order.order_type] || order.order_type}
</Badge>
</TableCell>
<TableCell>
<span className="capitalize">{order.order_status?.replace('wc-', '')}</span>
</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
))
subscription.orders?.map((order) => {
const rawStatus = order.order_status?.replace('wc-', '') || 'pending';
return (
<TableRow key={order.id}>
<TableCell>
<Link
to={`/orders/${order.order_id}`}
className="text-primary hover:underline font-medium"
>
#{order.order_id}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">
{orderTypeLabels[order.order_type] || order.order_type}
</Badge>
</TableCell>
<TableCell>
<span className="capitalize">{statusLabels[rawStatus] || rawStatus}</span>
</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>

View File

@@ -4,6 +4,7 @@ import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
@@ -151,6 +152,23 @@ export default function SubscriptionsIndex() {
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
// Checkbox logic
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const toggleAll = () => {
if (selectedIds.length === subscriptions.length) {
setSelectedIds([]);
} else {
setSelectedIds(subscriptions.map(s => s.id));
}
};
const toggleRow = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
return (
<div className="space-y-6">
{/* Header */}
@@ -181,6 +199,13 @@ export default function SubscriptionsIndex() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
<Checkbox
checked={subscriptions.length > 0 && selectedIds.length === subscriptions.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<TableHead className="w-[80px]">{__('ID')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Product')}</TableHead>
@@ -215,7 +240,18 @@ export default function SubscriptionsIndex() {
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell className="font-medium">#{sub.id}</TableCell>
<TableCell className="p-3">
<Checkbox
checked={selectedIds.includes(sub.id)}
onCheckedChange={() => toggleRow(sub.id)}
aria-label={__('Select subscription')}
/>
</TableCell>
<TableCell className="font-medium">
<Link to={`/subscriptions/${sub.id}`} className="hover:underline">
#{sub.id}
</Link>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{sub.user_name}</div>

View File

@@ -47,12 +47,25 @@ interface WNW_CONFIG {
pluginUrl?: string;
}
interface WNW_Store {
locale?: string;
currency?: string;
currency_symbol?: string;
currency_pos?: string;
thousand_sep?: string;
decimal_sep?: string;
decimals?: number;
symbol?: string; // Sometimes mapped
position?: string;
}
declare global {
interface Window {
WNW_API?: WNW_API_Config;
WNW_API: WNW_API_Config; // Make required to avoid "possibly undefined" check in every usage if we are sure it exists
wnw?: WNW_Config;
WNW_WC_MENUS?: WNW_WC_MENUS;
WNW_CONFIG?: WNW_CONFIG;
WNW_STORE?: WNW_Store;
}
}

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useRef, useState } from 'react';
declare global {
interface Window {
grecaptcha: {
ready: (callback: () => void) => void;
execute: (siteKey: string, options: { action: string }) => Promise<string>;
};
turnstile: {
render: (container: HTMLElement, options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'invisible';
appearance?: 'always' | 'execute' | 'interaction-only';
}) => string;
reset: (widgetId: string) => void;
execute: (container: HTMLElement | string) => void;
remove: (widgetId: string) => void;
};
}
}
interface CaptchaWidgetProps {
provider: 'none' | 'recaptcha' | 'turnstile';
siteKey: string;
onToken: (token: string) => void;
action?: string; // for reCAPTCHA v3 action name
}
/**
* Invisible CAPTCHA widget for checkout
* Supports Google reCAPTCHA v3 and Cloudflare Turnstile
*/
export function CaptchaWidget({ provider, siteKey, onToken, action = 'checkout' }: CaptchaWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (provider === 'none' || !siteKey) {
return;
}
// Load the appropriate script
const loadScript = (src: string, id: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (document.getElementById(id)) {
resolve();
return;
}
const script = document.createElement('script');
script.id = id;
script.src = src;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${id}`));
document.head.appendChild(script);
});
};
const initCaptcha = async () => {
try {
if (provider === 'recaptcha') {
await loadScript(
`https://www.google.com/recaptcha/api.js?render=${siteKey}`,
'recaptcha-script'
);
// Wait for grecaptcha to be ready
window.grecaptcha.ready(() => {
setIsLoaded(true);
});
} else if (provider === 'turnstile') {
await loadScript(
'https://challenges.cloudflare.com/turnstile/v0/api.js',
'turnstile-script'
);
// Wait a bit for turnstile to initialize
setTimeout(() => {
if (containerRef.current && window.turnstile) {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (token: string) => {
onToken(token);
},
'expired-callback': () => {
onToken('');
},
'error-callback': () => {
onToken('');
},
appearance: 'interaction-only',
size: 'invisible',
});
setIsLoaded(true);
}
}, 100);
}
} catch (error) {
console.error('Failed to load CAPTCHA:', error);
}
};
initCaptcha();
// Cleanup
return () => {
if (provider === 'turnstile' && widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current);
} catch {
// Ignore errors during cleanup
}
}
};
}, [provider, siteKey]);
// Execute reCAPTCHA when loaded
useEffect(() => {
if (provider === 'recaptcha' && isLoaded && window.grecaptcha) {
const executeRecaptcha = async () => {
try {
const token = await window.grecaptcha.execute(siteKey, { action });
onToken(token);
} catch (error) {
console.error('reCAPTCHA execute failed:', error);
onToken('');
}
};
// Execute immediately and then every 2 minutes (tokens expire)
executeRecaptcha();
const interval = setInterval(executeRecaptcha, 2 * 60 * 1000);
return () => clearInterval(interval);
}
}, [provider, isLoaded, siteKey, action, onToken]);
// Render nothing visible - both are invisible modes
if (provider === 'none' || !siteKey) {
return null;
}
return (
<div
ref={containerRef}
className="captcha-widget"
style={{
position: 'absolute',
visibility: 'hidden',
width: 0,
height: 0,
overflow: 'hidden',
}}
/>
);
}
export default CaptchaWidget;

View File

@@ -3,10 +3,17 @@ import { toast } from 'sonner';
interface NewsletterFormProps {
description?: string;
gdprRequired?: boolean;
consentText?: string;
}
export function NewsletterForm({ description }: NewsletterFormProps) {
export function NewsletterForm({
description,
gdprRequired = false,
consentText = 'I agree to receive marketing emails and understand I can unsubscribe at any time.',
}: NewsletterFormProps) {
const [email, setEmail] = useState('');
const [consent, setConsent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
@@ -17,6 +24,11 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
return;
}
if (gdprRequired && !consent) {
toast.error('Please accept the terms to subscribe');
return;
}
setLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
@@ -26,7 +38,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email }),
body: JSON.stringify({ email, consent }),
});
const data = await response.json();
@@ -34,6 +46,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
if (response.ok) {
toast.success(data.message || 'Successfully subscribed to newsletter!');
setEmail('');
setConsent(false);
} else {
toast.error(data.message || 'Failed to subscribe. Please try again.');
}
@@ -48,7 +61,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
return (
<div>
{description && <p className="text-sm text-gray-600 mb-4">{description}</p>}
<form onSubmit={handleSubmit} className="space-y-2">
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
value={email}
@@ -57,9 +70,25 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
className="w-full px-4 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={loading}
/>
{gdprRequired && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-1 rounded border-gray-300"
disabled={loading}
/>
<span className="text-xs text-gray-600 leading-relaxed">
{consentText}
</span>
</label>
)}
<button
type="submit"
disabled={loading}
disabled={loading || (gdprRequired && !consent)}
className="font-[inherit] w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Subscribing...' : 'Subscribe'}

View File

@@ -301,6 +301,8 @@ export function useFooterSettings() {
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
},
payment: data?.footer?.payment,
copyright: data?.footer?.copyright,
isLoading,
};
};

View File

@@ -350,21 +350,38 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</div>
{/* Payment Icons */}
{footerSettings.elements.payment && (
{(footerSettings.payment ? footerSettings.payment.enabled : footerSettings.elements.payment) && (
<div className="mt-8 pt-8 border-t">
<p className="text-xs text-gray-500 text-center mb-4">We accept</p>
<div className="flex justify-center gap-4 text-gray-400">
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
<p className="text-xs text-gray-500 text-center mb-4">
{footerSettings.payment?.title || 'We accept'}
</p>
<div className="flex justify-center gap-4 text-gray-400 items-center">
{footerSettings.payment?.methods && footerSettings.payment.methods.length > 0 ? (
footerSettings.payment.methods.map((method: any) => (
<div key={method.id} title={method.label}>
{method.url ? (
<img src={method.url} alt={method.label} className="h-6 w-auto object-contain" />
) : (
<span className="text-xs">💳 {method.label}</span>
)}
</div>
))
) : (
// Fallback for legacy or empty methods
<>
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
</>
)}
</div>
</div>
)}
{/* Copyright */}
{footerSettings.elements.copyright && (
{(footerSettings.copyright ? footerSettings.copyright.enabled : footerSettings.elements.copyright) && (
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
{footerSettings.copyright_text}
{footerSettings.copyright?.text || footerSettings.copyright_text}
</div>
)}
</div>

View File

@@ -14,6 +14,7 @@ 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';
import { CaptchaWidget } from '@/components/CaptchaWidget';
interface SavedAddress {
id: number;
@@ -42,7 +43,9 @@ export default function Checkout() {
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
const [discountTotal, setDiscountTotal] = useState(0);
const [captchaToken, setCaptchaToken] = useState('');
const user = (window as any).woonoowCustomer?.user;
const security = (window as any).woonoowCustomer?.security;
// Check if cart needs shipping (virtual-only carts don't need shipping)
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
@@ -567,6 +570,8 @@ export default function Checkout() {
customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
// CAPTCHA token for security validation
captcha_token: captchaToken,
};
// Submit order
@@ -579,10 +584,18 @@ export default function Checkout() {
toast.success('Order placed successfully!');
// Navigate to thank you page via SPA routing
// Using window.location.replace to prevent back button issues
// Build thank you page URL
const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
navigate(thankYouUrl, { replace: true });
// If user was logged in during this request (guest auto-register),
// we need a full page reload to recognize the auth cookie
if (data.user_logged_in) {
// Full page reload so browser recognizes the new auth cookie
window.location.href = thankYouUrl;
} else {
// Already logged in or no login happened - SPA navigate is fine
navigate(thankYouUrl, { replace: true });
}
return; // Stop execution here
} else {
throw new Error(data.error || 'Failed to create order');
@@ -615,6 +628,17 @@ export default function Checkout() {
return (
<Container>
<SEOHead title="Checkout" description="Complete your purchase" />
{/* Invisible CAPTCHA widget for bot protection */}
{security?.captcha_provider && security.captcha_provider !== 'none' && (
<CaptchaWidget
provider={security.captcha_provider}
siteKey={security.captcha_provider === 'recaptcha' ? security.recaptcha_site_key : security.turnstile_site_key}
onToken={setCaptchaToken}
action="checkout"
/>
)}
<div className="py-8">
{/* Header */}
<div className="mb-8">

View File

@@ -59,6 +59,8 @@ interface PageData {
og_description?: string;
og_image?: string;
};
container_width?: string;
effective_container_width?: 'boxed' | 'fullwidth';
structure?: {
sections: Section[];
};
@@ -205,8 +207,8 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
)}
</Helmet>
{/* Render sections */}
<div className="wn-page">
{/* Render sections using effective container width */}
<div className={`wn-page ${pageData.effective_container_width === 'boxed' ? 'container mx-auto px-4 max-w-6xl' : ''}`}>
{sections.map((section) => {
const SectionComponent = SECTION_COMPONENTS[section.type];

View File

@@ -1,4 +1,5 @@
<?php
/**
* WooNooW Documentation Registry
*
@@ -13,67 +14,21 @@ namespace WooNooW\Docs;
*
* @return array Documentation registry
*/
function get_docs_registry() {
function get_docs_registry()
{
$docs_dir = dirname(__FILE__);
// Core WooNooW documentation
$docs = [
'core' => [
'label' => 'WooNooW',
'label' => 'Help & Support',
'icon' => 'book-open',
'items' => [
[
'slug' => 'getting-started',
'title' => 'Getting Started',
'title' => 'Official Documentation',
'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',
],
],
],
];
@@ -111,7 +66,8 @@ function get_docs_registry() {
* @param string $slug Document slug
* @return array|null Document data with content, or null if not found
*/
function get_doc_by_slug($slug) {
function get_doc_by_slug($slug)
{
$registry = get_docs_registry();
foreach ($registry as $section) {

View File

@@ -1,133 +0,0 @@
# 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

@@ -1,139 +0,0 @@
# 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

View File

@@ -1,149 +0,0 @@
# 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

View File

@@ -1,145 +0,0 @@
# 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

View File

@@ -1,96 +0,0 @@
# 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.

View File

@@ -1,54 +1,28 @@
# Getting Started with WooNooW
# Help Center
Welcome to WooNooW! This guide will help you get up and running quickly.
Welcome to the WooNooW documentation hub. Here you can find comprehensive guides, resources, and support to elevate your WooCommerce store.
## What is WooNooW?
## 📚 Official Documentation
WooNooW transforms your WooCommerce store into a modern, fast Single Page Application (SPA). It provides:
We have moved our documentation to a dedicated site for a better reading experience.
-**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
[**Visit docs.woonoow.com ↗**](https://docs.woonoow.com)
## Quick Setup (3 Steps)
---
### Step 1: Activate the Plugin
## 🚀 Quick Links
After installing WooNooW, activate it from **Plugins → Installed Plugins**.
| Section | Description |
| :--- | :--- |
| **[Getting Started](https://docs.woonoow.com/docs/getting-started)** | Installation, Requirements, and Quick Start guides. |
| **[Configuration](https://docs.woonoow.com/docs/configuration)** | Learn how to customize SPA appearance and plugin settings. |
| **[Notifications](https://docs.woonoow.com/docs/hooks/notifications)** | Setup email templates, SMS, and WhatsApp channels. |
| **[Developer API](https://docs.woonoow.com/docs/developer)** | Hooks, Filters, and Addon development guides. |
The plugin will automatically:
- Create a "Store" page for the SPA
- Configure basic settings
## 🛠 Support & Resources
### Step 2: Access Admin Dashboard
- **[Support Ticket](https://woonoow.com/support)**: Get help from our engineering team.
- **[System Status](https://status.woonoow.com)**: Check API and service availability.
- **[Changelog](https://docs.woonoow.com/docs/changelog)**: See what's new in the latest version.
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
> **Tip:** Ensure your license is active in **Settings > License** to receive automatic updates and premium support.

View File

@@ -1,92 +0,0 @@
# 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.

View File

@@ -1,173 +0,0 @@
# 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

@@ -1,20 +1,24 @@
<?php
namespace WooNooW\Admin;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
class AppearanceController {
class AppearanceController
{
const OPTION_KEY = 'woonoow_appearance_settings';
const API_NAMESPACE = 'woonoow/v1';
public static function init() {
public static function init()
{
add_action('rest_api_init', [__CLASS__, 'register_routes']);
}
public static function register_routes() {
public static function register_routes()
{
// Get all settings (public access for frontend)
register_rest_route(self::API_NAMESPACE, '/appearance/settings', [
'methods' => 'GET',
@@ -72,14 +76,16 @@ class AppearanceController {
]);
}
public static function check_permission() {
public static function check_permission()
{
return current_user_can('manage_woocommerce');
}
/**
* Get all appearance settings
*/
public static function get_settings(WP_REST_Request $request) {
public static function get_settings(WP_REST_Request $request)
{
$stored = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
@@ -95,7 +101,8 @@ class AppearanceController {
/**
* Save general settings
*/
public static function save_general(WP_REST_Request $request) {
public static function save_general(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, []);
$defaults = self::get_default_settings();
$settings = array_replace_recursive($defaults, $settings);
@@ -105,6 +112,8 @@ class AppearanceController {
$general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'spa_page' => absint($request->get_param('spaPage') ?? 0),
'container_width' => sanitize_text_field($request->get_param('containerWidth') ?? 'boxed'),
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
'typography' => [
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
@@ -126,7 +135,8 @@ class AppearanceController {
],
];
$settings['general'] = $general_data;
// Merge with existing general settings to preserve other keys (like spa_frontpage)
$settings['general'] = array_merge($settings['general'] ?? [], $general_data);
update_option(self::OPTION_KEY, $settings);
return new WP_REST_Response([
@@ -139,7 +149,8 @@ class AppearanceController {
/**
* Save header settings
*/
public static function save_header(WP_REST_Request $request) {
public static function save_header(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$header_data = [
@@ -173,7 +184,8 @@ class AppearanceController {
/**
* Save footer settings
*/
public static function save_footer(WP_REST_Request $request) {
public static function save_footer(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$social_links = $request->get_param('socialLinks') ?? [];
@@ -221,16 +233,32 @@ class AppearanceController {
}
$footer_data = [
'payment' => [
'enabled' => (bool) ($request->get_param('payment')['enabled'] ?? true),
'title' => sanitize_text_field($request->get_param('payment')['title'] ?? 'We accept'),
'methods' => array_map(function ($method) {
return [
'id' => sanitize_text_field($method['id'] ?? uniqid()),
'url' => esc_url_raw($method['url'] ?? ''),
'label' => sanitize_text_field($method['label'] ?? ''),
'width' => sanitize_text_field($method['width'] ?? ''),
];
}, $request->get_param('payment')['methods'] ?? []),
],
'copyright' => [
'enabled' => (bool) ($request->get_param('copyright')['enabled'] ?? true),
'text' => wp_kses_post($request->get_param('copyright')['text'] ?? ''),
],
'columns' => sanitize_text_field($request->get_param('columns')),
'style' => sanitize_text_field($request->get_param('style')),
'copyright_text' => wp_kses_post($request->get_param('copyrightText')),
// 'copyright_text' => Deprecated, moved to copyright.text
// 'elements' => Deprecated, moved to individual sections (except menu/contact/social flags if needed)
'elements' => [
'newsletter' => (bool) ($request->get_param('elements')['newsletter'] ?? true),
'social' => (bool) ($request->get_param('elements')['social'] ?? true),
'payment' => (bool) ($request->get_param('elements')['payment'] ?? true),
'copyright' => (bool) ($request->get_param('elements')['copyright'] ?? true),
'menu' => (bool) ($request->get_param('elements')['menu'] ?? true),
'contact' => (bool) ($request->get_param('elements')['contact'] ?? true),
// Payment and Copyright moved
],
'social_links' => $sanitized_links,
'contact_data' => $sanitized_contact,
@@ -251,7 +279,8 @@ class AppearanceController {
/**
* Save menu settings
*/
public static function save_menus(WP_REST_Request $request) {
public static function save_menus(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$menus = $request->get_param('menus') ?? [];
@@ -265,7 +294,7 @@ class AppearanceController {
foreach (['primary', 'mobile'] as $location) {
if (isset($menus[$location]) && is_array($menus[$location])) {
foreach ($menus[$location] as $item) {
$sanitized_menus[$location][] = [
$sanitized_menus[$location][] = [
'id' => sanitize_text_field($item['id'] ?? uniqid()),
'label' => sanitize_text_field($item['label'] ?? ''),
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
@@ -289,7 +318,8 @@ class AppearanceController {
/**
* Save page-specific settings
*/
public static function save_page_settings(WP_REST_Request $request) {
public static function save_page_settings(WP_REST_Request $request)
{
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
$page = $request->get_param('page');
@@ -312,7 +342,8 @@ class AppearanceController {
/**
* Sanitize page-specific data
*/
private static function sanitize_page_data($page, $data) {
private static function sanitize_page_data($page, $data)
{
$sanitized = [];
switch ($page) {
@@ -437,7 +468,8 @@ class AppearanceController {
/**
* Get list of WordPress pages for page selector
*/
public static function get_pages_list(WP_REST_Request $request) {
public static function get_pages_list(WP_REST_Request $request)
{
$pages = get_pages([
'post_status' => 'publish',
'sort_column' => 'post_title',
@@ -451,7 +483,7 @@ class AppearanceController {
(int) get_option('woocommerce_myaccount_page_id'),
];
$pages_list = array_map(function($page) use ($store_pages) {
$pages_list = array_map(function ($page) use ($store_pages) {
$is_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
$is_store = in_array((int)$page->ID, $store_pages, true);
@@ -473,11 +505,14 @@ class AppearanceController {
/**
* Get default settings structure
*/
public static function get_default_settings() {
public static function get_default_settings()
{
return [
'general' => [
'spa_mode' => 'full',
'spa_page' => 0,
'container_width' => 'boxed',
'toast_position' => 'top-right',
'typography' => [
'mode' => 'predefined',
@@ -516,12 +551,22 @@ class AppearanceController {
'footer' => [
'columns' => '4',
'style' => 'detailed',
'copyright_text' => '© 2024 WooNooW. All rights reserved.',
'payment' => [
'enabled' => true,
'title' => 'We accept',
'methods' => [
['id' => 'visa', 'url' => '', 'label' => 'Visa', 'default_icon' => 'visa'], // Placeholder logic for defaults
['id' => 'mastercard', 'url' => '', 'label' => 'Mastercard', 'default_icon' => 'mastercard'],
['id' => 'paypal', 'url' => '', 'label' => 'PayPal', 'default_icon' => 'paypal'],
],
],
'copyright' => [
'enabled' => true,
'text' => '© 2024 WooNooW. All rights reserved.',
],
'elements' => [
'newsletter' => true,
'social' => true,
'payment' => true,
'copyright' => true,
'menu' => true,
'contact' => true,
],

View File

@@ -1,4 +1,5 @@
<?php
/**
* Campaigns REST Controller
*
@@ -13,15 +14,18 @@ use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Campaigns\CampaignManager;
use WooNooW\Core\ActivityLog\Logger;
class CampaignsController {
class CampaignsController
{
const API_NAMESPACE = 'woonoow/v1';
/**
* Register REST routes
*/
public static function register_routes() {
public static function register_routes()
{
// List campaigns
register_rest_route(self::API_NAMESPACE, '/campaigns', [
'methods' => 'GET',
@@ -82,14 +86,16 @@ class CampaignsController {
/**
* Check admin permission
*/
public static function check_admin_permission() {
public static function check_admin_permission()
{
return current_user_can('manage_options');
}
/**
* Get all campaigns
*/
public static function get_campaigns(WP_REST_Request $request) {
public static function get_campaigns(WP_REST_Request $request)
{
$campaigns = CampaignManager::get_all();
return new WP_REST_Response([
@@ -101,7 +107,8 @@ class CampaignsController {
/**
* Create campaign
*/
public static function create_campaign(WP_REST_Request $request) {
public static function create_campaign(WP_REST_Request $request)
{
$data = [
'title' => $request->get_param('title'),
'subject' => $request->get_param('subject'),
@@ -130,7 +137,8 @@ class CampaignsController {
/**
* Get single campaign
*/
public static function get_campaign(WP_REST_Request $request) {
public static function get_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
@@ -150,7 +158,8 @@ class CampaignsController {
/**
* Update campaign
*/
public static function update_campaign(WP_REST_Request $request) {
public static function update_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$data = [];
@@ -191,7 +200,8 @@ class CampaignsController {
/**
* Delete campaign
*/
public static function delete_campaign(WP_REST_Request $request) {
public static function delete_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::delete($campaign_id);
@@ -212,7 +222,8 @@ class CampaignsController {
/**
* Send campaign
*/
public static function send_campaign(WP_REST_Request $request) {
public static function send_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::send($campaign_id);
@@ -240,7 +251,8 @@ class CampaignsController {
/**
* Send test email
*/
public static function send_test_email(WP_REST_Request $request) {
public static function send_test_email(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$email = sanitize_email($request->get_param('email'));
@@ -260,6 +272,14 @@ class CampaignsController {
], 400);
}
// Log to activity log
Logger::log(
'test_sent',
'campaign',
$campaign_id,
sprintf(__('Test email sent to %s', 'woonoow'), $email)
);
return new WP_REST_Response([
'success' => true,
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
@@ -269,7 +289,8 @@ class CampaignsController {
/**
* Preview campaign
*/
public static function preview_campaign(WP_REST_Request $request) {
public static function preview_campaign(WP_REST_Request $request)
{
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
@@ -289,10 +310,17 @@ class CampaignsController {
$subject = $campaign['subject'] ?: $campaign['title'];
if ($template) {
// Use template subject if available
if (!empty($template['subject'])) {
$subject = $template['subject'];
}
$content = str_replace('{content}', $campaign['content'], $template['body']);
$content = str_replace('{campaign_title}', $campaign['title'], $content);
}
// Replace campaign_title in subject
$subject = str_replace('{campaign_title}', $campaign['title'], $subject);
// Replace placeholders
$site_name = get_bloginfo('name');
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
@@ -302,6 +330,9 @@ class CampaignsController {
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
$content = str_replace('{current_year}', date('Y'), $content);
// Parse card shortcodes before rendering
$content = $renderer->parse_cards($content);
// Render with design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {

View File

@@ -311,12 +311,27 @@ class CheckoutController
return ['error' => __('No items provided', 'woonoow')];
}
// Security: Rate limiting check
if (\WooNooW\Compat\SecuritySettingsProvider::is_rate_limited()) {
return ['error' => __('Too many orders. Please try again later.', 'woonoow')];
}
// Security: CAPTCHA validation
$captcha_token = $payload['captcha_token'] ?? '';
$captcha_result = \WooNooW\Compat\SecuritySettingsProvider::validate_captcha($captcha_token);
if (is_wp_error($captcha_result)) {
return ['error' => $captcha_result->get_error_message()];
}
// Create order
$order = wc_create_order();
if (is_wp_error($order)) {
return ['error' => $order->get_error_message()];
}
// Track if user was logged in during this request (for frontend page reload)
$user_logged_in = false;
// Set customer ID if user is logged in
if (is_user_logged_in()) {
$user_id = get_current_user_id();
@@ -358,8 +373,9 @@ class CheckoutController
$existing_user = get_user_by('email', $email);
if ($existing_user) {
// User exists - link order to them
// User exists - link order to them (but do NOT auto-login for security)
$order->set_customer_id($existing_user->ID);
// Note: user_logged_in stays false - existing users must authenticate separately
} else {
// Create new user account
$password = wp_generate_password(12, true, true);
@@ -387,6 +403,7 @@ class CheckoutController
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
wp_set_auth_cookie($new_user_id, true);
wp_set_current_user($new_user_id);
$user_logged_in = true;
// Set WooCommerce customer billing data
$customer = new \WC_Customer($new_user_id);
@@ -509,6 +526,9 @@ class CheckoutController
WC()->cart->empty_cart();
}
// Record this order attempt for rate limiting
\WooNooW\Compat\SecuritySettingsProvider::record_order_attempt();
return [
'ok' => true,
'order_id' => $order->get_id(),
@@ -516,6 +536,7 @@ class CheckoutController
'status' => $order->get_status(),
'pay_url' => $order->get_checkout_payment_url(),
'thankyou_url' => $order->get_checkout_order_received_url(),
'user_logged_in' => $user_logged_in, // True if user was logged in during this request (requires page reload)
];
}

View File

@@ -1,15 +1,19 @@
<?php
namespace WooNooW\API;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Validation;
use WooNooW\Database\SubscriberTable;
class NewsletterController {
class NewsletterController
{
const API_NAMESPACE = 'woonoow/v1';
public static function register_routes() {
public static function register_routes()
{
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribe', [
'methods' => 'POST',
'callback' => [__CLASS__, 'subscribe'],
@@ -18,7 +22,7 @@ class NewsletterController {
'email' => [
'required' => true,
'type' => 'string',
'validate_callback' => function($param) {
'validate_callback' => function ($param) {
return is_email($param);
},
],
@@ -28,7 +32,7 @@ class NewsletterController {
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_subscribers'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
@@ -36,7 +40,7 @@ class NewsletterController {
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers/(?P<email>[^/]+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_subscriber'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
@@ -44,7 +48,7 @@ class NewsletterController {
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_template'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
@@ -52,7 +56,7 @@ class NewsletterController {
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
'methods' => 'POST',
'callback' => [__CLASS__, 'save_template'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_options');
},
]);
@@ -73,9 +77,27 @@ class NewsletterController {
],
],
]);
// Public confirm endpoint (double opt-in)
register_rest_route(self::API_NAMESPACE, '/newsletter/confirm', [
'methods' => 'GET',
'callback' => [__CLASS__, 'confirm'],
'permission_callback' => '__return_true',
'args' => [
'email' => [
'required' => true,
'type' => 'string',
],
'token' => [
'required' => true,
'type' => 'string',
],
],
]);
}
public static function get_template(WP_REST_Request $request) {
public static function get_template(WP_REST_Request $request)
{
$template = $request->get_param('template');
$option_key = "woonoow_newsletter_{$template}_template";
@@ -93,7 +115,8 @@ class NewsletterController {
], 200);
}
public static function save_template(WP_REST_Request $request) {
public static function save_template(WP_REST_Request $request)
{
$template = $request->get_param('template');
$subject = sanitize_text_field($request->get_param('subject'));
$content = wp_kses_post($request->get_param('content'));
@@ -111,11 +134,25 @@ class NewsletterController {
], 200);
}
public static function delete_subscriber(WP_REST_Request $request) {
$email = urldecode($request->get_param('email'));
public static function delete_subscriber(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
if (self::use_custom_table()) {
$result = SubscriberTable::delete_by_email($email);
if ($result) {
return new WP_REST_Response([
'success' => true,
'message' => 'Subscriber removed successfully',
], 200);
}
return new WP_Error('not_found', 'Subscriber not found', ['status' => 404]);
}
// Legacy: wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
$subscribers = array_filter($subscribers, function($sub) use ($email) {
$subscribers = array_filter($subscribers, function ($sub) use ($email) {
return isset($sub['email']) && $sub['email'] !== $email;
});
@@ -127,8 +164,28 @@ class NewsletterController {
], 200);
}
public static function subscribe(WP_REST_Request $request) {
/**
* Check if custom subscriber table should be used
*/
private static function use_custom_table()
{
return SubscriberTable::table_exists();
}
public static function subscribe(WP_REST_Request $request)
{
$email = sanitize_email($request->get_param('email'));
$consent = (bool) $request->get_param('consent');
// Rate limiting (5 requests per IP per hour)
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$rate_key = 'woonoow_newsletter_rate_' . md5($ip);
$attempts = (int) get_transient($rate_key);
if ($attempts >= 5) {
return new WP_Error('rate_limited', __('Too many requests. Please try again later.', 'woonoow'), ['status' => 429]);
}
set_transient($rate_key, $attempts + 1, HOUR_IN_SECONDS);
// Use centralized validation with extensible filter hooks
$validation = Validation::validate_email($email, 'newsletter_subscribe');
@@ -137,40 +194,111 @@ class NewsletterController {
return $validation;
}
// Get existing subscribers (now stored as objects with metadata)
$subscribers = get_option('woonoow_newsletter_subscribers', []);
// Check if already subscribed
$existing = array_filter($subscribers, function($sub) use ($email) {
return isset($sub['email']) && $sub['email'] === $email;
});
if (!empty($existing)) {
return new WP_REST_Response([
'success' => true,
'message' => 'You are already subscribed to our newsletter!',
], 200);
// Check GDPR consent requirement
$gdpr_required = get_option('woonoow_newsletter_gdpr_consent', false);
if ($gdpr_required && !$consent) {
return new WP_Error('consent_required', __('Please accept the terms to subscribe.', 'woonoow'), ['status' => 400]);
}
// Check if email belongs to a WP user
$user = get_user_by('email', $email);
$user_id = $user ? $user->ID : null;
// Add new subscriber with metadata
$subscribers[] = [
'email' => $email,
'user_id' => $user_id,
'status' => 'active',
'subscribed_at' => current_time('mysql'),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
];
// Check double opt-in setting
$double_opt_in = get_option('woonoow_newsletter_double_opt_in', true);
$status = $double_opt_in ? 'pending' : 'active';
update_option('woonoow_newsletter_subscribers', $subscribers);
if (self::use_custom_table()) {
// Use custom table
$existing = SubscriberTable::get_by_email($email);
// Trigger notification events
if ($existing) {
if ($existing['status'] === 'active') {
return new WP_REST_Response([
'success' => true,
'message' => __('You are already subscribed to our newsletter!', 'woonoow'),
], 200);
}
if ($existing['status'] === 'pending') {
self::send_confirmation_email($email, $existing['user_id'] ?? null);
return new WP_REST_Response([
'success' => true,
'message' => __('Confirmation email resent. Please check your inbox.', 'woonoow'),
], 200);
}
// Resubscribe (was unsubscribed)
SubscriberTable::update_by_email($email, [
'status' => $status,
'consent' => $consent ? 1 : 0,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
]);
} else {
// New subscriber
SubscriberTable::add([
'email' => $email,
'user_id' => $user_id,
'status' => $status,
'consent' => $consent,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
]);
}
} else {
// Legacy: wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
// Check if already subscribed
$existing_key = null;
foreach ($subscribers as $key => $sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$existing_key = $key;
break;
}
}
if ($existing_key !== null) {
$existing = $subscribers[$existing_key];
if (($existing['status'] ?? 'active') === 'active') {
return new WP_REST_Response([
'success' => true,
'message' => __('You are already subscribed to our newsletter!', 'woonoow'),
], 200);
}
if (($existing['status'] ?? '') === 'pending') {
self::send_confirmation_email($email, $existing['user_id'] ?? null);
return new WP_REST_Response([
'success' => true,
'message' => __('Confirmation email resent. Please check your inbox.', 'woonoow'),
], 200);
}
}
$subscribers[] = [
'email' => $email,
'user_id' => $user_id,
'status' => $status,
'consent' => $consent,
'subscribed_at' => current_time('mysql'),
'ip_address' => $ip,
];
update_option('woonoow_newsletter_subscribers', $subscribers);
}
if ($double_opt_in) {
// Send confirmation email
self::send_confirmation_email($email, $user_id);
return new WP_REST_Response([
'success' => true,
'message' => __('Please check your email to confirm your subscription.', 'woonoow'),
], 200);
}
// Direct subscription (no double opt-in)
do_action('woonoow_newsletter_subscribed', $email, $user_id);
// Trigger notification system events (uses email builder)
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
'email' => $email,
'user_id' => $user_id,
@@ -185,25 +313,143 @@ class NewsletterController {
return new WP_REST_Response([
'success' => true,
'message' => 'Successfully subscribed! Check your email for confirmation.',
'message' => __('Successfully subscribed to our newsletter!', 'woonoow'),
], 200);
}
private static function send_welcome_email($email) {
$site_name = get_bloginfo('name');
$template = get_option('woonoow_newsletter_welcome_template', '');
/**
* Send confirmation email for double opt-in
*/
private static function send_confirmation_email($email, $user_id = null)
{
$confirmation_url = self::generate_confirmation_url($email);
if (empty($template)) {
$template = "Thank you for subscribing to our newsletter!\n\nYou'll receive updates about our latest products and offers.\n\nBest regards,\n{site_name}";
}
$subject = sprintf('Welcome to %s Newsletter!', $site_name);
$message = str_replace('{site_name}', $site_name, $template);
wp_mail($email, $subject, $message);
do_action('woonoow/notification/event', 'newsletter_confirm', 'customer', [
'email' => $email,
'user_id' => $user_id,
'confirmation_url' => $confirmation_url,
]);
}
public static function get_subscribers(WP_REST_Request $request) {
/**
* Generate confirmation URL with secure token
*/
public static function generate_confirmation_url($email)
{
$token = self::generate_unsubscribe_token($email); // Reuse same token logic
$base_url = rest_url('woonoow/v1/newsletter/confirm');
return add_query_arg([
'email' => urlencode($email),
'token' => $token,
], $base_url);
}
/**
* Handle confirmation request (double opt-in)
*/
public static function confirm(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
$token = sanitize_text_field($request->get_param('token'));
// Verify token
$expected_token = self::generate_unsubscribe_token($email);
if (!hash_equals($expected_token, $token)) {
return new WP_REST_Response([
'success' => false,
'message' => __('Invalid confirmation link', 'woonoow'),
], 400);
}
$found = false;
$user_id = null;
if (self::use_custom_table()) {
$existing = SubscriberTable::get_by_email($email);
if ($existing) {
if ($existing['status'] === 'active') {
$found = true;
} else {
SubscriberTable::update_by_email($email, [
'status' => 'active',
'confirmed_at' => current_time('mysql'),
]);
$user_id = $existing['user_id'] ?? null;
$found = true;
}
}
} else {
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
if (($sub['status'] ?? '') === 'active') {
$found = true;
break;
}
$sub['status'] = 'active';
$sub['confirmed_at'] = current_time('mysql');
$user_id = $sub['user_id'] ?? null;
$found = true;
break;
}
}
if ($found) {
update_option('woonoow_newsletter_subscribers', $subscribers);
}
}
// Trigger subscription events
do_action('woonoow_newsletter_subscribed', $email, $user_id);
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
do_action('woonoow/notification/event', 'newsletter_subscribed_admin', 'staff', [
'email' => $email,
'user_id' => $user_id,
'subscribed_at' => current_time('mysql'),
]);
// Return HTML page for nice UX
$site_name = get_bloginfo('name');
$shop_url = wc_get_page_permalink('shop') ?: home_url();
$html = sprintf(
'<!DOCTYPE html><html><head><title>%s</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}.box{background:white;padding:40px;border-radius:8px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;}h1{color:#333;margin-bottom:16px;}p{color:#666;}a{display:inline-block;margin-top:20px;padding:12px 24px;background:#333;color:white;text-decoration:none;border-radius:6px;}</style></head><body><div class="box"><h1>✓ Confirmed!</h1><p>You are now subscribed to %s newsletter.</p><a href="%s">Continue Shopping</a></div></body></html>',
__('Subscription Confirmed', 'woonoow'),
esc_html($site_name),
esc_url($shop_url)
);
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
// Dead code removed: send_welcome_email() - now handled via notification system
public static function get_subscribers(WP_REST_Request $request)
{
if (self::use_custom_table()) {
$result = SubscriberTable::get_all([
'per_page' => 100,
'page' => 1,
]);
return new WP_REST_Response([
'success' => true,
'data' => [
'subscribers' => $result['items'],
'count' => $result['total'],
],
], 200);
}
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return new WP_REST_Response([
@@ -218,7 +464,8 @@ class NewsletterController {
/**
* Handle unsubscribe request
*/
public static function unsubscribe(WP_REST_Request $request) {
public static function unsubscribe(WP_REST_Request $request)
{
$email = sanitize_email(urldecode($request->get_param('email')));
$token = sanitize_text_field($request->get_param('token'));
@@ -231,16 +478,32 @@ class NewsletterController {
], 400);
}
// Get subscribers
$subscribers = get_option('woonoow_newsletter_subscribers', []);
$found = false;
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$sub['status'] = 'unsubscribed';
$sub['unsubscribed_at'] = current_time('mysql');
if (self::use_custom_table()) {
$existing = SubscriberTable::get_by_email($email);
if ($existing) {
SubscriberTable::update_by_email($email, [
'status' => 'unsubscribed',
'unsubscribed_at' => current_time('mysql'),
]);
$found = true;
break;
}
} else {
// Legacy: wp_options
$subscribers = get_option('woonoow_newsletter_subscribers', []);
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$sub['status'] = 'unsubscribed';
$sub['unsubscribed_at'] = current_time('mysql');
$found = true;
break;
}
}
if ($found) {
update_option('woonoow_newsletter_subscribers', $subscribers);
}
}
@@ -251,8 +514,6 @@ class NewsletterController {
], 404);
}
update_option('woonoow_newsletter_subscribers', $subscribers);
do_action('woonoow_newsletter_unsubscribed', $email);
// Return HTML page for nice UX
@@ -271,7 +532,8 @@ class NewsletterController {
/**
* Generate secure unsubscribe token
*/
private static function generate_unsubscribe_token($email) {
private static function generate_unsubscribe_token($email)
{
$secret = wp_salt('auth');
return hash_hmac('sha256', $email, $secret);
}
@@ -279,7 +541,8 @@ class NewsletterController {
/**
* Generate unsubscribe URL for email templates
*/
public static function generate_unsubscribe_url($email) {
public static function generate_unsubscribe_url($email)
{
$token = self::generate_unsubscribe_token($email);
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
return add_query_arg([
@@ -288,4 +551,3 @@ class NewsletterController {
], $base_url);
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Notifications REST API Controller
*
@@ -17,7 +18,8 @@ use WooNooW\Core\Notifications\TemplateProvider;
use WooNooW\Core\Notifications\EventRegistry;
use WooNooW\Core\Notifications\PushNotificationHandler;
class NotificationsController {
class NotificationsController
{
/**
* REST API namespace
@@ -32,7 +34,8 @@ class NotificationsController {
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
// GET /woonoow/v1/notifications/channels
register_rest_route($this->namespace, '/' . $this->rest_base . '/channels', [
[
@@ -254,7 +257,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_channels(WP_REST_Request $request) {
public function get_channels(WP_REST_Request $request)
{
// Get channel enabled states
$email_enabled = get_option('woonoow_email_notifications_enabled', true);
$push_enabled = get_option('woonoow_push_notifications_enabled', true);
@@ -288,7 +292,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_events(WP_REST_Request $request) {
public function get_events(WP_REST_Request $request)
{
$settings = get_option('woonoow_notification_settings', []);
// Get all events from EventRegistry (single source of truth)
@@ -322,13 +327,14 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_staff_events(WP_REST_Request $request) {
public function get_staff_events(WP_REST_Request $request)
{
$all_events = $this->get_all_events();
// Filter events where recipient_type is 'staff'
$staff_events = [];
foreach ($all_events as $category => $events) {
$filtered = array_filter($events, function($event) {
$filtered = array_filter($events, function ($event) {
return ($event['recipient_type'] ?? 'staff') === 'staff';
});
@@ -346,13 +352,14 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_customer_events(WP_REST_Request $request) {
public function get_customer_events(WP_REST_Request $request)
{
$all_events = $this->get_all_events();
// Filter events where recipient_type is 'customer'
$customer_events = [];
foreach ($all_events as $category => $events) {
$filtered = array_filter($events, function($event) {
$filtered = array_filter($events, function ($event) {
return ($event['recipient_type'] ?? 'staff') === 'customer';
});
@@ -369,7 +376,8 @@ class NotificationsController {
*
* @return array
*/
private function get_all_events() {
private function get_all_events()
{
// Use EventRegistry - same as get_events() but returns ungrouped
$settings = get_option('woonoow_notification_settings', []);
@@ -403,7 +411,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function update_event(WP_REST_Request $request) {
public function update_event(WP_REST_Request $request)
{
$params = $request->get_json_params();
$event_id = isset($params['eventId']) ? $params['eventId'] : null;
$channel_id = isset($params['channelId']) ? $params['channelId'] : null;
@@ -452,7 +461,8 @@ class NotificationsController {
*
* @return bool
*/
public function check_permission() {
public function check_permission()
{
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
}
@@ -462,7 +472,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_templates(WP_REST_Request $request) {
public function get_templates(WP_REST_Request $request)
{
$templates = TemplateProvider::get_templates();
return new WP_REST_Response($templates, 200);
}
@@ -473,7 +484,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function get_template(WP_REST_Request $request) {
public function get_template(WP_REST_Request $request)
{
$event_id = $request->get_param('eventId');
$channel_id = $request->get_param('channelId');
$recipient_type = $request->get_param('recipient') ?? 'customer';
@@ -527,7 +539,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function save_template(WP_REST_Request $request) {
public function save_template(WP_REST_Request $request)
{
$event_id = $request->get_param('eventId');
$channel_id = $request->get_param('channelId');
$recipient_type = $request->get_param('recipient') ?? 'customer';
@@ -563,7 +576,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function delete_template(WP_REST_Request $request) {
public function delete_template(WP_REST_Request $request)
{
$event_id = $request->get_param('eventId');
$channel_id = $request->get_param('channelId');
$recipient_type = $request->get_param('recipient') ?? 'customer';
@@ -582,7 +596,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_vapid_key(WP_REST_Request $request) {
public function get_vapid_key(WP_REST_Request $request)
{
$public_key = PushNotificationHandler::get_public_key();
return new WP_REST_Response([
@@ -596,7 +611,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function push_subscribe(WP_REST_Request $request) {
public function push_subscribe(WP_REST_Request $request)
{
$subscription = $request->get_param('subscription');
$user_id = get_current_user_id();
@@ -630,7 +646,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function push_unsubscribe(WP_REST_Request $request) {
public function push_unsubscribe(WP_REST_Request $request)
{
$subscription_id = $request->get_param('subscriptionId');
PushNotificationHandler::unsubscribe($subscription_id);
@@ -647,7 +664,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_push_settings(WP_REST_Request $request) {
public function get_push_settings(WP_REST_Request $request)
{
$settings = PushNotificationHandler::get_settings();
return new WP_REST_Response($settings, 200);
@@ -659,7 +677,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function update_push_settings(WP_REST_Request $request) {
public function update_push_settings(WP_REST_Request $request)
{
$settings = $request->get_json_params();
if (empty($settings)) {
@@ -693,7 +712,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function toggle_channel(WP_REST_Request $request) {
public function toggle_channel(WP_REST_Request $request)
{
$params = $request->get_json_params();
$channel_id = isset($params['channelId']) ? $params['channelId'] : null;
$enabled = isset($params['enabled']) ? $params['enabled'] : null;
@@ -749,7 +769,8 @@ class NotificationsController {
/**
* Get email customization settings
*/
public function get_email_settings(WP_REST_Request $request) {
public function get_email_settings(WP_REST_Request $request)
{
$defaults = [
'primary_color' => '#7f54b3',
'secondary_color' => '#7f54b3',
@@ -781,7 +802,8 @@ class NotificationsController {
/**
* Save email customization settings
*/
public function save_email_settings(WP_REST_Request $request) {
public function save_email_settings(WP_REST_Request $request)
{
$data = $request->get_json_params();
$settings = [
@@ -811,7 +833,8 @@ class NotificationsController {
/**
* Reset email customization settings to defaults
*/
public function reset_email_settings(WP_REST_Request $request) {
public function reset_email_settings(WP_REST_Request $request)
{
delete_option('woonoow_email_settings');
return new WP_REST_Response([
@@ -823,7 +846,8 @@ class NotificationsController {
/**
* Sanitize social links array
*/
private function sanitize_social_links($links) {
private function sanitize_social_links($links)
{
if (!is_array($links)) {
return [];
}
@@ -856,7 +880,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_system_mode(WP_REST_Request $request) {
public function get_system_mode(WP_REST_Request $request)
{
$mode = get_option('woonoow_notification_system_mode', 'woonoow');
return new WP_REST_Response([
@@ -874,7 +899,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function set_system_mode(WP_REST_Request $request) {
public function set_system_mode(WP_REST_Request $request)
{
$params = $request->get_json_params();
$mode = isset($params['mode']) ? $params['mode'] : null;
@@ -908,7 +934,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_logs(WP_REST_Request $request) {
public function get_logs(WP_REST_Request $request)
{
$page = (int) $request->get_param('page') ?: 1;
$per_page = (int) $request->get_param('per_page') ?: 20;
$channel = $request->get_param('channel');
@@ -929,14 +956,14 @@ class NotificationsController {
if ($search) {
$search_lower = strtolower($search);
$all_logs = array_filter($all_logs, function($log) use ($search_lower) {
$all_logs = array_filter($all_logs, function ($log) use ($search_lower) {
return strpos(strtolower($log['recipient'] ?? ''), $search_lower) !== false ||
strpos(strtolower($log['subject'] ?? ''), $search_lower) !== false;
strpos(strtolower($log['subject'] ?? ''), $search_lower) !== false;
});
}
// Sort by date descending
usort($all_logs, function($a, $b) {
usort($all_logs, function ($a, $b) {
return strtotime($b['created_at'] ?? '') - strtotime($a['created_at'] ?? '');
});
@@ -958,7 +985,8 @@ class NotificationsController {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error
*/
public function send_test_email(WP_REST_Request $request) {
public function send_test_email(WP_REST_Request $request)
{
$event_id = $request->get_param('eventId');
$channel_id = $request->get_param('channelId');
$recipient_type = $request->get_param('recipient') ?? 'customer';
@@ -1032,11 +1060,12 @@ class NotificationsController {
* @param string $event_id
* @return array
*/
private function get_sample_data_for_event($event_id) {
private function get_sample_data_for_event($event_id)
{
$base_data = [
'site_name' => get_bloginfo('name'),
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'site_url' => home_url(),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'support_email' => get_option('admin_email'),
@@ -1101,7 +1130,8 @@ class NotificationsController {
*
* @return string
*/
private function get_sample_order_items_html() {
private function get_sample_order_items_html()
{
return '<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<thead>
<tr style="background: #f5f5f5;">
@@ -1138,7 +1168,8 @@ class NotificationsController {
* @param array $variables
* @return string
*/
private function replace_variables($text, $variables) {
private function replace_variables($text, $variables)
{
foreach ($variables as $key => $value) {
$text = str_replace('{' . $key . '}', $value, $text);
}
@@ -1153,7 +1184,8 @@ class NotificationsController {
* @param array $variables
* @return string
*/
private function render_test_email($body_markdown, $subject, $variables) {
private function render_test_email($body_markdown, $subject, $variables)
{
// Parse cards
$content = $this->parse_cards_for_test($body_markdown);
@@ -1176,14 +1208,14 @@ class NotificationsController {
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_url($logo_url),
esc_attr($variables['store_name'])
);
} else {
$header = sprintf(
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_html($header_text)
);
}
@@ -1232,11 +1264,12 @@ class NotificationsController {
* @param string $content
* @return string
*/
private function parse_cards_for_test($content) {
private function parse_cards_for_test($content)
{
// Parse [card:type] syntax
$content = preg_replace_callback(
'/\[card:(\w+)\](.*?)\[\/card\]/s',
function($matches) {
function ($matches) {
$type = $matches[1];
$card_content = $this->markdown_to_html($matches[2]);
return '<div class="card card-' . esc_attr($type) . '">' . $card_content . '</div>';
@@ -1247,7 +1280,7 @@ class NotificationsController {
// Parse [card type="..."] syntax
$content = preg_replace_callback(
'/\[card([^\]]*)\](.*?)\[\/card\]/s',
function($matches) {
function ($matches) {
$attrs = $matches[1];
$card_content = $this->markdown_to_html($matches[2]);
$type = 'default';
@@ -1262,12 +1295,14 @@ class NotificationsController {
// Parse buttons - new [button:style](url)Text[/button] syntax
$content = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) {
function ($matches) {
$style = $matches[1];
$url = $matches[2];
$text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
// Don't escape URLs with variable placeholders
$escaped_url = preg_match('/\{[a-z_]+\}/', $url) ? $url : esc_url($url);
return '<p style="text-align: center;"><a href="' . $escaped_url . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
},
$content
);
@@ -1275,12 +1310,14 @@ class NotificationsController {
// Parse buttons - old [button url="..." style="..."]Text[/button] syntax
$content = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](\w+)["\'])?\]([^\[]+)\[\/button\]/',
function($matches) {
function ($matches) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
// Don't escape URLs with variable placeholders
$escaped_url = preg_match('/\{[a-z_]+\}/', $url) ? $url : esc_url($url);
return '<p style="text-align: center;"><a href="' . $escaped_url . '" class="' . $class . '">' . esc_html($text) . '</a></p>';
},
$content
);
@@ -1299,16 +1336,19 @@ class NotificationsController {
* @param string $text
* @return string
*/
private function markdown_to_html($text) {
private function markdown_to_html($text)
{
// Parse buttons FIRST - new [button:style](url)Text[/button] syntax
$text = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) {
function ($matches) {
$style = $matches[1];
$url = $matches[2];
$btn_text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
// Don't escape URLs with variable placeholders
$escaped_url = preg_match('/\{[a-z_]+\}/', $url) ? $url : esc_url($url);
return '<p style="text-align: center;"><a href="' . $escaped_url . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
},
$text
);
@@ -1316,12 +1356,14 @@ class NotificationsController {
// Parse buttons - old [button url="..."] syntax
$text = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=[\'"](\\w+)[\'"])?\]([^\[]+)\[\/button\]/',
function($matches) {
function ($matches) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$btn_text = trim($matches[3]);
$class = $style === 'outline' ? 'button-outline' : 'button';
return '<p style="text-align: center;"><a href="' . esc_url($url) . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
// Don't escape URLs with variable placeholders
$escaped_url = preg_match('/\{[a-z_]+\}/', $url) ? $url : esc_url($url);
return '<p style="text-align: center;"><a href="' . $escaped_url . '" class="' . $class . '">' . esc_html($btn_text) . '</a></p>';
},
$text
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Api;
use WP_REST_Request;
@@ -114,8 +115,6 @@ class PagesController
'callback' => [__CLASS__, 'delete_page'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
}
/**
@@ -209,13 +208,23 @@ class PagesController
// Get SEO data (Yoast/Rank Math)
$seo = self::get_seo_data($page->ID);
// Get container width
$container_width = get_post_meta($page->ID, '_wn_page_container_width', true);
return new WP_REST_Response([
'id' => $page->ID,
'type' => 'page',
'slug' => $page->post_name,
'id' => $page->ID,
'type' => 'page',
'slug' => $page->post_name,
'title' => $page->post_title,
'seo' => $seo,
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
'container_width' => $container_width ?: 'default', // local setting
'effective_container_width' => ($container_width && $container_width !== 'default')
? $container_width
: (($settings['general']['container_width'] ?? 'boxed') ?: 'boxed'),
'structure' => $structure ?: ['sections' => []],
], 200);
}
@@ -249,6 +258,11 @@ class PagesController
update_post_meta($page->ID, '_wn_page_structure', $save_data);
// Save container width
if (isset($body['container_width'])) {
update_post_meta($page->ID, '_wn_page_container_width', sanitize_text_field($body['container_width']));
}
// Invalidate SSR cache
delete_transient("wn_ssr_page_{$page->ID}");
@@ -385,7 +399,8 @@ class PagesController
* Set page as SPA Landing (the page shown at SPA root route)
* This does NOT affect WordPress page_on_front setting.
*/
public static function set_as_spa_landing(WP_REST_Request $request) {
public static function set_as_spa_landing(WP_REST_Request $request)
{
$id = (int)$request->get_param('id');
// Verify the page exists
@@ -414,7 +429,8 @@ class PagesController
* Unset SPA Landing (the page shown at SPA root route)
* After unsetting, SPA will redirect to /shop or /checkout based on mode
*/
public static function unset_spa_landing(WP_REST_Request $request) {
public static function unset_spa_landing(WP_REST_Request $request)
{
// Update WooNooW SPA settings - clear the SPA frontpage
$settings = get_option('woonoow_appearance_settings', []);
if (isset($settings['general'])) {
@@ -491,7 +507,8 @@ class PagesController
/**
* Delete page
*/
public static function delete_page(WP_REST_Request $request) {
public static function delete_page(WP_REST_Request $request)
{
$id = (int)$request->get_param('id');
$page = get_post($id);
@@ -716,118 +733,247 @@ class PagesController
// Start output buffering
ob_start();
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html($title); ?> - Preview</title>
<style>
/* Reset and base styles */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
background: #fff;
}
img { max-width: 100%; height: auto; }
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
/* Section base */
.wn-section { padding: 4rem 1rem; }
.wn-container { max-width: 1200px; margin: 0 auto; }
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html($title); ?> - Preview</title>
<style>
/* Reset and base styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Color schemes */
.wn-scheme-default { background: #fff; color: #1f2937; }
.wn-scheme-primary { background: #3b82f6; color: #fff; }
.wn-scheme-secondary { background: #1f2937; color: #fff; }
.wn-scheme-muted { background: #f3f4f6; color: #1f2937; }
.wn-scheme-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
background: #fff;
}
/* Hero section */
.wn-hero { text-align: center; padding: 6rem 1rem; }
.wn-hero h1 { font-size: 2.5rem; font-weight: 800; margin-bottom: 1rem; }
.wn-hero p { font-size: 1.25rem; opacity: 0.9; margin-bottom: 2rem; }
.wn-hero .wn-btn {
display: inline-block; padding: 0.75rem 1.5rem;
background: currentColor; color: inherit;
border-radius: 0.5rem; text-decoration: none;
filter: invert(1); font-weight: 600;
}
img {
max-width: 100%;
height: auto;
}
/* Content section */
.wn-content { padding: 3rem 1rem; }
.wn-content.wn-narrow .wn-container { max-width: 720px; }
.wn-content.wn-medium .wn-container { max-width: 960px; }
/* Section base */
.wn-section {
padding: 4rem 1rem;
}
/* Image + Text */
.wn-image-text { display: flex; gap: 3rem; align-items: center; flex-wrap: wrap; }
.wn-image-text .wn-image { flex: 1; min-width: 300px; }
.wn-image-text .wn-text { flex: 1; min-width: 300px; }
.wn-image-text.wn-image-right { flex-direction: row-reverse; }
.wn-container {
max-width: 1200px;
margin: 0 auto;
}
/* Feature grid */
.wn-features { display: grid; gap: 2rem; }
.wn-features.wn-grid-2 { grid-template-columns: repeat(2, 1fr); }
.wn-features.wn-grid-3 { grid-template-columns: repeat(3, 1fr); }
.wn-features.wn-grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.wn-features { grid-template-columns: 1fr; }
}
.wn-feature { text-align: center; padding: 1.5rem; }
.wn-feature-icon { font-size: 2rem; margin-bottom: 1rem; }
/* Color schemes */
.wn-scheme-default {
background: #fff;
color: #1f2937;
}
/* CTA Banner */
.wn-cta { text-align: center; padding: 4rem 1rem; }
.wn-cta h2 { font-size: 2rem; margin-bottom: 1rem; }
.wn-scheme-primary {
background: #3b82f6;
color: #fff;
}
/* Contact form */
.wn-contact form { max-width: 500px; margin: 0 auto; }
.wn-contact input, .wn-contact textarea {
width: 100%; padding: 0.75rem; margin-bottom: 1rem;
border: 1px solid #d1d5db; border-radius: 0.375rem;
}
.wn-contact button {
width: 100%; padding: 0.75rem; background: #3b82f6;
color: #fff; border: none; border-radius: 0.375rem;
cursor: pointer; font-weight: 600;
}
.wn-scheme-secondary {
background: #1f2937;
color: #fff;
}
/* Preview indicator */
.wn-preview-indicator {
position: fixed; top: 0; left: 0; right: 0;
background: #f59e0b; color: #000; text-align: center;
padding: 0.5rem; font-size: 0.875rem; font-weight: 500;
z-index: 9999;
}
</style>
</head>
<body>
<div class="wn-preview-indicator">
🔍 Preview Mode <?php if ($sample_post): ?>(Using: <?php echo esc_html($sample_post->post_title); ?>)<?php endif; ?>
</div>
.wn-scheme-muted {
background: #f3f4f6;
color: #1f2937;
}
<main style="padding-top: 2.5rem;">
<?php
foreach ($sections as $section) {
echo PageSSR::render_section($section, $sample_post ? PlaceholderRenderer::build_post_data($sample_post) : []);
}
.wn-scheme-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
if (empty($sections)) {
echo '<div style="text-align:center; padding:4rem; color:#9ca3af;">';
echo '<p>No sections added yet.</p>';
echo '<p>Add sections in the editor to see preview.</p>';
echo '</div>';
}
?>
</main>
</body>
</html>
<?php
/* Hero section */
.wn-hero {
text-align: center;
padding: 6rem 1rem;
}
.wn-hero h1 {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 1rem;
}
.wn-hero p {
font-size: 1.25rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.wn-hero .wn-btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: currentColor;
color: inherit;
border-radius: 0.5rem;
text-decoration: none;
filter: invert(1);
font-weight: 600;
}
/* Content section */
.wn-content {
padding: 3rem 1rem;
}
.wn-content.wn-narrow .wn-container {
max-width: 720px;
}
.wn-content.wn-medium .wn-container {
max-width: 960px;
}
/* Image + Text */
.wn-image-text {
display: flex;
gap: 3rem;
align-items: center;
flex-wrap: wrap;
}
.wn-image-text .wn-image {
flex: 1;
min-width: 300px;
}
.wn-image-text .wn-text {
flex: 1;
min-width: 300px;
}
.wn-image-text.wn-image-right {
flex-direction: row-reverse;
}
/* Feature grid */
.wn-features {
display: grid;
gap: 2rem;
}
.wn-features.wn-grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.wn-features.wn-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.wn-features.wn-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 768px) {
.wn-features {
grid-template-columns: 1fr;
}
}
.wn-feature {
text-align: center;
padding: 1.5rem;
}
.wn-feature-icon {
font-size: 2rem;
margin-bottom: 1rem;
}
/* CTA Banner */
.wn-cta {
text-align: center;
padding: 4rem 1rem;
}
.wn-cta h2 {
font-size: 2rem;
margin-bottom: 1rem;
}
/* Contact form */
.wn-contact form {
max-width: 500px;
margin: 0 auto;
}
.wn-contact input,
.wn-contact textarea {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.wn-contact button {
width: 100%;
padding: 0.75rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 600;
}
/* Preview indicator */
.wn-preview-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #f59e0b;
color: #000;
text-align: center;
padding: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
z-index: 9999;
}
</style>
</head>
<body>
<div class="wn-preview-indicator">
🔍 Preview Mode <?php if ($sample_post): ?>(Using: <?php echo esc_html($sample_post->post_title); ?>)<?php endif; ?>
</div>
<main style="padding-top: 2.5rem;">
<?php
foreach ($sections as $section) {
echo PageSSR::render_section($section, $sample_post ? PlaceholderRenderer::build_post_data($sample_post) : []);
}
if (empty($sections)) {
echo '<div style="text-align:center; padding:4rem; color:#9ca3af;">';
echo '<p>No sections added yet.</p>';
echo '<p>Add sections in the editor to see preview.</p>';
echo '</div>';
}
?>
</main>
</body>
</html>
<?php
return ob_get_clean();
}
}

View File

@@ -371,13 +371,13 @@ class ProductsController
}
// Virtual and downloadable
if (isset($data['virtual'])) {
if (array_key_exists('virtual', $data)) {
$product->set_virtual((bool) $data['virtual']);
}
if (isset($data['downloadable'])) {
if (array_key_exists('downloadable', $data)) {
$product->set_downloadable((bool) $data['downloadable']);
}
if (isset($data['featured'])) {
if (array_key_exists('featured', $data)) {
$product->set_featured((bool) $data['featured']);
}
@@ -510,13 +510,13 @@ class ProductsController
if (isset($data['height'])) $product->set_height(self::sanitize_number($data['height']));
// Virtual and downloadable
if (isset($data['virtual'])) {
if (array_key_exists('virtual', $data)) {
$product->set_virtual((bool) $data['virtual']);
}
if (isset($data['downloadable'])) {
if (array_key_exists('downloadable', $data)) {
$product->set_downloadable((bool) $data['downloadable']);
}
if (isset($data['featured'])) {
if (array_key_exists('featured', $data)) {
$product->set_featured((bool) $data['featured']);
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Store REST API Controller
*
@@ -11,13 +12,15 @@ namespace WooNooW\API;
use WooNooW\Compat\StoreSettingsProvider;
use WooNooW\Compat\CustomerSettingsProvider;
use WooNooW\Compat\SecuritySettingsProvider;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
class StoreController extends WP_REST_Controller {
class StoreController extends WP_REST_Controller
{
/**
* Namespace
@@ -32,7 +35,8 @@ class StoreController extends WP_REST_Controller {
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
// GET /woonoow/v1/store/branding (PUBLIC - for login page)
register_rest_route($this->namespace, '/' . $this->rest_base . '/branding', [
[
@@ -104,6 +108,24 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/security-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/security-settings', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_security_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// POST /woonoow/v1/store/security-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/security-settings', [
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [$this, 'save_security_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
}
/**
@@ -112,7 +134,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public function get_branding(WP_REST_Request $request) {
public function get_branding(WP_REST_Request $request)
{
$branding = [
'store_name' => get_option('woonoow_store_name', '') ?: get_option('blogname', 'WooNooW'),
'store_logo' => get_option('woonoow_store_logo', ''),
@@ -133,7 +156,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_settings(WP_REST_Request $request) {
public function get_settings(WP_REST_Request $request)
{
try {
$settings = StoreSettingsProvider::get_settings();
@@ -156,7 +180,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_settings(WP_REST_Request $request) {
public function save_settings(WP_REST_Request $request)
{
$settings = $request->get_json_params();
if (empty($settings)) {
@@ -198,7 +223,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_countries(WP_REST_Request $request) {
public function get_countries(WP_REST_Request $request)
{
try {
$countries = StoreSettingsProvider::get_countries();
@@ -221,7 +247,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_timezones(WP_REST_Request $request) {
public function get_timezones(WP_REST_Request $request)
{
try {
$timezones = StoreSettingsProvider::get_timezones();
@@ -244,7 +271,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_currencies(WP_REST_Request $request) {
public function get_currencies(WP_REST_Request $request)
{
try {
$currencies = StoreSettingsProvider::get_currencies();
@@ -267,7 +295,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_customer_settings(WP_REST_Request $request) {
public function get_customer_settings(WP_REST_Request $request)
{
try {
$settings = CustomerSettingsProvider::get_settings();
@@ -290,7 +319,8 @@ class StoreController extends WP_REST_Controller {
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_customer_settings(WP_REST_Request $request) {
public function save_customer_settings(WP_REST_Request $request)
{
try {
$settings = $request->get_json_params();
@@ -320,7 +350,6 @@ class StoreController extends WP_REST_Controller {
'message' => __('Customer settings updated successfully', 'woonoow'),
'settings' => $new_settings,
], 200);
} catch (\Exception $e) {
return new WP_Error(
'save_customer_settings_failed',
@@ -330,12 +359,83 @@ class StoreController extends WP_REST_Controller {
}
}
/**
* Get security settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_security_settings(WP_REST_Request $request)
{
try {
$settings = SecuritySettingsProvider::get_settings();
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
return new WP_Error(
'get_security_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Save security settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_security_settings(WP_REST_Request $request)
{
try {
$settings = $request->get_json_params();
if (empty($settings)) {
return new WP_Error(
'invalid_settings',
__('Invalid settings data', 'woonoow'),
['status' => 400]
);
}
$updated = SecuritySettingsProvider::update_settings($settings);
if (!$updated) {
return new WP_Error(
'update_failed',
__('Failed to update security settings', 'woonoow'),
['status' => 500]
);
}
// Return updated settings
$new_settings = SecuritySettingsProvider::get_settings();
return new WP_REST_Response([
'success' => true,
'message' => __('Security settings updated successfully', 'woonoow'),
'settings' => $new_settings,
], 200);
} catch (\Exception $e) {
return new WP_Error(
'save_security_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Check if user has permission
*
* @return bool True if user has permission
*/
public function check_permission() {
public function check_permission()
{
// Check WooCommerce capability first, fallback to manage_options
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
}

View File

@@ -471,6 +471,31 @@ class SubscriptionsController
}
$enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period);
// Add payment method title
$payment_title = $subscription->payment_method; // Default to ID
// 1. Try from payment_meta (stored snapshot)
if (!empty($subscription->payment_meta)) {
$meta = json_decode($subscription->payment_meta, true);
if (isset($meta['method_title']) && !empty($meta['method_title'])) {
$payment_title = $meta['method_title'];
}
}
// 2. If it looks like an ID (no spaces, lowercase), try to get fresh title from gateway
if ($payment_title === $subscription->payment_method && function_exists('WC')) {
$gateways_handler = WC()->payment_gateways();
if ($gateways_handler) {
$gateways = $gateways_handler->payment_gateways();
if (isset($gateways[$subscription->payment_method])) {
$gw = $gateways[$subscription->payment_method];
$payment_title = $gw->get_title() ?: $gw->method_title;
}
}
}
$enriched['payment_method_title'] = $payment_title;
return $enriched;
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace WooNooW\Compat;
if ( ! defined('ABSPATH') ) exit;
if (! defined('ABSPATH')) exit;
/**
* Navigation Registry
@@ -11,14 +12,16 @@ if ( ! defined('ABSPATH') ) exit;
*
* @since 1.0.0
*/
class NavigationRegistry {
class NavigationRegistry
{
const NAV_OPTION = 'wnw_nav_tree';
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
/**
* Initialize hooks
*/
public static function init() {
public static function init()
{
// Use 'init' hook instead of 'plugins_loaded' to avoid translation loading warnings (WP 6.7+)
add_action('init', [__CLASS__, 'build_nav_tree'], 10);
add_action('activated_plugin', [__CLASS__, 'flush']);
@@ -28,7 +31,8 @@ class NavigationRegistry {
/**
* Build the complete navigation tree
*/
public static function build_nav_tree() {
public static function build_nav_tree()
{
// Check if we need to rebuild (version mismatch)
$cached = get_option(self::NAV_OPTION, []);
$cached_version = $cached['version'] ?? '';
@@ -104,7 +108,8 @@ class NavigationRegistry {
*
* @return array Base navigation tree
*/
private static function get_base_tree(): array {
private static function get_base_tree(): array
{
$tree = [
[
'key' => 'dashboard',
@@ -207,7 +212,8 @@ class NavigationRegistry {
*
* @return array Marketing submenu items
*/
private static function get_marketing_children(): array {
private static function get_marketing_children(): array
{
$children = [];
// Newsletter - only if module enabled
@@ -226,7 +232,8 @@ class NavigationRegistry {
*
* @return array Settings submenu items
*/
private static function get_settings_children(): array {
private static function get_settings_children(): array
{
$admin = admin_url('admin.php');
$children = [
@@ -236,6 +243,7 @@ class NavigationRegistry {
['label' => __('Shipping & Delivery', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/shipping'],
['label' => __('Tax', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/tax'],
['label' => __('Customers', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/customers'],
['label' => __('Security', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/security'],
['label' => __('Notifications', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/notifications'],
['label' => __('Modules', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/modules'],
['label' => __('Developer', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/developer'],
@@ -250,7 +258,8 @@ class NavigationRegistry {
*
* @return array Subscriptions section or empty array
*/
private static function get_subscriptions_section(): array {
private static function get_subscriptions_section(): array
{
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
return [];
}
@@ -273,7 +282,8 @@ class NavigationRegistry {
*
* @return array Navigation tree
*/
public static function get_nav_tree(): array {
public static function get_nav_tree(): array
{
$data = get_option(self::NAV_OPTION, []);
return $data['tree'] ?? self::get_base_tree();
}
@@ -284,7 +294,8 @@ class NavigationRegistry {
* @param string $key Section key
* @return array|null Section data or null if not found
*/
public static function get_section(string $key): ?array {
public static function get_section(string $key): ?array
{
$tree = self::get_nav_tree();
foreach ($tree as $section) {
if (($section['key'] ?? '') === $key) {
@@ -297,7 +308,8 @@ class NavigationRegistry {
/**
* Flush navigation cache
*/
public static function flush() {
public static function flush()
{
delete_option(self::NAV_OPTION);
// Rebuild immediately after flush
self::build_nav_tree();
@@ -308,7 +320,8 @@ class NavigationRegistry {
*
* @return array Array suitable for JSON encoding
*/
public static function get_frontend_nav_tree(): array {
public static function get_frontend_nav_tree(): array
{
return self::get_nav_tree();
}
}

View File

@@ -0,0 +1,301 @@
<?php
/**
* Security Settings Provider
*
* Provides security-related settings including rate limiting and CAPTCHA.
*
* @package WooNooW
*/
namespace WooNooW\Compat;
class SecuritySettingsProvider
{
/**
* Get security settings
*
* @return array
*/
public static function get_settings()
{
return [
// Rate Limiting
'enable_checkout_rate_limit' => get_option('woonoow_enable_checkout_rate_limit', 'yes') === 'yes',
'rate_limit_orders' => intval(get_option('woonoow_rate_limit_orders', 5)),
'rate_limit_minutes' => intval(get_option('woonoow_rate_limit_minutes', 10)),
// CAPTCHA
'captcha_provider' => get_option('woonoow_captcha_provider', 'none'), // none, recaptcha, turnstile
'recaptcha_site_key' => get_option('woonoow_recaptcha_site_key', ''),
'recaptcha_secret_key' => get_option('woonoow_recaptcha_secret_key', ''),
'turnstile_site_key' => get_option('woonoow_turnstile_site_key', ''),
'turnstile_secret_key' => get_option('woonoow_turnstile_secret_key', ''),
];
}
/**
* Get public settings (safe to expose to frontend)
*
* @return array
*/
public static function get_public_settings()
{
$settings = self::get_settings();
return [
'captcha_provider' => $settings['captcha_provider'],
'recaptcha_site_key' => $settings['recaptcha_site_key'],
'turnstile_site_key' => $settings['turnstile_site_key'],
];
}
/**
* Update security settings
*
* @param array $settings
* @return bool
*/
public static function update_settings($settings)
{
// Rate Limiting
if (array_key_exists('enable_checkout_rate_limit', $settings)) {
$value = !empty($settings['enable_checkout_rate_limit']) ? 'yes' : 'no';
update_option('woonoow_enable_checkout_rate_limit', $value);
}
if (isset($settings['rate_limit_orders'])) {
$value = max(1, intval($settings['rate_limit_orders']));
update_option('woonoow_rate_limit_orders', $value);
}
if (isset($settings['rate_limit_minutes'])) {
$value = max(1, intval($settings['rate_limit_minutes']));
update_option('woonoow_rate_limit_minutes', $value);
}
// CAPTCHA Provider
if (isset($settings['captcha_provider'])) {
$valid_providers = ['none', 'recaptcha', 'turnstile'];
$value = in_array($settings['captcha_provider'], $valid_providers)
? $settings['captcha_provider']
: 'none';
update_option('woonoow_captcha_provider', $value);
}
// reCAPTCHA Keys
if (isset($settings['recaptcha_site_key'])) {
update_option('woonoow_recaptcha_site_key', sanitize_text_field($settings['recaptcha_site_key']));
}
if (isset($settings['recaptcha_secret_key'])) {
update_option('woonoow_recaptcha_secret_key', sanitize_text_field($settings['recaptcha_secret_key']));
}
// Turnstile Keys
if (isset($settings['turnstile_site_key'])) {
update_option('woonoow_turnstile_site_key', sanitize_text_field($settings['turnstile_site_key']));
}
if (isset($settings['turnstile_secret_key'])) {
update_option('woonoow_turnstile_secret_key', sanitize_text_field($settings['turnstile_secret_key']));
}
return true;
}
/**
* Check if rate limit is exceeded for an IP
*
* @param string|null $ip IP address (null = auto-detect)
* @return bool True if rate limit exceeded
*/
public static function is_rate_limited($ip = null)
{
$settings = self::get_settings();
if (!$settings['enable_checkout_rate_limit']) {
return false;
}
if ($ip === null) {
$ip = self::get_client_ip();
}
$transient_key = 'woonoow_rate_' . md5($ip);
$attempts = get_transient($transient_key);
if ($attempts === false) {
return false;
}
return intval($attempts) >= $settings['rate_limit_orders'];
}
/**
* Record an order attempt for rate limiting
*
* @param string|null $ip IP address (null = auto-detect)
* @return void
*/
public static function record_order_attempt($ip = null)
{
$settings = self::get_settings();
if (!$settings['enable_checkout_rate_limit']) {
return;
}
if ($ip === null) {
$ip = self::get_client_ip();
}
$transient_key = 'woonoow_rate_' . md5($ip);
$attempts = get_transient($transient_key);
if ($attempts === false) {
// First attempt, set with expiration
set_transient($transient_key, 1, $settings['rate_limit_minutes'] * MINUTE_IN_SECONDS);
} else {
// Increment attempts (keep same expiration by getting remaining time)
$attempts = intval($attempts) + 1;
set_transient($transient_key, $attempts, $settings['rate_limit_minutes'] * MINUTE_IN_SECONDS);
}
}
/**
* Validate CAPTCHA token
*
* @param string $token CAPTCHA token from frontend
* @return bool|WP_Error True if valid, WP_Error if invalid
*/
public static function validate_captcha($token)
{
$settings = self::get_settings();
if ($settings['captcha_provider'] === 'none') {
return true; // No CAPTCHA enabled
}
if (empty($token)) {
return new \WP_Error('captcha_missing', __('CAPTCHA verification required', 'woonoow'));
}
if ($settings['captcha_provider'] === 'recaptcha') {
return self::validate_recaptcha($token, $settings['recaptcha_secret_key']);
}
if ($settings['captcha_provider'] === 'turnstile') {
return self::validate_turnstile($token, $settings['turnstile_secret_key']);
}
return true;
}
/**
* Validate Google reCAPTCHA v3 token
*
* @param string $token Token from frontend
* @param string $secret_key Secret key
* @return bool|WP_Error
*/
private static function validate_recaptcha($token, $secret_key)
{
if (empty($secret_key)) {
return new \WP_Error('captcha_config', __('reCAPTCHA not configured', 'woonoow'));
}
$response = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
'body' => [
'secret' => $secret_key,
'response' => $token,
'remoteip' => self::get_client_ip(),
],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return new \WP_Error('captcha_error', __('CAPTCHA verification failed', 'woonoow'));
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($body['success']) || !$body['success']) {
return new \WP_Error('captcha_invalid', __('CAPTCHA verification failed', 'woonoow'));
}
// reCAPTCHA v3 returns a score (0.0 - 1.0), we accept 0.5 and above
$score = $body['score'] ?? 0;
if ($score < 0.5) {
return new \WP_Error('captcha_score', __('CAPTCHA score too low', 'woonoow'));
}
return true;
}
/**
* Validate Cloudflare Turnstile token
*
* @param string $token Token from frontend
* @param string $secret_key Secret key
* @return bool|WP_Error
*/
private static function validate_turnstile($token, $secret_key)
{
if (empty($secret_key)) {
return new \WP_Error('captcha_config', __('Turnstile not configured', 'woonoow'));
}
$response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
'secret' => $secret_key,
'response' => $token,
'remoteip' => self::get_client_ip(),
],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return new \WP_Error('captcha_error', __('CAPTCHA verification failed', 'woonoow'));
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!isset($body['success']) || !$body['success']) {
return new \WP_Error('captcha_invalid', __('CAPTCHA verification failed', 'woonoow'));
}
return true;
}
/**
* Get client IP address
*
* @return string
*/
private static function get_client_ip()
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ip = $_SERVER[$header];
// Handle comma-separated list (X-Forwarded-For)
if (strpos($ip, ',') !== false) {
$ip = trim(explode(',', $ip)[0]);
}
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '127.0.0.1';
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Campaign Manager
*
@@ -9,9 +10,12 @@
namespace WooNooW\Core\Campaigns;
use WooNooW\Database\SubscriberTable;
if (!defined('ABSPATH')) exit;
class CampaignManager {
class CampaignManager
{
const POST_TYPE = 'wnw_campaign';
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
@@ -21,7 +25,8 @@ class CampaignManager {
/**
* Get instance
*/
public static function instance() {
public static function instance()
{
if (self::$instance === null) {
self::$instance = new self();
}
@@ -31,7 +36,8 @@ class CampaignManager {
/**
* Initialize
*/
public static function init() {
public static function init()
{
add_action('init', [__CLASS__, 'register_post_type']);
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
}
@@ -39,7 +45,8 @@ class CampaignManager {
/**
* Register campaign post type
*/
public static function register_post_type() {
public static function register_post_type()
{
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('Campaigns', 'woonoow'),
@@ -60,7 +67,8 @@ class CampaignManager {
* @param array $data Campaign data
* @return int|WP_Error Campaign ID or error
*/
public static function create($data) {
public static function create($data)
{
$post_data = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
@@ -86,7 +94,8 @@ class CampaignManager {
* @param array $data Campaign data
* @return bool|WP_Error
*/
public static function update($campaign_id, $data) {
public static function update($campaign_id, $data)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
@@ -113,7 +122,8 @@ class CampaignManager {
* @param int $campaign_id
* @param array $data
*/
private static function update_meta($campaign_id, $data) {
private static function update_meta($campaign_id, $data)
{
$meta_fields = [
'subject' => '_wnw_subject',
'content' => '_wnw_content',
@@ -153,7 +163,8 @@ class CampaignManager {
* @param int $campaign_id
* @return array|null
*/
public static function get($campaign_id) {
public static function get($campaign_id)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
@@ -169,7 +180,8 @@ class CampaignManager {
* @param array $args Query args
* @return array
*/
public static function get_all($args = []) {
public static function get_all($args = [])
{
$defaults = [
'post_type' => self::POST_TYPE,
'post_status' => 'any',
@@ -192,7 +204,8 @@ class CampaignManager {
* @param WP_Post $post
* @return array
*/
private static function format_campaign($post) {
private static function format_campaign($post)
{
return [
'id' => $post->ID,
'title' => $post->post_title,
@@ -215,7 +228,8 @@ class CampaignManager {
* @param int $campaign_id
* @return bool
*/
public static function delete($campaign_id) {
public static function delete($campaign_id)
{
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
@@ -231,7 +245,8 @@ class CampaignManager {
* @param int $campaign_id
* @return array Result with sent/failed counts
*/
public static function send($campaign_id) {
public static function send($campaign_id)
{
$campaign = self::get($campaign_id);
if (!$campaign) {
@@ -313,7 +328,8 @@ class CampaignManager {
* @param string $email Test email address
* @return bool
*/
public static function send_test($campaign_id, $email) {
public static function send_test($campaign_id, $email)
{
$campaign = self::get($campaign_id);
if (!$campaign) {
@@ -340,7 +356,8 @@ class CampaignManager {
* @param array $campaign
* @return array ['subject' => string, 'body' => string]
*/
private static function render_campaign_email($campaign) {
private static function render_campaign_email($campaign)
{
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
// Get the campaign email template
@@ -364,12 +381,17 @@ class CampaignManager {
$site_name = get_bloginfo('name');
$site_url = home_url();
// Replace campaign-specific variables in subject
$subject = str_replace('{campaign_title}', $campaign['title'], $subject);
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
$body = str_replace('{site_url}', $site_url, $body);
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
$body = str_replace('{current_year}', date('Y'), $body);
// Parse card shortcodes before rendering
$body = $renderer->parse_cards($body);
// Render through email design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
@@ -388,37 +410,32 @@ class CampaignManager {
/**
* Get subscribers
*
* @param array $filters Optional audience filters
* @return array
*/
private static function get_subscribers() {
// Check if using custom table
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
if ($use_table && self::has_subscribers_table()) {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_results(
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
ARRAY_A
);
private static function get_subscribers($filters = [])
{
// Use SubscriberTable if available
if (SubscriberTable::table_exists()) {
return SubscriberTable::get_active($filters);
}
// Use wp_options storage
// Legacy: use wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return array_filter($subscribers, function($sub) {
return array_filter($subscribers, function ($sub) {
return ($sub['status'] ?? 'active') === 'active';
});
}
/**
* Check if subscribers table exists
* Check if subscribers table exists (deprecated - use SubscriberTable::table_exists())
*
* @deprecated Use SubscriberTable::table_exists() instead
* @return bool
*/
private static function has_subscribers_table() {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
private static function has_subscribers_table()
{
return SubscriberTable::table_exists();
}
/**
@@ -427,7 +444,8 @@ class CampaignManager {
* @param string $email
* @return string
*/
private static function get_unsubscribe_url($email) {
private static function get_unsubscribe_url($email)
{
// Use NewsletterController's secure token-based URL
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
}
@@ -435,7 +453,8 @@ class CampaignManager {
/**
* Process scheduled campaigns (WP-Cron)
*/
public static function process_scheduled_campaigns() {
public static function process_scheduled_campaigns()
{
// Only if scheduling is enabled
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
return;
@@ -464,7 +483,8 @@ class CampaignManager {
/**
* Enable scheduling (registers cron)
*/
public static function enable_scheduling() {
public static function enable_scheduling()
{
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
}
@@ -473,7 +493,8 @@ class CampaignManager {
/**
* Disable scheduling (clears cron)
*/
public static function disable_scheduling() {
public static function disable_scheduling()
{
wp_clear_scheduled_hook(self::CRON_HOOK);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* Channel Registry
*
* Manages registration and retrieval of notification channels
*
* @package WooNooW\Core\Notifications
*/
namespace WooNooW\Core\Notifications;
use WooNooW\Core\Notifications\Channels\ChannelInterface;
class ChannelRegistry
{
/**
* Registered channels
*
* @var array<string, ChannelInterface>
*/
private static $channels = [];
/**
* Register a notification channel
*
* @param ChannelInterface $channel Channel instance
* @return bool Success status
*/
public static function register(ChannelInterface $channel)
{
$id = $channel->get_id();
if (empty($id)) {
return false;
}
self::$channels[$id] = $channel;
return true;
}
/**
* Get a registered channel by ID
*
* @param string $channel_id Channel identifier
* @return ChannelInterface|null Channel instance or null if not found
*/
public static function get($channel_id)
{
return self::$channels[$channel_id] ?? null;
}
/**
* Get all registered channels
*
* @return array<string, ChannelInterface> Associative array of channel_id => channel_instance
*/
public static function get_all()
{
return self::$channels;
}
/**
* Check if a channel is registered
*
* @param string $channel_id Channel identifier
* @return bool True if channel exists
*/
public static function has($channel_id)
{
return isset(self::$channels[$channel_id]);
}
/**
* Unregister a channel
*
* @param string $channel_id Channel identifier
* @return bool Success status
*/
public static function unregister($channel_id)
{
if (isset(self::$channels[$channel_id])) {
unset(self::$channels[$channel_id]);
return true;
}
return false;
}
/**
* Get list of configured channel IDs
*
* Only returns channels that are properly configured (is_configured() returns true)
*
* @return array List of configured channel IDs
*/
public static function get_configured_channels()
{
$configured = [];
foreach (self::$channels as $id => $channel) {
if ($channel->is_configured()) {
$configured[] = $id;
}
}
return $configured;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* Channel Interface
*
* Contract for implementing custom notification channels (WhatsApp, SMS, Telegram, etc.)
*
* @package WooNooW\Core\Notifications\Channels
*/
namespace WooNooW\Core\Notifications\Channels;
interface ChannelInterface
{
/**
* Get channel unique identifier
*
* @return string Channel ID (e.g., 'whatsapp', 'sms', 'telegram')
*/
public function get_id();
/**
* Get channel display label
*
* @return string Channel label for UI (e.g., 'WhatsApp', 'SMS', 'Telegram')
*/
public function get_label();
/**
* Check if channel is properly configured
*
* Example: API keys are set, credentials are valid, etc.
*
* @return bool True if channel is ready to send notifications
*/
public function is_configured();
/**
* Send notification through this channel
*
* @param string $event_id Event identifier (e.g., 'order_completed', 'newsletter_confirm')
* @param string $recipient Recipient type ('customer', 'staff')
* @param array $data Notification context data (order, user, custom vars, etc.)
* @return bool|array Success status, or array with 'success' and 'message' keys
*/
public function send($event_id, $recipient, $data);
/**
* Get channel configuration fields for admin settings
*
* Optional. Returns array of field definitions for settings UI.
*
* @return array Field definitions (e.g., API key, sender number, etc.)
*/
public function get_config_fields();
}

View File

@@ -0,0 +1,252 @@
<?php
/**
* WhatsApp Channel - Example Implementation
*
* This is a reference implementation showing how to create a custom notification channel.
* Developers can use this as a template for implementing WhatsApp, SMS, Telegram, etc.
*
* @package WooNooW\Core\Notifications\Channels
*/
namespace WooNooW\Core\Notifications\Channels;
/**
* Example WhatsApp Channel Implementation
*
* This channel sends notifications via WhatsApp Business API.
* Replace API calls with your actual WhatsApp service provider (Twilio, MessageBird, etc.)
*/
class WhatsAppChannel implements ChannelInterface
{
/**
* Get channel ID
*/
public function get_id()
{
return 'whatsapp';
}
/**
* Get channel label
*/
public function get_label()
{
return __('WhatsApp', 'woonoow');
}
/**
* Check if channel is configured
*/
public function is_configured()
{
$api_key = get_option('woonoow_whatsapp_api_key', '');
$phone_number = get_option('woonoow_whatsapp_phone_number', '');
return !empty($api_key) && !empty($phone_number);
}
/**
* Send WhatsApp notification
*
* @param string $event_id Event identifier
* @param string $recipient Recipient type ('customer' or 'staff')
* @param array $data Context data (order, user, etc.)
* @return bool|array Success status
*/
public function send($event_id, $recipient, $data)
{
// Get recipient phone number
$phone = $this->get_recipient_phone($recipient, $data);
if (empty($phone)) {
return [
'success' => false,
'message' => 'No phone number available for recipient',
];
}
// Build message content based on event
$message = $this->build_message($event_id, $data);
if (empty($message)) {
return [
'success' => false,
'message' => 'Could not build message for event: ' . $event_id,
];
}
// Send via WhatsApp API
$result = $this->send_whatsapp_message($phone, $message);
// Log the send attempt
do_action('woonoow_whatsapp_sent', $event_id, $recipient, $phone, $result);
return $result;
}
/**
* Get configuration fields for admin settings
*/
public function get_config_fields()
{
return [
[
'id' => 'woonoow_whatsapp_api_key',
'label' => __('WhatsApp API Key', 'woonoow'),
'type' => 'text',
'description' => __('Your WhatsApp Business API key', 'woonoow'),
],
[
'id' => 'woonoow_whatsapp_phone_number',
'label' => __('WhatsApp Business Number', 'woonoow'),
'type' => 'text',
'description' => __('Your WhatsApp Business phone number (with country code)', 'woonoow'),
'placeholder' => '+1234567890',
],
[
'id' => 'woonoow_whatsapp_provider',
'label' => __('Service Provider', 'woonoow'),
'type' => 'select',
'options' => [
'twilio' => 'Twilio',
'messagebird' => 'MessageBird',
'custom' => 'Custom',
],
'default' => 'twilio',
],
];
}
/**
* Get recipient phone number
*
* @param string $recipient Recipient type
* @param array $data Context data
* @return string Phone number or empty string
*/
private function get_recipient_phone($recipient, $data)
{
if ($recipient === 'customer') {
// Get customer phone from order or user data
if (isset($data['order'])) {
return $data['order']->get_billing_phone();
}
if (isset($data['user_id'])) {
return get_user_meta($data['user_id'], 'billing_phone', true);
}
if (isset($data['email'])) {
$user = get_user_by('email', $data['email']);
if ($user) {
return get_user_meta($user->ID, 'billing_phone', true);
}
}
} elseif ($recipient === 'staff') {
// Get admin phone from settings
return get_option('woonoow_whatsapp_admin_phone', '');
}
return '';
}
/**
* Build message content based on event
*
* @param string $event_id Event identifier
* @param array $data Context data
* @return string Message text
*/
private function build_message($event_id, $data)
{
// Allow filtering message content
$message = apply_filters("woonoow_whatsapp_message_{$event_id}", '', $data);
if (!empty($message)) {
return $message;
}
// Default messages for common events
$site_name = get_bloginfo('name');
switch ($event_id) {
case 'order_completed':
if (isset($data['order'])) {
$order = $data['order'];
return sprintf(
"🎉 Your order #%s has been completed! Thank you for shopping with %s.",
$order->get_order_number(),
$site_name
);
}
break;
case 'newsletter_confirm':
if (isset($data['confirmation_url'])) {
return sprintf(
"Please confirm your newsletter subscription by clicking: %s",
$data['confirmation_url']
);
}
break;
// Add more event templates as needed
}
return '';
}
/**
* Send WhatsApp message via API
*
* Replace this with actual API integration for your provider
*
* @param string $phone Recipient phone number
* @param string $message Message text
* @return array Result with 'success' and 'message' keys
*/
private function send_whatsapp_message($phone, $message)
{
$api_key = get_option('woonoow_whatsapp_api_key', '');
$from_number = get_option('woonoow_whatsapp_phone_number', '');
$provider = get_option('woonoow_whatsapp_provider', 'twilio');
// Example: Twilio API (replace with your actual implementation)
if ($provider === 'twilio') {
$endpoint = 'https://api.twilio.com/2010-04-01/Accounts/YOUR_ACCOUNT_SID/Messages.json';
$response = wp_remote_post($endpoint, [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($api_key),
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => [
'From' => 'whatsapp:' . $from_number,
'To' => 'whatsapp:' . $phone,
'Body' => $message,
],
]);
if (is_wp_error($response)) {
return [
'success' => false,
'message' => $response->get_error_message(),
];
}
$status_code = wp_remote_retrieve_response_code($response);
return [
'success' => $status_code >= 200 && $status_code < 300,
'message' => $status_code >= 200 && $status_code < 300
? 'WhatsApp message sent successfully'
: 'Failed to send WhatsApp message',
];
}
// For custom providers, implement your own logic here
return [
'success' => false,
'message' => 'Provider not configured',
];
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Default Email Templates (DEPRECATED)
*
@@ -17,7 +18,8 @@ namespace WooNooW\Core\Notifications;
use WooNooW\Email\DefaultTemplates as NewDefaultTemplates;
class DefaultEmailTemplates {
class DefaultEmailTemplates
{
/**
* Get default template for an event and recipient type
@@ -26,7 +28,8 @@ class DefaultEmailTemplates {
* @param string $recipient_type 'staff' or 'customer'
* @return array ['subject' => string, 'body' => string]
*/
public static function get_template($event_id, $recipient_type) {
public static function get_template($event_id, $recipient_type)
{
// Get templates directly from this class
$allTemplates = self::get_all_templates();
@@ -47,7 +50,8 @@ class DefaultEmailTemplates {
*
* @return array
*/
private static function get_all_templates() {
private static function get_all_templates()
{
// This method is now deprecated but kept for backwards compatibility
// Use WooNooW\Email\DefaultTemplates instead
return [
@@ -135,7 +139,7 @@ class DefaultEmailTemplates {
[/card]
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]
[button url="{store_url}" style="outline"]' . __('Continue Shopping', 'woonoow') . '[/button]',
[button url="{shop_url}" style="outline"]' . __('Continue Shopping', 'woonoow') . '[/button]',
],
],
@@ -261,7 +265,7 @@ class DefaultEmailTemplates {
[/card]
[button url="{account_url}" style="solid"]' . __('Go to My Account', 'woonoow') . '[/button]
[button url="{store_url}" style="outline"]' . __('Start Shopping', 'woonoow') . '[/button]',
[button url="{shop_url}" style="outline"]' . __('Start Shopping', 'woonoow') . '[/button]',
],
],
@@ -295,7 +299,8 @@ class DefaultEmailTemplates {
*
* @return array
*/
public static function get_new_templates() {
public static function get_new_templates()
{
return NewDefaultTemplates::get_all_templates();
}
@@ -306,7 +311,8 @@ class DefaultEmailTemplates {
* @param string $event_id Event ID
* @return string
*/
public static function get_default_subject($recipient_type, $event_id) {
public static function get_default_subject($recipient_type, $event_id)
{
return NewDefaultTemplates::get_default_subject($recipient_type, $event_id);
}
}

View File

@@ -89,7 +89,7 @@ class EmailRenderer
* @param string $recipient_type
* @return array|null
*/
private function get_template_settings($event_id, $recipient_type)
public function get_template_settings($event_id, $recipient_type)
{
// Get saved template (with recipient_type for proper default template lookup)
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
@@ -187,7 +187,7 @@ class EmailRenderer
'site_name' => get_bloginfo('name'),
'site_title' => get_bloginfo('name'),
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'site_url' => home_url(),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'support_email' => get_option('admin_email'),
@@ -381,7 +381,7 @@ class EmailRenderer
* @param string $content
* @return string
*/
private function parse_cards($content)
public function parse_cards($content)
{
// Use a single unified regex to match BOTH syntaxes in document order
// This ensures cards are rendered in the order they appear
@@ -473,8 +473,31 @@ class EmailRenderer
$hero_text_color = '#ffffff'; // Always white on gradient
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
// Helper function to escape URL while preserving variable placeholders like {unsubscribe_url}
$escape_url_preserving_variables = function ($url) {
// If URL contains variable placeholder, don't escape (will be replaced later)
if (preg_match('/\{[a-z_]+\}/', $url)) {
// Just return the URL as-is - it will be replaced with a real URL later
return $url;
}
return esc_url($url);
};
// Helper function to generate button HTML
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
$generateButtonHtml = function ($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color, $escape_url_preserving_variables) {
$escaped_url = $escape_url_preserving_variables($url);
if ($style === 'link') {
// Plain link - just a simple <a> tag styled like regular text link (inline, no wrapper)
return sprintf(
'<a href="%s" style="color: %s; text-decoration: underline; font-family: \'Inter\', Arial, sans-serif;">%s</a>',
$escaped_url,
esc_attr($primary_color),
esc_html($text)
);
}
// Styled buttons (solid/outline) get table wrapper for email client compatibility
if ($style === 'outline') {
// Outline button - transparent background with border
$button_style = sprintf(
@@ -494,7 +517,7 @@ class EmailRenderer
// Use table-based button for better email client compatibility
return sprintf(
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
esc_url($url),
$escaped_url,
$button_style,
esc_html($text)
);
@@ -542,9 +565,25 @@ class EmailRenderer
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
// Add inline color to all headings and paragraphs for email client compatibility
$content = preg_replace(
'/<(h[1-6]|p)([^>]*)>/',
'<$1$2 style="color: ' . esc_attr($hero_text_color) . ';">',
// Preserve existing style attributes (like text-align) by appending to them
$content = preg_replace_callback(
'/<(h[1-6]|p)([^>]*?)(\s+style=["\']([^"\']*)["\'])?([^>]*)>/',
function ($matches) use ($hero_text_color) {
$tag = $matches[1];
$before_style = $matches[2];
$existing_style = isset($matches[4]) ? $matches[4] : '';
$after_style = $matches[5];
$color_style = 'color: ' . esc_attr($hero_text_color) . ';';
if ($existing_style) {
// Append to existing style
$new_style = rtrim($existing_style, ';') . '; ' . $color_style;
return '<' . $tag . $before_style . ' style="' . $new_style . '"' . $after_style . '>';
} else {
// Add new style attribute
return '<' . $tag . $before_style . ' style="' . $color_style . '"' . $after_style . '>';
}
},
$content
);
}
@@ -560,6 +599,11 @@ class EmailRenderer
elseif ($type === 'warning') {
$style .= ' background-color: #fff8e1;';
}
// Basic card - plain text, no card styling (for footers/muted content)
elseif ($type === 'basic') {
$style = 'width: 100%; background-color: transparent;'; // No background
$content_style = 'padding: 0;'; // No padding
}
}
// Add background image
@@ -616,7 +660,7 @@ class EmailRenderer
*
* @return string
*/
private function get_design_template()
public function get_design_template()
{
// Use single base template (theme-agnostic)
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
@@ -641,7 +685,7 @@ class EmailRenderer
* @param array $variables All variables
* @return string
*/
private function render_html($template_path, $content, $subject, $variables)
public function render_html($template_path, $content, $subject, $variables)
{
if (!file_exists($template_path)) {
// Fallback to plain HTML
@@ -654,6 +698,10 @@ class EmailRenderer
// Get email customization settings
$email_settings = get_option('woonoow_email_settings', []);
// Ensure required variables have defaults
$variables['site_url'] = $variables['site_url'] ?? home_url();
$variables['store_name'] = $variables['store_name'] ?? get_bloginfo('name');
// Email body background
$body_bg = '#f8f8f8';
@@ -668,7 +716,7 @@ class EmailRenderer
if (!empty($logo_url)) {
$header = sprintf(
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_url($logo_url),
esc_attr($variables['store_name'])
);
@@ -677,7 +725,7 @@ class EmailRenderer
$header_text = !empty($email_settings['header_text']) ? $email_settings['header_text'] : $variables['store_name'];
$header = sprintf(
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
esc_url($variables['store_url']),
esc_url($variables['site_url']),
esc_html($header_text)
);
}
@@ -724,7 +772,7 @@ class EmailRenderer
$html = str_replace('{{email_content}}', $content, $html);
$html = str_replace('{{email_footer}}', $footer, $html);
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $html);
$html = str_replace('{{store_url}}', esc_url($variables['store_url']), $html);
$html = str_replace('{{site_url}}', esc_url($variables['site_url']), $html);
$html = str_replace('{{current_year}}', date('Y'), $html);
// Replace all other variables

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
<?php
/**
* Markdown to Email HTML Parser
*
@@ -17,7 +18,8 @@
namespace WooNooW\Core\Notifications;
class MarkdownParser {
class MarkdownParser
{
/**
* Parse markdown to email HTML
@@ -25,13 +27,14 @@ class MarkdownParser {
* @param string $markdown
* @return string
*/
public static function parse($markdown) {
public static function parse($markdown)
{
$html = $markdown;
// Parse card blocks first (:::card or :::card[type])
$html = preg_replace_callback(
'/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/s',
function($matches) {
function ($matches) {
$type = $matches[1] ?? '';
$content = trim($matches[2]);
$parsed_content = self::parse_basics($content);
@@ -44,7 +47,7 @@ class MarkdownParser {
// Also support legacy [button](url){text} syntax
$html = preg_replace_callback(
'/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/',
function($matches) {
function ($matches) {
$style = $matches[1] ?? '';
$url = $matches[2];
$text = $matches[3];
@@ -68,25 +71,42 @@ class MarkdownParser {
* @param string $text
* @return string
*/
private static function parse_basics($text) {
private static function parse_basics($text)
{
$html = $text;
// Protect variables from markdown parsing by temporarily replacing them
$variables = [];
$var_index = 0;
$html = preg_replace_callback('/\{([^}]+)\}/', function($matches) use (&$variables, &$var_index) {
$html = preg_replace_callback('/\{([^}]+)\}/', function ($matches) use (&$variables, &$var_index) {
$placeholder = '<!--VAR' . $var_index . '-->';
$variables[$placeholder] = $matches[0];
$var_index++;
return $placeholder;
}, $html);
// Protect existing HTML tags (h1-h6, p) with style attributes from being overwritten
$html_tags = [];
$tag_index = 0;
$html = preg_replace_callback('/<(h[1-6]|p)([^>]*style=[^>]*)>/', function ($matches) use (&$html_tags, &$tag_index) {
$placeholder = '<!--HTMLTAG' . $tag_index . '-->';
$html_tags[$placeholder] = $matches[0];
$tag_index++;
return $placeholder;
}, $html);
// Headings (must be done in order from h4 to h1 to avoid conflicts)
// Only match markdown syntax (lines starting with #), not existing HTML
$html = preg_replace('/^#### (.*)$/m', '<h4>$1</h4>', $html);
$html = preg_replace('/^### (.*)$/m', '<h3>$1</h3>', $html);
$html = preg_replace('/^## (.*)$/m', '<h2>$1</h2>', $html);
$html = preg_replace('/^# (.*)$/m', '<h1>$1</h1>', $html);
// Restore protected HTML tags
foreach ($html_tags as $placeholder => $original) {
$html = str_replace($placeholder, $original, $html);
}
// Bold (don't match across newlines)
$html = preg_replace('/\*\*([^\n*]+?)\*\*/', '<strong>$1</strong>', $html);
$html = preg_replace('/__([^\n_]+?)__/', '<strong>$1</strong>', $html);
@@ -107,7 +127,7 @@ class MarkdownParser {
$paragraph_content = '';
$processed_lines = [];
$close_paragraph = function() use (&$paragraph_content, &$processed_lines) {
$close_paragraph = function () use (&$paragraph_content, &$processed_lines) {
if ($paragraph_content) {
$processed_lines[] = '<p>' . $paragraph_content . '</p>';
$paragraph_content = '';
@@ -185,7 +205,8 @@ class MarkdownParser {
* @param string $html
* @return string
*/
public static function nl2br_email($html) {
public static function nl2br_email($html)
{
// Don't convert newlines inside HTML tags
$html = preg_replace('/(?<!>)\n(?!<)/', '<br>', $html);
return $html;

View File

@@ -1,4 +1,5 @@
<?php
/**
* Notification Manager
*
@@ -9,7 +10,10 @@
namespace WooNooW\Core\Notifications;
class NotificationManager {
use WooNooW\Core\Notifications\ChannelRegistry;
class NotificationManager
{
/**
* Check if a channel is enabled globally
@@ -17,14 +21,22 @@ class NotificationManager {
* @param string $channel_id Channel ID (email, push, etc.)
* @return bool
*/
public static function is_channel_enabled($channel_id) {
public static function is_channel_enabled($channel_id)
{
// Check built-in channels
if ($channel_id === 'email') {
return (bool) get_option('woonoow_email_notifications_enabled', true);
} elseif ($channel_id === 'push') {
return (bool) get_option('woonoow_push_notifications_enabled', true);
}
// For addon channels, check if they're registered and enabled
// Check if channel is registered in ChannelRegistry
if (ChannelRegistry::has($channel_id)) {
$channel = ChannelRegistry::get($channel_id);
return $channel->is_configured();
}
// Legacy: check via filter (backward compatibility)
$channels = apply_filters('woonoow_notification_channels', []);
foreach ($channels as $channel) {
if ($channel['id'] === $channel_id) {
@@ -42,7 +54,8 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return bool
*/
public static function is_event_channel_enabled($event_id, $channel_id) {
public static function is_event_channel_enabled($event_id, $channel_id)
{
$settings = get_option('woonoow_notification_settings', []);
if (!isset($settings[$event_id])) {
@@ -69,7 +82,8 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return bool
*/
public static function should_send_notification($event_id, $channel_id) {
public static function should_send_notification($event_id, $channel_id)
{
// Check if WooNooW notification system is enabled
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
if ($system_mode !== 'woonoow') {
@@ -96,7 +110,8 @@ class NotificationManager {
* @param string $channel_id Channel ID
* @return string Recipient type (admin, customer, both)
*/
public static function get_recipient($event_id, $channel_id) {
public static function get_recipient($event_id, $channel_id)
{
$settings = get_option('woonoow_notification_settings', []);
if (!isset($settings[$event_id]['channels'][$channel_id]['recipient'])) {
@@ -114,7 +129,8 @@ class NotificationManager {
* @param array $data Notification data
* @return bool Success status
*/
public static function send($event_id, $channel_id, $data = []) {
public static function send($event_id, $channel_id, $data = [])
{
// Validate if notification should be sent
if (!self::should_send_notification($event_id, $channel_id)) {
return false;
@@ -123,7 +139,15 @@ class NotificationManager {
// Get recipient
$recipient = self::get_recipient($event_id, $channel_id);
// Allow addons to handle their own channels
// Try to use registered channel from ChannelRegistry
if (ChannelRegistry::has($channel_id)) {
$channel = ChannelRegistry::get($channel_id);
if ($channel->is_configured()) {
return $channel->send($event_id, $recipient, $data);
}
}
// Legacy: Allow addons to handle their own channels via filter
$sent = apply_filters(
'woonoow_send_notification',
false,
@@ -138,7 +162,7 @@ class NotificationManager {
return $sent;
}
// Handle built-in channels
// Handle built-in channels (email, push)
if ($channel_id === 'email') {
return self::send_email($event_id, $recipient, $data);
} elseif ($channel_id === 'push') {
@@ -156,7 +180,8 @@ class NotificationManager {
* @param array $data Notification data
* @return bool
*/
private static function send_email($event_id, $recipient, $data) {
private static function send_email($event_id, $recipient, $data)
{
// Use EmailRenderer to render the email
$renderer = EmailRenderer::instance();
$email_data = $renderer->render($event_id, $recipient, $data['order'] ?? $data['product'] ?? $data['customer'] ?? null, $data);
@@ -183,7 +208,8 @@ class NotificationManager {
* @param array $data Notification data
* @return bool
*/
private static function send_push($event_id, $recipient, $data) {
private static function send_push($event_id, $recipient, $data)
{
// Push notification sending will be implemented later
// This is a placeholder for future implementation
do_action('woonoow_send_push_notification', $event_id, $recipient, $data);

View File

@@ -1,4 +1,5 @@
<?php
/**
* Notification Template Provider
*
@@ -11,7 +12,8 @@ namespace WooNooW\Core\Notifications;
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
class TemplateProvider {
class TemplateProvider
{
/**
* Option key for storing templates
@@ -23,7 +25,8 @@ class TemplateProvider {
*
* @return array
*/
public static function get_templates() {
public static function get_templates()
{
$templates = get_option(self::OPTION_KEY, []);
// Merge with defaults
@@ -40,7 +43,8 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return array|null
*/
public static function get_template($event_id, $channel_id, $recipient_type = 'customer') {
public static function get_template($event_id, $channel_id, $recipient_type = 'customer')
{
$templates = self::get_templates();
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
@@ -68,7 +72,8 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer') {
public static function save_template($event_id, $channel_id, $template, $recipient_type = 'customer')
{
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
@@ -94,7 +99,8 @@ class TemplateProvider {
* @param string $recipient_type Recipient type ('customer' or 'staff')
* @return bool
*/
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer') {
public static function delete_template($event_id, $channel_id, $recipient_type = 'customer')
{
$templates = get_option(self::OPTION_KEY, []);
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
@@ -112,7 +118,8 @@ class TemplateProvider {
*
* @return array
*/
public static function get_default_templates() {
public static function get_default_templates()
{
$templates = [];
// Get all events from EventRegistry (single source of truth)
@@ -227,7 +234,8 @@ class TemplateProvider {
* @param string $event_id Event ID
* @return array
*/
private static function get_variables_for_event($event_id) {
private static function get_variables_for_event($event_id)
{
// Product events
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
return self::get_product_variables();
@@ -252,7 +260,8 @@ class TemplateProvider {
*
* @return array
*/
public static function get_order_variables() {
public static function get_order_variables()
{
return [
'order_number' => __('Order Number', 'woonoow'),
'order_total' => __('Order Total', 'woonoow'),
@@ -272,7 +281,7 @@ class TemplateProvider {
'billing_address' => __('Billing Address', 'woonoow'),
'shipping_address' => __('Shipping Address', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
@@ -282,14 +291,15 @@ class TemplateProvider {
*
* @return array
*/
public static function get_product_variables() {
public static function get_product_variables()
{
return [
'product_name' => __('Product Name', 'woonoow'),
'product_sku' => __('Product SKU', 'woonoow'),
'product_url' => __('Product URL', 'woonoow'),
'stock_quantity' => __('Stock Quantity', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
];
}
@@ -298,13 +308,14 @@ class TemplateProvider {
*
* @return array
*/
public static function get_customer_variables() {
public static function get_customer_variables()
{
return [
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'customer_phone' => __('Customer Phone', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'store_email' => __('Store Email', 'woonoow'),
];
}
@@ -314,7 +325,8 @@ class TemplateProvider {
*
* @return array
*/
public static function get_subscription_variables() {
public static function get_subscription_variables()
{
return [
'subscription_id' => __('Subscription ID', 'woonoow'),
'subscription_status' => __('Subscription Status', 'woonoow'),
@@ -327,7 +339,7 @@ class TemplateProvider {
'customer_name' => __('Customer Name', 'woonoow'),
'customer_email' => __('Customer Email', 'woonoow'),
'store_name' => __('Store Name', 'woonoow'),
'store_url' => __('Store URL', 'woonoow'),
'site_url' => __('Site URL', 'woonoow'),
'my_account_url' => __('My Account URL', 'woonoow'),
];
}
@@ -339,7 +351,8 @@ class TemplateProvider {
* @param array $data Data to replace variables
* @return string
*/
public static function replace_variables($content, $data) {
public static function replace_variables($content, $data)
{
foreach ($data as $key => $value) {
$content = str_replace('{' . $key . '}', $value, $content);
}

View File

@@ -0,0 +1,366 @@
<?php
/**
* Subscriber Table - Custom database table for newsletter subscribers
*
* Provides scalable storage for subscribers instead of wp_options
*
* @package WooNooW\Database
*/
namespace WooNooW\Database;
class SubscriberTable
{
const TABLE_NAME = 'woonoow_subscribers';
const DB_VERSION = '1.0';
const DB_VERSION_OPTION = 'woonoow_subscribers_db_version';
/**
* Get full table name with prefix
*/
public static function get_table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or update the subscribers table
*/
public static function create_table()
{
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
user_id BIGINT(20) UNSIGNED DEFAULT NULL,
status ENUM('pending','active','unsubscribed') DEFAULT 'pending',
consent TINYINT(1) DEFAULT 0,
consent_text TEXT,
source VARCHAR(50) DEFAULT 'form',
subscribed_at DATETIME DEFAULT NULL,
confirmed_at DATETIME DEFAULT NULL,
unsubscribed_at DATETIME DEFAULT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_email (email),
KEY idx_status (status),
KEY idx_user_id (user_id)
) $charset_collate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
/**
* Check if table exists
*/
public static function table_exists()
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name;
}
/**
* Migrate existing subscribers from wp_options to custom table
*
* @return array Migration result with counts
*/
public static function migrate_from_options()
{
global $wpdb;
// Ensure table exists
if (!self::table_exists()) {
self::create_table();
}
$table_name = self::get_table_name();
$subscribers = get_option('woonoow_newsletter_subscribers', []);
if (empty($subscribers)) {
return ['migrated' => 0, 'skipped' => 0, 'message' => 'No subscribers to migrate'];
}
$migrated = 0;
$skipped = 0;
foreach ($subscribers as $sub) {
if (empty($sub['email'])) {
$skipped++;
continue;
}
// Check if already exists in table
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table_name WHERE email = %s",
$sub['email']
));
if ($exists) {
$skipped++;
continue;
}
// Insert into new table
$result = $wpdb->insert($table_name, [
'email' => $sub['email'],
'user_id' => $sub['user_id'] ?? null,
'status' => $sub['status'] ?? 'active',
'consent' => !empty($sub['consent']) ? 1 : 0,
'subscribed_at' => $sub['subscribed_at'] ?? current_time('mysql'),
'confirmed_at' => $sub['confirmed_at'] ?? null,
'ip_address' => $sub['ip_address'] ?? null,
]);
if ($result) {
$migrated++;
} else {
$skipped++;
}
}
// If all migrated successfully, remove the option
if ($migrated > 0 && $skipped === 0) {
delete_option('woonoow_newsletter_subscribers');
}
return [
'migrated' => $migrated,
'skipped' => $skipped,
'message' => "Migrated $migrated subscribers, skipped $skipped",
];
}
// =========================================================================
// CRUD OPERATIONS
// =========================================================================
/**
* Add a new subscriber
*/
public static function add($data)
{
global $wpdb;
$table_name = self::get_table_name();
$result = $wpdb->insert($table_name, [
'email' => $data['email'],
'user_id' => $data['user_id'] ?? null,
'status' => $data['status'] ?? 'pending',
'consent' => !empty($data['consent']) ? 1 : 0,
'consent_text' => $data['consent_text'] ?? null,
'source' => $data['source'] ?? 'form',
'subscribed_at' => $data['subscribed_at'] ?? current_time('mysql'),
'ip_address' => $data['ip_address'] ?? null,
]);
if ($result) {
return $wpdb->insert_id;
}
return false;
}
/**
* Get a subscriber by email
*/
public static function get_by_email($email)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE email = %s",
$email
), ARRAY_A);
}
/**
* Get a subscriber by ID
*/
public static function get($id)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$id
), ARRAY_A);
}
/**
* Update a subscriber
*/
public static function update($id, $data)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->update($table_name, $data, ['id' => $id]);
}
/**
* Update subscriber by email
*/
public static function update_by_email($email, $data)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->update($table_name, $data, ['email' => $email]);
}
/**
* Delete a subscriber
*/
public static function delete($id)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->delete($table_name, ['id' => $id]);
}
/**
* Delete subscriber by email
*/
public static function delete_by_email($email)
{
global $wpdb;
$table_name = self::get_table_name();
return $wpdb->delete($table_name, ['email' => $email]);
}
/**
* Get all active subscribers
*
* @param array $filters Optional filters
* @return array List of subscribers
*/
public static function get_active($filters = [])
{
global $wpdb;
$table_name = self::get_table_name();
$where = ["status = 'active'"];
$values = [];
// Filter: subscribed after date
if (!empty($filters['subscribed_after'])) {
$where[] = "subscribed_at >= %s";
$values[] = $filters['subscribed_after'];
}
// Filter: subscribed before date
if (!empty($filters['subscribed_before'])) {
$where[] = "subscribed_at <= %s";
$values[] = $filters['subscribed_before'];
}
// Filter: registered users only
if (!empty($filters['registered_only'])) {
$where[] = "user_id IS NOT NULL";
}
// Filter: guests only
if (!empty($filters['guests_only'])) {
$where[] = "user_id IS NULL";
}
$where_sql = implode(' AND ', $where);
$sql = "SELECT * FROM $table_name WHERE $where_sql ORDER BY subscribed_at DESC";
if (!empty($values)) {
$sql = $wpdb->prepare($sql, ...$values);
}
return $wpdb->get_results($sql, ARRAY_A);
}
/**
* Get all subscribers with pagination
*/
public static function get_all($args = [])
{
global $wpdb;
$table_name = self::get_table_name();
$defaults = [
'per_page' => 20,
'page' => 1,
'status' => '',
'search' => '',
'orderby' => 'subscribed_at',
'order' => 'DESC',
];
$args = wp_parse_args($args, $defaults);
$where = [];
$values = [];
if (!empty($args['status'])) {
$where[] = "status = %s";
$values[] = $args['status'];
}
if (!empty($args['search'])) {
$where[] = "email LIKE %s";
$values[] = '%' . $wpdb->esc_like($args['search']) . '%';
}
$where_sql = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
// Get total count
$count_sql = "SELECT COUNT(*) FROM $table_name $where_sql";
if (!empty($values)) {
$count_sql = $wpdb->prepare($count_sql, ...$values);
}
$total = (int) $wpdb->get_var($count_sql);
// Get paginated results
$offset = ($args['page'] - 1) * $args['per_page'];
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'subscribed_at DESC';
$sql = "SELECT * FROM $table_name $where_sql ORDER BY $orderby LIMIT %d OFFSET %d";
$values[] = $args['per_page'];
$values[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, ...$values), ARRAY_A);
return [
'items' => $items,
'total' => $total,
'pages' => ceil($total / $args['per_page']),
];
}
/**
* Count subscribers by status
*/
public static function count_by_status($status = null)
{
global $wpdb;
$table_name = self::get_table_name();
if ($status) {
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table_name WHERE status = %s",
$status
));
}
return (int) $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
}
}

View File

@@ -1,16 +1,19 @@
<?php
namespace WooNooW\Frontend;
/**
* Frontend Assets Manager
* Handles loading of customer-spa assets
*/
class Assets {
class Assets
{
/**
* Initialize
*/
public static function init() {
public static function init()
{
add_action('wp_enqueue_scripts', [self::class, 'enqueue_assets'], 20);
add_action('wp_head', [self::class, 'add_inline_config'], 5);
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
@@ -21,7 +24,8 @@ class Assets {
/**
* Add type="module" to customer-spa scripts
*/
public static function add_module_type($tag, $handle, $src) {
public static function add_module_type($tag, $handle, $src)
{
// Add type="module" to our Vite scripts
if (strpos($handle, 'woonoow-customer') !== false) {
$tag = str_replace('<script ', '<script type="module" ', $tag);
@@ -32,7 +36,8 @@ class Assets {
/**
* Enqueue customer-spa assets
*/
public static function enqueue_assets() {
public static function enqueue_assets()
{
// Only load on pages with WooNooW shortcodes or in full SPA mode
if (!self::should_load_assets()) {
return;
@@ -85,7 +90,7 @@ class Assets {
);
// Add type="module" for Vite build
add_filter('script_loader_tag', function($tag, $handle, $src) {
add_filter('script_loader_tag', function ($tag, $handle, $src) {
if ($handle === 'woonoow-customer-spa') {
$tag = str_replace('<script ', '<script type="module" ', $tag);
}
@@ -104,7 +109,8 @@ class Assets {
/**
* Inject SPA mounting point for full mode
*/
public static function inject_spa_mount_point() {
public static function inject_spa_mount_point()
{
if (!self::should_load_assets()) {
return;
}
@@ -122,7 +128,8 @@ class Assets {
/**
* Add inline config and scripts to page head
*/
public static function add_inline_config() {
public static function add_inline_config()
{
if (!self::should_load_assets()) {
return;
}
@@ -211,6 +218,10 @@ class Assets {
if ($spa_frontpage) {
$front_page_slug = $spa_frontpage->post_name;
}
} elseif ($is_spa_wp_frontpage && $spa_page) {
// If the SPA Entry Page itself is set as the WP Frontpage,
// use its content as the SPA Frontpage content.
$front_page_slug = $spa_page->post_name;
}
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
@@ -236,9 +247,10 @@ class Assets {
'useBrowserRouter' => $use_browser_router,
'frontPageSlug' => $front_page_slug,
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
];
?>
?>
<script type="text/javascript">
window.woonoowCustomer = <?php echo wp_json_encode($config); ?>;
</script>
@@ -248,7 +260,7 @@ class Assets {
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
if ($is_dev) {
$dev_server = 'https://woonoow.local:5174';
?>
?>
<script type="module">
import RefreshRuntime from '<?php echo $dev_server; ?>/@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
@@ -258,14 +270,15 @@ class Assets {
</script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
<?php
<?php
}
}
/**
* Check if we should load customer-spa assets
*/
private static function should_load_assets() {
private static function should_load_assets()
{
global $post;
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
@@ -377,7 +390,8 @@ class Assets {
/**
* Check if current page is the designated SPA page
*/
private static function is_spa_page() {
private static function is_spa_page()
{
global $post;
if (!$post) {
return false;
@@ -399,7 +413,8 @@ class Assets {
* Check if current request is a frontpage SPA route
* Used to detect SPA routes by URL when SPA page is set as frontpage
*/
private static function is_frontpage_spa_route() {
private static function is_frontpage_spa_route()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
@@ -443,7 +458,8 @@ class Assets {
/**
* Dequeue conflicting scripts when SPA is active
*/
public static function dequeue_conflicting_scripts() {
public static function dequeue_conflicting_scripts()
{
if (!self::should_load_assets()) {
return;
}

View File

@@ -781,4 +781,21 @@ class LicenseManager
$license_id
), ARRAY_A);
}
/**
* Get licenses by order ID
*
* @param int $order_id
* @return array
*/
public static function get_licenses_by_order($order_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE order_id = %d ORDER BY created_at ASC",
$order_id
), ARRAY_A);
}
}

View File

@@ -11,7 +11,7 @@ class TemplateRegistry
*/
public static function get_templates()
{
return [
return apply_filters('woonoow_page_templates', [
[
'id' => 'blank',
'label' => 'Blank Page',
@@ -40,7 +40,7 @@ class TemplateRegistry
'icon' => 'mail',
'sections' => self::get_contact_structure()
]
];
]);
}
/**