fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout
This commit is contained in:
212
.agent/reports/newsletter-module-audit-2026-02-01.md
Normal file
212
.agent/reports/newsletter-module-audit-2026-02-01.md
Normal 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)
|
||||||
@@ -53,6 +53,8 @@ import { __ } from '@/lib/i18n';
|
|||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||||
|
|
||||||
|
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
|
||||||
|
|
||||||
function useFullscreen() {
|
function useFullscreen() {
|
||||||
const [on, setOn] = useState<boolean>(() => {
|
const [on, setOn] = useState<boolean>(() => {
|
||||||
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
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 SettingsShipping from '@/routes/Settings/Shipping';
|
||||||
import SettingsTax from '@/routes/Settings/Tax';
|
import SettingsTax from '@/routes/Settings/Tax';
|
||||||
import SettingsCustomers from '@/routes/Settings/Customers';
|
import SettingsCustomers from '@/routes/Settings/Customers';
|
||||||
|
import SettingsSecurity from '@/routes/Settings/Security';
|
||||||
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
||||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||||
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
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 AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||||
import AppearancePages from '@/routes/Appearance/Pages';
|
import AppearancePages from '@/routes/Appearance/Pages';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
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 CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
import Help from '@/routes/Help';
|
import Help from '@/routes/Help';
|
||||||
@@ -620,6 +625,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||||
<Route path="/settings/tax" element={<SettingsTax />} />
|
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||||
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
<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/taxes" element={<Navigate to="/settings/tax" replace />} />
|
||||||
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
||||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||||
@@ -653,8 +659,17 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
|
||||||
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
<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 */}
|
{/* Help - Main menu route with no submenu */}
|
||||||
<Route path="/help" element={<Help />} />
|
<Route path="/help" element={<Help />} />
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function BlockRenderer({
|
|||||||
return (
|
return (
|
||||||
<div style={cardStyles[block.cardType]}>
|
<div style={cardStyles[block.cardType]}>
|
||||||
<div
|
<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' } : {}}
|
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
/>
|
/>
|
||||||
@@ -97,17 +97,17 @@ export function BlockRenderer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'button': {
|
case 'button': {
|
||||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
// Different styles based on button type
|
||||||
? {
|
let buttonStyle: React.CSSProperties;
|
||||||
display: 'inline-block',
|
|
||||||
background: 'var(--wn-primary, #7f54b3)',
|
if (block.style === 'link') {
|
||||||
color: '#fff',
|
// Plain link style - just underlined text
|
||||||
padding: '14px 28px',
|
buttonStyle = {
|
||||||
borderRadius: '6px',
|
color: 'var(--wn-primary, #7f54b3)',
|
||||||
textDecoration: 'none',
|
textDecoration: 'underline',
|
||||||
fontWeight: 600,
|
};
|
||||||
}
|
} else if (block.style === 'outline') {
|
||||||
: {
|
buttonStyle = {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'var(--wn-secondary, #7f54b3)',
|
color: 'var(--wn-secondary, #7f54b3)',
|
||||||
@@ -117,18 +117,33 @@ export function BlockRenderer({
|
|||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 600,
|
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 = {
|
const containerStyle: React.CSSProperties = {
|
||||||
textAlign: block.align || 'center',
|
textAlign: block.align || 'center',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (block.widthMode === 'full') {
|
// Width modes don't apply to plain links
|
||||||
buttonStyle.display = 'block';
|
if (block.style !== 'link') {
|
||||||
buttonStyle.width = '100%';
|
if (block.widthMode === 'full') {
|
||||||
buttonStyle.textAlign = 'center';
|
buttonStyle.display = 'block';
|
||||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
buttonStyle.width = '100%';
|
||||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
buttonStyle.textAlign = 'center';
|
||||||
buttonStyle.width = '100%';
|
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||||
|
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||||
|
buttonStyle.width = '100%';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (block: EmailBlock) => {
|
const openEditDialog = (block: EmailBlock) => {
|
||||||
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
|
||||||
setEditingBlockId(block.id);
|
setEditingBlockId(block.id);
|
||||||
|
|
||||||
if (block.type === 'card') {
|
if (block.type === 'card') {
|
||||||
@@ -123,7 +122,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
|||||||
setEditingAlign(block.align);
|
setEditingAlign(block.align);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
|
|||||||
|
|
||||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
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';
|
export type ContentWidth = 'fit' | 'full' | 'custom';
|
||||||
|
|
||||||
|
|||||||
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal file
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ function fmt(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DateRange({ value, onChange }: Props) {
|
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 [start, setStart] = useState<string | undefined>(value?.date_start);
|
||||||
const [end, setEnd] = useState<string | undefined>(value?.date_end);
|
const [end, setEnd] = useState<string | undefined>(value?.date_end);
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ export default function DateRange({ value, onChange }: Props) {
|
|||||||
return {
|
return {
|
||||||
today: { date_start: todayStr, date_end: todayStr },
|
today: { date_start: todayStr, date_end: todayStr },
|
||||||
last7: { date_start: fmt(last7), date_end: todayStr },
|
last7: { date_start: fmt(last7), date_end: todayStr },
|
||||||
last30:{ date_start: fmt(last30), date_end: todayStr },
|
last30: { date_start: fmt(last30), date_end: todayStr },
|
||||||
custom:{ date_start: start, date_end: end },
|
custom: { date_start: start, date_end: end },
|
||||||
};
|
};
|
||||||
}, [start, end]);
|
}, [start, end]);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export default function DateRange({ value, onChange }: Props) {
|
|||||||
if (preset === "custom") {
|
if (preset === "custom") {
|
||||||
onChange?.({ date_start: start, date_end: end, preset });
|
onChange?.({ date_start: start, date_end: end, preset });
|
||||||
} else {
|
} else {
|
||||||
const pr = (presets as any)[preset] || presets.last7;
|
const pr = (presets as any)[preset] || presets.last30;
|
||||||
onChange?.({ ...pr, preset });
|
onChange?.({ ...pr, preset });
|
||||||
setStart(pr.date_start);
|
setStart(pr.date_start);
|
||||||
setEnd(pr.date_end);
|
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">
|
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
||||||
<Select value={preset} onValueChange={(v) => setPreset(v)}>
|
<Select value={preset} onValueChange={(v) => setPreset(v)}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder={__("Last 7 days")} />
|
<SelectValue placeholder={__("Last 30 days")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" className="z-[1000]">
|
<SelectContent position="popper" className="z-[1000]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export function RichTextEditor({
|
|||||||
const currentContent = editor.getHTML();
|
const currentContent = editor.getHTML();
|
||||||
// Only update if content is different (avoid infinite loops)
|
// Only update if content is different (avoid infinite loops)
|
||||||
if (content !== currentContent) {
|
if (content !== currentContent) {
|
||||||
console.log('RichTextEditor: Updating content', { content, currentContent });
|
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ export function RichTextEditor({
|
|||||||
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
||||||
const [buttonText, setButtonText] = useState('Click Here');
|
const [buttonText, setButtonText] = useState('Click Here');
|
||||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
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 [isEditingButton, setIsEditingButton] = useState(false);
|
||||||
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -388,12 +387,12 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Customer Variables */}
|
{/* Subscriber/Customer Variables */}
|
||||||
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
|
{variables.some(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('_name') && !v.startsWith('order') && !v.startsWith('site') && !v.startsWith('store'))) && (
|
||||||
<div>
|
<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">
|
<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
|
<button
|
||||||
key={variable}
|
key={variable}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -425,11 +424,11 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Store/Site Variables */}
|
{/* 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>
|
||||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<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
|
<button
|
||||||
key={variable}
|
key={variable}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -501,13 +500,14 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
<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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||||
|
<SelectItem value="link">{__('Plain Link')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface ButtonOptions {
|
|||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
button: {
|
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 }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const { text, href, style } = HTMLAttributes;
|
const { text, href, style } = HTMLAttributes;
|
||||||
|
|
||||||
// Simple link styling - no fancy button appearance in editor
|
// Different styling based on button style
|
||||||
// The actual button styling happens in email rendering (EmailRenderer.php)
|
let inlineStyle: string;
|
||||||
// In editor, just show as a styled link (differentiable from regular links)
|
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 [
|
return [
|
||||||
'a',
|
'a',
|
||||||
mergeAttributes(this.options.HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, {
|
||||||
href,
|
href,
|
||||||
class: 'button-node',
|
class: style === 'link' ? 'link-node' : '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;',
|
style: inlineStyle,
|
||||||
'data-button': '',
|
'data-button': '',
|
||||||
'data-text': text,
|
'data-text': text,
|
||||||
'data-href': href,
|
'data-href': href,
|
||||||
'data-style': style,
|
'data-style': style,
|
||||||
title: `Button: ${text} → ${href}`,
|
title: style === 'link' ? `Link: ${text}` : `Button: ${text} → ${href}`,
|
||||||
}),
|
}),
|
||||||
text,
|
text,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Import design tokens for UI sizing and control defaults */
|
/* Import design tokens for UI sizing and control defaults */
|
||||||
@import './components/ui/tokens.css';
|
@import './components/ui/tokens.css';
|
||||||
|
|
||||||
|
/* stylelint-disable at-rule-no-unknown */
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -82,12 +83,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Override WordPress common.css focus/active styles */
|
/* 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:focus,
|
||||||
a:active {
|
a:active {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -258,12 +262,8 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
|
/* Optional page presets (opt-in by adding the class to a wrapper before printing)
|
||||||
.print-a4 {}
|
These classes are used dynamically and styled via @media print rules below */
|
||||||
|
|
||||||
.print-letter {}
|
|
||||||
|
|
||||||
.print-4x6 {}
|
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-letter {}
|
/* Letter format - extend as needed */
|
||||||
|
|
||||||
/* Thermal label (4x6in) with minimal margins */
|
/* Thermal label (4x6in) with minimal margins */
|
||||||
.print-4x6 {
|
.print-4x6 {
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
|
|
||||||
let markdown = html;
|
let markdown = html;
|
||||||
|
|
||||||
// Headings
|
// Store aligned headings for preservation
|
||||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
const alignedHeadings: { [key: string]: string } = {};
|
||||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
let headingIndex = 0;
|
||||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
|
||||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
// 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
|
// Bold
|
||||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||||
@@ -100,6 +116,11 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
markdown = markdown.replace(placeholder, html);
|
markdown = markdown.replace(placeholder, html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore aligned headings
|
||||||
|
Object.entries(alignedHeadings).forEach(([placeholder, html]) => {
|
||||||
|
markdown = markdown.replace(placeholder, html);
|
||||||
|
});
|
||||||
|
|
||||||
// Clean up excessive newlines
|
// Clean up excessive newlines
|
||||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
|||||||
@@ -96,15 +96,22 @@ export function markdownToHtml(markdown: string): string {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Parse [button:style](url)Text[/button] (new syntax)
|
// 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) => {
|
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';
|
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)
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
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';
|
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
|
// Parse remaining markdown
|
||||||
@@ -153,8 +160,11 @@ export function parseMarkdownBasics(text: string): string {
|
|||||||
// Allow whitespace and newlines between parts
|
// Allow whitespace and newlines between parts
|
||||||
// Include data-button attributes for TipTap recognition
|
// Include data-button attributes for TipTap recognition
|
||||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
|
||||||
const trimmedText = text.trim();
|
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>`;
|
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { MediaUploader } from '@/components/MediaUploader';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface SocialLink {
|
interface SocialLink {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,18 +38,37 @@ interface ContactData {
|
|||||||
show_address: boolean;
|
show_address: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaymentMethod {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppearanceFooter() {
|
export default function AppearanceFooter() {
|
||||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [columns, setColumns] = useState('4');
|
const [columns, setColumns] = useState('4');
|
||||||
const [style, setStyle] = useState('detailed');
|
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({
|
const [elements, setElements] = useState({
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
social: true,
|
social: true,
|
||||||
payment: true,
|
|
||||||
copyright: true,
|
|
||||||
menu: true,
|
menu: true,
|
||||||
contact: true,
|
contact: true,
|
||||||
});
|
});
|
||||||
@@ -62,19 +83,16 @@ export default function AppearanceFooter() {
|
|||||||
show_phone: true,
|
show_phone: true,
|
||||||
show_address: true,
|
show_address: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultSections: FooterSection[] = [
|
const defaultSections: FooterSection[] = [
|
||||||
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
||||||
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
||||||
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
||||||
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only keeping newsletter_description, titles are now managed per column
|
||||||
const [labels, setLabels] = useState({
|
const [labels, setLabels] = useState({
|
||||||
contact_title: 'Contact',
|
|
||||||
menu_title: 'Quick Links',
|
|
||||||
social_title: 'Follow Us',
|
|
||||||
newsletter_title: 'Newsletter',
|
|
||||||
newsletter_description: 'Subscribe to get updates',
|
newsletter_description: 'Subscribe to get updates',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,12 +101,34 @@ export default function AppearanceFooter() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/appearance/settings');
|
const response = await api.get('/appearance/settings');
|
||||||
const footer = response.data?.footer;
|
const footer = response.data?.footer;
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
if (footer.columns) setColumns(footer.columns);
|
if (footer.columns) setColumns(footer.columns);
|
||||||
if (footer.style) setStyle(footer.style);
|
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.social_links) setSocialLinks(footer.social_links);
|
||||||
if (footer.sections && footer.sections.length > 0) {
|
if (footer.sections && footer.sections.length > 0) {
|
||||||
setSections(footer.sections);
|
setSections(footer.sections);
|
||||||
@@ -96,11 +136,15 @@ export default function AppearanceFooter() {
|
|||||||
setSections(defaultSections);
|
setSections(defaultSections);
|
||||||
}
|
}
|
||||||
if (footer.contact_data) setContactData(footer.contact_data);
|
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 {
|
} else {
|
||||||
setSections(defaultSections);
|
setSections(defaultSections);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch store identity data
|
// Fetch store identity data
|
||||||
try {
|
try {
|
||||||
const identityResponse = await api.get('/settings/store-identity');
|
const identityResponse = await api.get('/settings/store-identity');
|
||||||
@@ -122,7 +166,7 @@ export default function AppearanceFooter() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -152,7 +196,7 @@ export default function AppearanceFooter() {
|
|||||||
...sections,
|
...sections,
|
||||||
{
|
{
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
title: 'New Section',
|
title: 'New Column',
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
content: '',
|
content: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -168,12 +212,34 @@ export default function AppearanceFooter() {
|
|||||||
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
|
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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
columns,
|
columns,
|
||||||
style,
|
style,
|
||||||
copyrightText,
|
copyright,
|
||||||
|
payment,
|
||||||
elements,
|
elements,
|
||||||
socialLinks,
|
socialLinks,
|
||||||
sections,
|
sections,
|
||||||
@@ -227,177 +293,127 @@ export default function AppearanceFooter() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Content & Contact */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Section Labels"
|
title="Content & Contact"
|
||||||
description="Customize footer section headings and text"
|
description="Manage footer content and contact details"
|
||||||
>
|
>
|
||||||
<SettingsSection label="Contact Title" htmlFor="contact-title">
|
<div className="space-y-6">
|
||||||
<Input
|
<div>
|
||||||
id="contact-title"
|
<h3 className="text-lg font-medium mb-4">Contact Information</h3>
|
||||||
value={labels.contact_title}
|
<SettingsSection label="Email" htmlFor="contact-email">
|
||||||
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
|
<Input
|
||||||
placeholder="Contact"
|
id="contact-email"
|
||||||
/>
|
type="email"
|
||||||
</SettingsSection>
|
value={contactData.email}
|
||||||
|
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
||||||
<SettingsSection label="Menu Title" htmlFor="menu-title">
|
placeholder="info@store.com"
|
||||||
<Input
|
/>
|
||||||
id="menu-title"
|
<div className="flex items-center gap-2 mt-2">
|
||||||
value={labels.menu_title}
|
<Switch
|
||||||
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
|
checked={contactData.show_email}
|
||||||
placeholder="Quick Links"
|
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||||
placeholder="URL"
|
</div>
|
||||||
value={link.url}
|
</SettingsSection>
|
||||||
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
|
||||||
className="flex-1"
|
<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
|
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||||
onClick={() => removeSocialLink(link.id)}
|
</div>
|
||||||
variant="ghost"
|
</SettingsSection>
|
||||||
size="icon"
|
|
||||||
>
|
<SettingsSection label="Address" htmlFor="contact-address">
|
||||||
<X className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Custom Sections Builder */}
|
{/* Custom Columns (was Custom Sections) */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Custom Sections"
|
title="Custom Columns"
|
||||||
description="Build custom footer sections with flexible content"
|
description="Build footer columns with flexible content"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Footer Sections</Label>
|
<Label>Footer Columns</Label>
|
||||||
<Button onClick={addSection} variant="outline" size="sm">
|
<Button onClick={addSection} variant="outline" size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Section
|
Add Column
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,7 +421,7 @@ export default function AppearanceFooter() {
|
|||||||
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Section Title"
|
placeholder="Column Title"
|
||||||
value={section.title}
|
value={section.title}
|
||||||
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
||||||
className="flex-1 mr-2"
|
className="flex-1 mr-2"
|
||||||
@@ -458,11 +474,122 @@ export default function AppearanceFooter() {
|
|||||||
|
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</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>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function AppearanceGeneral() {
|
|||||||
const [customHeading, setCustomHeading] = useState('');
|
const [customHeading, setCustomHeading] = useState('');
|
||||||
const [customBody, setCustomBody] = useState('');
|
const [customBody, setCustomBody] = useState('');
|
||||||
const [fontScale, setFontScale] = useState([1.0]);
|
const [fontScale, setFontScale] = useState([1.0]);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
|
||||||
|
|
||||||
const fontPairs = {
|
const fontPairs = {
|
||||||
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
||||||
@@ -65,6 +66,9 @@ export default function AppearanceGeneral() {
|
|||||||
setCustomBody(general.typography.custom?.body || '');
|
setCustomBody(general.typography.custom?.body || '');
|
||||||
setFontScale([general.typography.scale || 1.0]);
|
setFontScale([general.typography.scale || 1.0]);
|
||||||
}
|
}
|
||||||
|
if (general.container_width) {
|
||||||
|
setContainerWidth(general.container_width);
|
||||||
|
}
|
||||||
if (general.colors) {
|
if (general.colors) {
|
||||||
setColors({
|
setColors({
|
||||||
primary: general.colors.primary || '#1a1a1a',
|
primary: general.colors.primary || '#1a1a1a',
|
||||||
@@ -110,6 +114,7 @@ export default function AppearanceGeneral() {
|
|||||||
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
||||||
scale: fontScale[0],
|
scale: fontScale[0],
|
||||||
},
|
},
|
||||||
|
containerWidth,
|
||||||
colors,
|
colors,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,6 +212,36 @@ export default function AppearanceGeneral() {
|
|||||||
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
||||||
</p>
|
</p>
|
||||||
</SettingsSection>
|
</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>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ interface CanvasRendererProps {
|
|||||||
onDuplicateSection: (id: string) => void;
|
onDuplicateSection: (id: string) => void;
|
||||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||||
onReorderSections: (sections: Section[]) => void;
|
onReorderSections: (sections: Section[]) => void;
|
||||||
|
|
||||||
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
|
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_TYPES = [
|
const SECTION_TYPES = [
|
||||||
@@ -84,7 +86,9 @@ export function CanvasRenderer({
|
|||||||
onDuplicateSection,
|
onDuplicateSection,
|
||||||
onMoveSection,
|
onMoveSection,
|
||||||
onReorderSections,
|
onReorderSections,
|
||||||
|
|
||||||
onDeviceModeChange,
|
onDeviceModeChange,
|
||||||
|
containerWidth = 'default',
|
||||||
}: CanvasRendererProps) {
|
}: CanvasRendererProps) {
|
||||||
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -149,7 +153,9 @@ export function CanvasRenderer({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
|
'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 ? (
|
{sections.length === 0 ? (
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function CanvasSection({
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent className="z-[60]">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ interface PageItem {
|
|||||||
title: string;
|
title: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
isSpaLanding?: boolean;
|
isSpaLanding?: boolean;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InspectorPanelProps {
|
interface InspectorPanelProps {
|
||||||
@@ -69,6 +71,7 @@ interface InspectorPanelProps {
|
|||||||
onSetAsSpaLanding?: () => void;
|
onSetAsSpaLanding?: () => void;
|
||||||
onUnsetSpaLanding?: () => void;
|
onUnsetSpaLanding?: () => void;
|
||||||
onDeletePage?: () => void;
|
onDeletePage?: () => void;
|
||||||
|
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section field configurations
|
// Section field configurations
|
||||||
@@ -191,6 +194,7 @@ export function InspectorPanel({
|
|||||||
onSetAsSpaLanding,
|
onSetAsSpaLanding,
|
||||||
onUnsetSpaLanding,
|
onUnsetSpaLanding,
|
||||||
onDeletePage,
|
onDeletePage,
|
||||||
|
onContainerWidthChange,
|
||||||
}: InspectorPanelProps) {
|
}: InspectorPanelProps) {
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +277,31 @@ export function InspectorPanel({
|
|||||||
</div>
|
</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 */}
|
{/* Danger Zone */}
|
||||||
{!isTemplate && page && onDeletePage && (
|
{!isTemplate && page && onDeletePage && (
|
||||||
<div className="pt-2 border-t mt-2">
|
<div className="pt-2 border-t mt-2">
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ export default function AppearancePages() {
|
|||||||
enabled: !!currentPage,
|
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
|
// Update store when page data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageData?.structure?.sections) {
|
if (pageData?.structure?.sections) {
|
||||||
@@ -106,6 +112,10 @@ export default function AppearancePages() {
|
|||||||
if (pageData?.is_front_page !== undefined && currentPage) {
|
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||||
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||||
|
|
||||||
@@ -296,7 +306,13 @@ export default function AppearancePages() {
|
|||||||
onDuplicateSection={duplicateSection}
|
onDuplicateSection={duplicateSection}
|
||||||
onMoveSection={moveSection}
|
onMoveSection={moveSection}
|
||||||
onReorderSections={reorderSections}
|
onReorderSections={reorderSections}
|
||||||
|
|
||||||
onDeviceModeChange={setDeviceMode}
|
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">
|
<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()}
|
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||||
onDeletePage={handleDeletePage}
|
onDeletePage={handleDeletePage}
|
||||||
|
onContainerWidthChange={(width) => {
|
||||||
|
if (currentPage) {
|
||||||
|
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||||
|
markAsSaved(); // Mark as changed so save button enables
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface PageItem {
|
|||||||
url?: string;
|
url?: string;
|
||||||
isFrontPage?: boolean;
|
isFrontPage?: boolean;
|
||||||
isSpaLanding?: boolean;
|
isSpaLanding?: boolean;
|
||||||
|
containerWidth?: 'boxed' | 'fullwidth';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageEditorState {
|
interface PageEditorState {
|
||||||
@@ -422,7 +423,10 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ sections })
|
body: JSON.stringify({
|
||||||
|
sections,
|
||||||
|
container_width: currentPage.containerWidth
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -11,27 +11,34 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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';
|
import { formatMoney } from '@/lib/currency';
|
||||||
|
|
||||||
export default function CustomersIndex() {
|
export default function CustomersIndex() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
||||||
useFABConfig('none');
|
useFABConfig('none');
|
||||||
|
|
||||||
// Fetch customers
|
// Fetch customers
|
||||||
const customersQuery = useQuery({
|
const customersQuery = useQuery({
|
||||||
queryKey: ['customers', page, search],
|
queryKey: ['customers', page, search],
|
||||||
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete mutation
|
// Delete mutation
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
@@ -46,14 +53,14 @@ export default function CustomersIndex() {
|
|||||||
showErrorToast(error);
|
showErrorToast(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const toggleSelection = (id: number) => {
|
const toggleSelection = (id: number) => {
|
||||||
setSelectedIds(prev =>
|
setSelectedIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedIds.length === customers.length) {
|
if (selectedIds.length === customers.length) {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -61,21 +68,21 @@ export default function CustomersIndex() {
|
|||||||
setSelectedIds(customers.map(c => c.id));
|
setSelectedIds(customers.map(c => c.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedIds.length === 0) return;
|
if (selectedIds.length === 0) return;
|
||||||
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
||||||
deleteMutation.mutate(selectedIds);
|
deleteMutation.mutate(selectedIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const customers = customersQuery.data?.data || [];
|
const customers = customersQuery.data?.data || [];
|
||||||
const pagination = customersQuery.data?.pagination;
|
const pagination = customersQuery.data?.pagination;
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (customersQuery.isLoading) {
|
if (customersQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +92,7 @@ export default function CustomersIndex() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
if (customersQuery.isError) {
|
if (customersQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +103,7 @@ export default function CustomersIndex() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Mobile: Search */}
|
{/* Mobile: Search */}
|
||||||
@@ -130,7 +137,7 @@ export default function CustomersIndex() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={customersQuery.isFetching}
|
disabled={customersQuery.isFetching}
|
||||||
@@ -140,7 +147,7 @@ export default function CustomersIndex() {
|
|||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Search */}
|
{/* Right: Search */}
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -158,7 +165,7 @@ export default function CustomersIndex() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
<div className="hidden md:block rounded-lg border overflow-hidden">
|
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@@ -212,9 +219,8 @@ export default function CustomersIndex() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
|
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
<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' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
}`}>
|
||||||
}`}>
|
|
||||||
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -225,14 +231,37 @@ export default function CustomersIndex() {
|
|||||||
<td className="p-3 text-sm text-muted-foreground">
|
<td className="p-3 text-sm text-muted-foreground">
|
||||||
{new Date(customer.registered).toLocaleDateString()}
|
{new Date(customer.registered).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3 text-center">
|
||||||
<button
|
<DropdownMenu>
|
||||||
onClick={() => navigate(`/customers/${customer.id}/edit`)}
|
<DropdownMenuTrigger asChild>
|
||||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
>
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
<Edit className="w-4 h-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
{__('Edit')}
|
</Button>
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -240,7 +269,7 @@ export default function CustomersIndex() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: Cards */}
|
{/* Mobile: Cards */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3">
|
||||||
{customers.length === 0 ? (
|
{customers.length === 0 ? (
|
||||||
@@ -257,7 +286,7 @@ export default function CustomersIndex() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -302,7 +331,7 @@ export default function CustomersIndex() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination && pagination.total_pages > 1 && (
|
{pagination && pagination.total_pages > 1 && (
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
|
||||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Send,
|
Send,
|
||||||
@@ -21,6 +20,7 @@ import { __ } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -181,22 +181,25 @@ export default function CampaignEdit() {
|
|||||||
|
|
||||||
if (!isNew && isLoading) {
|
if (!isNew && isLoading) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout title={__('Loading...')} description="">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="flex items-center justify-center py-12">
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
</div>
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<div className="space-y-6">
|
||||||
title={isNew ? __('New Campaign') : __('Edit Campaign')}
|
{/* Header */}
|
||||||
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
<div className="flex items-center justify-between">
|
||||||
>
|
<div>
|
||||||
{/* Back button */}
|
<h2 className="text-lg font-medium">
|
||||||
<div className="mb-6">
|
{isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||||
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
|
</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" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{__('Back to Campaigns')}
|
{__('Back to Campaigns')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -245,15 +248,14 @@ export default function CampaignEdit() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="content">{__('Email Content')}</Label>
|
<Label htmlFor="content">{__('Email Content')}</Label>
|
||||||
<Textarea
|
<RichTextEditor
|
||||||
id="content"
|
content={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')}
|
onChange={setContent}
|
||||||
value={content}
|
placeholder={__('Write your newsletter content here...')}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
variables={['site_name', 'current_date', 'subscriber_email', 'current_year', 'store_name', 'unsubscribe_url']}
|
||||||
className="min-h-[300px] font-mono text-sm"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,11 +325,13 @@ export default function CampaignEdit() {
|
|||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
||||||
|
<DialogDescription>{__('Preview how your email will look to subscribers')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="border rounded-lg bg-white p-4">
|
<div className="border rounded-lg overflow-hidden bg-gray-100">
|
||||||
<div
|
<iframe
|
||||||
className="prose max-w-none"
|
srcDoc={previewHtml}
|
||||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
className="w-full min-h-[600px] bg-white"
|
||||||
|
title={__('Email Preview')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -338,8 +342,9 @@ export default function CampaignEdit() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||||
|
<DialogDescription>{__('Send a test email to verify your campaign before sending to all subscribers')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -395,6 +400,6 @@ export default function CampaignEdit() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</SettingsLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Send,
|
Send,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit,
|
Edit,
|
||||||
@@ -44,6 +42,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
|
|
||||||
interface Campaign {
|
interface Campaign {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -131,14 +130,18 @@ export default function CampaignsList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<SettingsCard
|
||||||
title={__('Campaigns')}
|
title={__('Campaigns')}
|
||||||
description={__('Create and send email campaigns to your newsletter subscribers')}
|
description={__('Create and send email campaigns to your newsletter subscribers')}
|
||||||
>
|
>
|
||||||
<SettingsCard
|
<div className="space-y-4">
|
||||||
title={__('All Campaigns')}
|
{/* Header with count */}
|
||||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
<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">
|
<div className="space-y-4">
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<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"
|
className="!pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
{/* New Campaign button removed - available in sidebar */}
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{__('New Campaign')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaigns Table */}
|
{/* Campaigns Table */}
|
||||||
@@ -168,7 +168,7 @@ export default function CampaignsList() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||||
<p>{__('No campaigns yet')}</p>
|
<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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{__('Create your first campaign')}
|
{__('Create your first campaign')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,7 +191,7 @@ export default function CampaignsList() {
|
|||||||
{filteredCampaigns.map((campaign) => {
|
{filteredCampaigns.map((campaign) => {
|
||||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={campaign.id}>
|
<TableRow key={campaign.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -225,11 +225,11 @@ export default function CampaignsList() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
{campaign.sent_at
|
{campaign.sent_at
|
||||||
? formatDate(campaign.sent_at)
|
? formatDate(campaign.sent_at)
|
||||||
: campaign.scheduled_at
|
: campaign.scheduled_at
|
||||||
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
||||||
: formatDate(campaign.created_at)
|
: formatDate(campaign.created_at)
|
||||||
}
|
}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
@@ -240,7 +240,7 @@ export default function CampaignsList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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 className="mr-2 h-4 w-4" />
|
||||||
{__('Edit')}
|
{__('Edit')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -248,7 +248,7 @@ export default function CampaignsList() {
|
|||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
{__('Duplicate')}
|
{__('Duplicate')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setDeleteId(campaign.id)}
|
onClick={() => setDeleteId(campaign.id)}
|
||||||
className="text-red-600"
|
className="text-red-600"
|
||||||
>
|
>
|
||||||
@@ -266,7 +266,7 @@ export default function CampaignsList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
@@ -288,6 +288,6 @@ export default function CampaignsList() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</SettingsLayout>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ import { ErrorCard } from '@/components/ErrorCard';
|
|||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
||||||
import { CouponCard } from './components/CouponCard';
|
import { CouponCard } from './components/CouponCard';
|
||||||
@@ -34,11 +42,11 @@ export default function CouponsIndex() {
|
|||||||
// Fetch coupons
|
// Fetch coupons
|
||||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
queryKey: ['coupons', page, search, discountType],
|
queryKey: ['coupons', page, search, discountType],
|
||||||
queryFn: () => CouponsApi.list({
|
queryFn: () => CouponsApi.list({
|
||||||
page,
|
page,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
search,
|
search,
|
||||||
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ export default function CouponsIndex() {
|
|||||||
// Bulk delete
|
// Bulk delete
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
||||||
|
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
await deleteMutation.mutateAsync(id);
|
await deleteMutation.mutateAsync(id);
|
||||||
}
|
}
|
||||||
@@ -149,7 +157,7 @@ export default function CouponsIndex() {
|
|||||||
{/* Desktop Toolbar */}
|
{/* Desktop Toolbar */}
|
||||||
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
||||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||||
|
|
||||||
{/* Left: Bulk Actions */}
|
{/* Left: Bulk Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Delete - Show only when items selected */}
|
{/* Delete - Show only when items selected */}
|
||||||
@@ -173,7 +181,7 @@ export default function CouponsIndex() {
|
|||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
{__('Refresh')}
|
{__('Refresh')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* New Coupon - Desktop only */}
|
{/* New Coupon - Desktop only */}
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
||||||
@@ -264,7 +272,7 @@ export default function CouponsIndex() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
||||||
{coupon.code}
|
{coupon.code}
|
||||||
</Link>
|
</Link>
|
||||||
{coupon.description && (
|
{coupon.description && (
|
||||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||||
@@ -289,13 +297,32 @@ export default function CouponsIndex() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
<button
|
<DropdownMenu>
|
||||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
>
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
<Edit className="w-4 h-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
{__('Edit')}
|
</Button>
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -131,138 +130,131 @@ export default function Campaigns() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsCard
|
{/* Actions Bar */}
|
||||||
title={__('All Campaigns')}
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
<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" />
|
||||||
<div className="space-y-4">
|
<Input
|
||||||
{/* Actions Bar */}
|
placeholder={__('Search campaigns...')}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
value={searchQuery}
|
||||||
<div className="relative flex-1 max-w-sm">
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
className="!pl-9"
|
||||||
<Input
|
/>
|
||||||
placeholder={__('Search campaigns...')}
|
</div>
|
||||||
value={searchQuery}
|
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
className="!pl-9"
|
{__('New Campaign')}
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{__('New Campaign')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Campaigns Table */}
|
{/* Campaigns Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{__('Loading campaigns...')}
|
{__('Loading campaigns...')}
|
||||||
</div>
|
</div>
|
||||||
) : filteredCampaigns.length === 0 ? (
|
) : filteredCampaigns.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
{searchQuery ? __('No campaigns found matching your search') : (
|
{searchQuery ? __('No campaigns found matching your search') : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||||
<p>{__('No campaigns yet')}</p>
|
<p>{__('No campaigns yet')}</p>
|
||||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{__('Create your first campaign')}
|
{__('Create your first campaign')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{__('Title')}</TableHead>
|
|
||||||
<TableHead>{__('Status')}</TableHead>
|
|
||||||
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
|
||||||
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
|
||||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredCampaigns.map((campaign) => {
|
|
||||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
|
||||||
const StatusIcon = status.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={campaign.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{campaign.title}</div>
|
|
||||||
{campaign.subject && (
|
|
||||||
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
|
||||||
{campaign.subject}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
|
||||||
<StatusIcon className="h-3 w-3" />
|
|
||||||
{__(status.label)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden md:table-cell">
|
|
||||||
{campaign.status === 'sent' ? (
|
|
||||||
<span>
|
|
||||||
{campaign.sent_count}/{campaign.recipient_count}
|
|
||||||
{campaign.failed_count > 0 && (
|
|
||||||
<span className="text-red-500 ml-1">
|
|
||||||
({campaign.failed_count} {__('failed')})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
|
||||||
{campaign.sent_at
|
|
||||||
? formatDate(campaign.sent_at)
|
|
||||||
: campaign.scheduled_at
|
|
||||||
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
|
|
||||||
: formatDate(campaign.created_at)
|
|
||||||
}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
{__('Edit')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
{__('Duplicate')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setDeleteId(campaign.id)}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
{__('Delete')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { toast } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -16,6 +17,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
export default function Subscribers() {
|
export default function Subscribers() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -66,91 +73,147 @@ export default function Subscribers() {
|
|||||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SettingsCard
|
{/* Actions Bar */}
|
||||||
title={__('Subscribers List')}
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
|
<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" />
|
||||||
<div className="space-y-4">
|
<Input
|
||||||
{/* Actions Bar */}
|
placeholder={__('Filter subscribers...')}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
value={searchQuery}
|
||||||
<div className="relative flex-1 max-w-sm">
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
className="!pl-9"
|
||||||
<Input
|
/>
|
||||||
placeholder={__('Filter subscribers...')}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="!pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{__('Export CSV')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subscribers Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{__('Loading subscribers...')}
|
|
||||||
</div>
|
|
||||||
) : filteredSubscribers.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="border rounded-lg">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{__('Email')}</TableHead>
|
|
||||||
<TableHead>{__('Status')}</TableHead>
|
|
||||||
<TableHead>{__('Subscribed Date')}</TableHead>
|
|
||||||
<TableHead>{__('WP User')}</TableHead>
|
|
||||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredSubscribers.map((subscriber: any) => (
|
|
||||||
<TableRow key={subscriber.email}>
|
|
||||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
|
||||||
{subscriber.status || __('Active')}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{subscriber.subscribed_at
|
|
||||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{subscriber.user_id ? (
|
|
||||||
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
|
||||||
disabled={deleteSubscriber.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 */}
|
{/* Email Template Settings */}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
@@ -187,6 +250,6 @@ export default function Subscribers() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,24 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Mail } from 'lucide-react';
|
import { Mail, Users, Send } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
import Subscribers from './Subscribers';
|
import { cn } from '@/lib/utils'; // Assuming cn exists, widely used in ShadCN
|
||||||
import Campaigns from './Campaigns';
|
|
||||||
|
|
||||||
export default function Newsletter() {
|
export default function NewsletterLayout() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [activeTab, setActiveTab] = useState('subscribers');
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { isEnabled } = useModules();
|
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
|
// Show disabled state if newsletter module is off
|
||||||
if (!isEnabled('newsletter')) {
|
if (!isEnabled('newsletter')) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<div className="w-full space-y-6">
|
||||||
title={__('Newsletter')}
|
<div>
|
||||||
description={__('Newsletter module is disabled')}
|
<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">
|
<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" />
|
<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>
|
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
|
||||||
@@ -46,29 +29,78 @@ export default function Newsletter() {
|
|||||||
{__('Go to Module Settings')}
|
{__('Go to Module Settings')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<SettingsLayout
|
<div className="w-full space-y-6">
|
||||||
title={__('Newsletter')}
|
<div>
|
||||||
description={__('Manage subscribers and send email campaigns')}
|
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
|
||||||
>
|
<p className="text-muted-foreground mt-2">{__('Manage subscribers and send email campaigns')}</p>
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
</div>
|
||||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
|
||||||
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="subscribers" className="space-y-4 mt-6">
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
<Subscribers />
|
{/* Sidebar Navigation */}
|
||||||
</TabsContent>
|
<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">
|
<div className="pt-4 border-t">
|
||||||
<Campaigns />
|
<Button
|
||||||
</TabsContent>
|
className="w-full justify-start"
|
||||||
</Tabs>
|
variant="outline"
|
||||||
</SettingsLayout>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
|
||||||
import { Mail, Tag } from 'lucide-react';
|
import { Mail, Tag } from 'lucide-react';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -29,10 +28,12 @@ export default function Marketing() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<div className="w-full space-y-6">
|
||||||
title={__('Marketing')}
|
<div>
|
||||||
description={__('Newsletter, campaigns, and promotions')}
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<button
|
<button
|
||||||
@@ -52,6 +53,6 @@ export default function Marketing() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function StatusBadge({ status }: { status?: string }) {
|
|||||||
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
|
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() {
|
export default function OrderShow() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -315,6 +315,69 @@ export default function OrderShow() {
|
|||||||
</div>
|
</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 */}
|
{/* Items */}
|
||||||
<div className="rounded border overflow-hidden">
|
<div className="rounded border overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
|
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
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 { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -94,8 +101,8 @@ export default function Orders() {
|
|||||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||||
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
||||||
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || 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 [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
|
||||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
@@ -136,7 +143,7 @@ export default function Orders() {
|
|||||||
const rows = data?.rows;
|
const rows = data?.rows;
|
||||||
if (!rows) return [];
|
if (!rows) return [];
|
||||||
if (!searchQuery.trim()) return rows;
|
if (!searchQuery.trim()) return rows;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return rows.filter((order: any) =>
|
return rows.filter((order: any) =>
|
||||||
order.number?.toString().includes(query) ||
|
order.number?.toString().includes(query) ||
|
||||||
@@ -255,8 +262,8 @@ export default function Orders() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={q.isLoading || isRefreshing}
|
disabled={q.isLoading || isRefreshing}
|
||||||
@@ -305,7 +312,7 @@ export default function Orders() {
|
|||||||
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeFiltersCount > 0 && (
|
{activeFiltersCount > 0 && (
|
||||||
<button
|
<button
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||||
@@ -432,7 +439,7 @@ export default function Orders() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<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>
|
||||||
<td className="p-3 min-w-32">
|
<td className="p-3 min-w-32">
|
||||||
<span title={row.date ?? ""}>
|
<span title={row.date ?? ""}>
|
||||||
@@ -454,9 +461,36 @@ export default function Orders() {
|
|||||||
decimals: store.decimals,
|
decimals: store.decimals,
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center space-x-2">
|
<td className="p-3 text-center">
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
|
<DropdownMenu>
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
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 { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle
|
AlertDialogTitle
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -45,7 +52,7 @@ function StockBadge({ value, quantity }: { value?: string; quantity?: number })
|
|||||||
const v = (value || '').toLowerCase();
|
const v = (value || '').toLowerCase();
|
||||||
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
||||||
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -62,8 +69,8 @@ export default function Products() {
|
|||||||
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
||||||
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
||||||
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
||||||
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
|
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 [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
@@ -113,7 +120,7 @@ export default function Products() {
|
|||||||
const rows = data?.rows;
|
const rows = data?.rows;
|
||||||
if (!rows) return [];
|
if (!rows) return [];
|
||||||
if (!searchQuery.trim()) return rows;
|
if (!searchQuery.trim()) return rows;
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return rows.filter((product: any) =>
|
return rows.filter((product: any) =>
|
||||||
product.name?.toLowerCase().includes(query) ||
|
product.name?.toLowerCase().includes(query) ||
|
||||||
@@ -227,7 +234,7 @@ export default function Products() {
|
|||||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
@@ -236,8 +243,8 @@ export default function Products() {
|
|||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={q.isLoading || isRefreshing}
|
disabled={q.isLoading || isRefreshing}
|
||||||
@@ -412,9 +419,37 @@ export default function Products() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
<DropdownMenu>
|
||||||
{__('Edit')}
|
<DropdownMenuTrigger asChild>
|
||||||
</Link>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export default function EditTemplate() {
|
|||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
site_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ export default function EditTemplate() {
|
|||||||
current_year: new Date().getFullYear().toString(),
|
current_year: new Date().getFullYear().toString(),
|
||||||
site_name: 'My WordPress Store',
|
site_name: 'My WordPress Store',
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: '#',
|
site_url: '#',
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
support_email: 'support@example.com',
|
support_email: 'support@example.com',
|
||||||
// Account-related URLs and variables
|
// Account-related URLs and variables
|
||||||
@@ -310,6 +310,9 @@ export default function EditTemplate() {
|
|||||||
user_temp_password: '••••••••',
|
user_temp_password: '••••••••',
|
||||||
customer_first_name: 'John',
|
customer_first_name: 'John',
|
||||||
customer_last_name: 'Doe',
|
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) => {
|
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; }
|
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 { 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; }
|
.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; }
|
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
|
||||||
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -597,7 +601,7 @@ export default function EditTemplate() {
|
|||||||
{__('Send a test email with sample data to verify the template looks correct.')}
|
{__('Send a test email with sample data to verify the template looks correct.')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function TemplateEditor({
|
|||||||
// Replace store-identity variables with actual data
|
// Replace store-identity variables with actual data
|
||||||
const storeVariables: { [key: string]: string } = {
|
const storeVariables: { [key: string]: string } = {
|
||||||
store_name: 'My WordPress Store',
|
store_name: 'My WordPress Store',
|
||||||
store_url: window.location.origin,
|
site_url: window.location.origin,
|
||||||
store_email: 'store@example.com',
|
store_email: 'store@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
298
admin-spa/src/routes/Settings/Security.tsx
Normal file
298
admin-spa/src/routes/Settings/Security.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ interface Subscription {
|
|||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
last_payment_date: string | null;
|
last_payment_date: string | null;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
|
payment_method_title?: string;
|
||||||
pause_count: number;
|
pause_count: number;
|
||||||
failed_payment_count: number;
|
failed_payment_count: number;
|
||||||
cancel_reason: string | null;
|
cancel_reason: string | null;
|
||||||
@@ -65,6 +66,7 @@ const statusColors: Record<string, string> = {
|
|||||||
'cancelled': 'bg-gray-100 text-gray-800',
|
'cancelled': 'bg-gray-100 text-gray-800',
|
||||||
'expired': 'bg-red-100 text-red-800',
|
'expired': 'bg-red-100 text-red-800',
|
||||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
|
'draft': 'bg-gray-100 text-gray-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -74,6 +76,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
'cancelled': __('Cancelled'),
|
'cancelled': __('Cancelled'),
|
||||||
'expired': __('Expired'),
|
'expired': __('Expired'),
|
||||||
'pending-cancel': __('Pending Cancel'),
|
'pending-cancel': __('Pending Cancel'),
|
||||||
|
'draft': __('Draft'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderTypeLabels: Record<string, string> = {
|
const orderTypeLabels: Record<string, string> = {
|
||||||
@@ -83,6 +86,22 @@ const orderTypeLabels: Record<string, string> = {
|
|||||||
'resubscribe': __('Resubscribe'),
|
'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) {
|
async function fetchSubscription(id: string) {
|
||||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
||||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||||
@@ -257,7 +276,7 @@ export default function SubscriptionDetail() {
|
|||||||
{subscription.billing_schedule}
|
{subscription.billing_schedule}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg font-semibold mt-1">
|
<p className="text-lg font-semibold mt-1">
|
||||||
{window.WNW_STORE?.currency_symbol}{subscription.recurring_amount}
|
{formatPrice(subscription.recurring_amount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +336,7 @@ export default function SubscriptionDetail() {
|
|||||||
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
|
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CreditCard className="w-4 h-4" />
|
<CreditCard className="w-4 h-4" />
|
||||||
{subscription.payment_method || __('Not set')}
|
{subscription.payment_method_title || subscription.payment_method || __('Not set')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -368,29 +387,32 @@ export default function SubscriptionDetail() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
subscription.orders?.map((order) => (
|
subscription.orders?.map((order) => {
|
||||||
<TableRow key={order.id}>
|
const rawStatus = order.order_status?.replace('wc-', '') || 'pending';
|
||||||
<TableCell>
|
return (
|
||||||
<Link
|
<TableRow key={order.id}>
|
||||||
to={`/orders/${order.order_id}`}
|
<TableCell>
|
||||||
className="text-primary hover:underline font-medium"
|
<Link
|
||||||
>
|
to={`/orders/${order.order_id}`}
|
||||||
#{order.order_id}
|
className="text-primary hover:underline font-medium"
|
||||||
</Link>
|
>
|
||||||
</TableCell>
|
#{order.order_id}
|
||||||
<TableCell>
|
</Link>
|
||||||
<Badge variant="outline">
|
</TableCell>
|
||||||
{orderTypeLabels[order.order_type] || order.order_type}
|
<TableCell>
|
||||||
</Badge>
|
<Badge variant="outline">
|
||||||
</TableCell>
|
{orderTypeLabels[order.order_type] || order.order_type}
|
||||||
<TableCell>
|
</Badge>
|
||||||
<span className="capitalize">{order.order_status?.replace('wc-', '')}</span>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<span className="capitalize">{statusLabels[rawStatus] || rawStatus}</span>
|
||||||
{new Date(order.created_at).toLocaleDateString()}
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
</TableRow>
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
))
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -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 { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -151,6 +152,23 @@ export default function SubscriptionsIndex() {
|
|||||||
const total = data?.total || 0;
|
const total = data?.total || 0;
|
||||||
const totalPages = Math.ceil(total / 20);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -181,6 +199,13 @@ export default function SubscriptionsIndex() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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 className="w-[80px]">{__('ID')}</TableHead>
|
||||||
<TableHead>{__('Customer')}</TableHead>
|
<TableHead>{__('Customer')}</TableHead>
|
||||||
<TableHead>{__('Product')}</TableHead>
|
<TableHead>{__('Product')}</TableHead>
|
||||||
@@ -215,7 +240,18 @@ export default function SubscriptionsIndex() {
|
|||||||
) : (
|
) : (
|
||||||
subscriptions.map((sub) => (
|
subscriptions.map((sub) => (
|
||||||
<TableRow key={sub.id}>
|
<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>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{sub.user_name}</div>
|
<div className="font-medium">{sub.user_name}</div>
|
||||||
|
|||||||
15
admin-spa/src/types/window.d.ts
vendored
15
admin-spa/src/types/window.d.ts
vendored
@@ -47,12 +47,25 @@ interface WNW_CONFIG {
|
|||||||
pluginUrl?: string;
|
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 {
|
declare global {
|
||||||
interface Window {
|
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?: WNW_Config;
|
||||||
WNW_WC_MENUS?: WNW_WC_MENUS;
|
WNW_WC_MENUS?: WNW_WC_MENUS;
|
||||||
WNW_CONFIG?: WNW_CONFIG;
|
WNW_CONFIG?: WNW_CONFIG;
|
||||||
|
WNW_STORE?: WNW_Store;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
164
customer-spa/src/components/CaptchaWidget.tsx
Normal file
164
customer-spa/src/components/CaptchaWidget.tsx
Normal 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;
|
||||||
@@ -3,20 +3,32 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
interface NewsletterFormProps {
|
interface NewsletterFormProps {
|
||||||
description?: string;
|
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 [email, setEmail] = useState('');
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
toast.error('Please enter a valid email address');
|
toast.error('Please enter a valid email address');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gdprRequired && !consent) {
|
||||||
|
toast.error('Please accept the terms to subscribe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||||
@@ -26,7 +38,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email, consent }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -34,6 +46,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(data.message || 'Successfully subscribed to newsletter!');
|
toast.success(data.message || 'Successfully subscribed to newsletter!');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
|
setConsent(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.message || 'Failed to subscribe. Please try again.');
|
toast.error(data.message || 'Failed to subscribe. Please try again.');
|
||||||
}
|
}
|
||||||
@@ -48,7 +61,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{description && <p className="text-sm text-gray-600 mb-4">{description}</p>}
|
{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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={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"
|
className="w-full px-4 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
disabled={loading}
|
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
|
<button
|
||||||
type="submit"
|
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"
|
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'}
|
{loading ? 'Subscribing...' : 'Subscribe'}
|
||||||
|
|||||||
@@ -301,6 +301,8 @@ export function useFooterSettings() {
|
|||||||
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
|
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
|
||||||
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
|
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
|
||||||
},
|
},
|
||||||
|
payment: data?.footer?.payment,
|
||||||
|
copyright: data?.footer?.copyright,
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -350,21 +350,38 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Icons */}
|
{/* Payment Icons */}
|
||||||
{footerSettings.elements.payment && (
|
{(footerSettings.payment ? footerSettings.payment.enabled : footerSettings.elements.payment) && (
|
||||||
<div className="mt-8 pt-8 border-t">
|
<div className="mt-8 pt-8 border-t">
|
||||||
<p className="text-xs text-gray-500 text-center mb-4">We accept</p>
|
<p className="text-xs text-gray-500 text-center mb-4">
|
||||||
<div className="flex justify-center gap-4 text-gray-400">
|
{footerSettings.payment?.title || 'We accept'}
|
||||||
<span className="text-xs">💳 Visa</span>
|
</p>
|
||||||
<span className="text-xs">💳 Mastercard</span>
|
<div className="flex justify-center gap-4 text-gray-400 items-center">
|
||||||
<span className="text-xs">💳 PayPal</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* 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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import { AddressSelector } from '@/components/AddressSelector';
|
import { AddressSelector } from '@/components/AddressSelector';
|
||||||
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
|
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
|
||||||
|
import { CaptchaWidget } from '@/components/CaptchaWidget';
|
||||||
|
|
||||||
interface SavedAddress {
|
interface SavedAddress {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -42,7 +43,9 @@ export default function Checkout() {
|
|||||||
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
||||||
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
|
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
|
||||||
const [discountTotal, setDiscountTotal] = useState(0);
|
const [discountTotal, setDiscountTotal] = useState(0);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
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)
|
// Check if cart needs shipping (virtual-only carts don't need shipping)
|
||||||
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
|
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
|
||||||
@@ -567,6 +570,8 @@ export default function Checkout() {
|
|||||||
customer_note: orderNotes,
|
customer_note: orderNotes,
|
||||||
// Include all custom field data for backend processing
|
// Include all custom field data for backend processing
|
||||||
custom_fields: customFieldData,
|
custom_fields: customFieldData,
|
||||||
|
// CAPTCHA token for security validation
|
||||||
|
captcha_token: captchaToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Submit order
|
// Submit order
|
||||||
@@ -579,10 +584,18 @@ export default function Checkout() {
|
|||||||
|
|
||||||
toast.success('Order placed successfully!');
|
toast.success('Order placed successfully!');
|
||||||
|
|
||||||
// Navigate to thank you page via SPA routing
|
// Build thank you page URL
|
||||||
// Using window.location.replace to prevent back button issues
|
|
||||||
const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
|
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
|
return; // Stop execution here
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'Failed to create order');
|
throw new Error(data.error || 'Failed to create order');
|
||||||
@@ -615,6 +628,17 @@ export default function Checkout() {
|
|||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<SEOHead title="Checkout" description="Complete your purchase" />
|
<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">
|
<div className="py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ interface PageData {
|
|||||||
og_description?: string;
|
og_description?: string;
|
||||||
og_image?: string;
|
og_image?: string;
|
||||||
};
|
};
|
||||||
|
container_width?: string;
|
||||||
|
effective_container_width?: 'boxed' | 'fullwidth';
|
||||||
structure?: {
|
structure?: {
|
||||||
sections: Section[];
|
sections: Section[];
|
||||||
};
|
};
|
||||||
@@ -205,8 +207,8 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
|
|||||||
)}
|
)}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
{/* Render sections */}
|
{/* Render sections using effective container width */}
|
||||||
<div className="wn-page">
|
<div className={`wn-page ${pageData.effective_container_width === 'boxed' ? 'container mx-auto px-4 max-w-6xl' : ''}`}>
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const SectionComponent = SECTION_COMPONENTS[section.type];
|
const SectionComponent = SECTION_COMPONENTS[section.type];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WooNooW Documentation Registry
|
* WooNooW Documentation Registry
|
||||||
*
|
*
|
||||||
@@ -13,71 +14,25 @@ namespace WooNooW\Docs;
|
|||||||
*
|
*
|
||||||
* @return array Documentation registry
|
* @return array Documentation registry
|
||||||
*/
|
*/
|
||||||
function get_docs_registry() {
|
function get_docs_registry()
|
||||||
|
{
|
||||||
$docs_dir = dirname(__FILE__);
|
$docs_dir = dirname(__FILE__);
|
||||||
|
|
||||||
// Core WooNooW documentation
|
// Core WooNooW documentation
|
||||||
$docs = [
|
$docs = [
|
||||||
'core' => [
|
'core' => [
|
||||||
'label' => 'WooNooW',
|
'label' => 'Help & Support',
|
||||||
'icon' => 'book-open',
|
'icon' => 'book-open',
|
||||||
'items' => [
|
'items' => [
|
||||||
[
|
[
|
||||||
'slug' => 'getting-started',
|
'slug' => 'getting-started',
|
||||||
'title' => 'Getting Started',
|
'title' => 'Official Documentation',
|
||||||
'file' => $docs_dir . '/getting-started.md',
|
'file' => $docs_dir . '/getting-started.md',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'slug' => 'installation',
|
|
||||||
'title' => 'Installation',
|
|
||||||
'file' => $docs_dir . '/installation.md',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'slug' => 'troubleshooting',
|
|
||||||
'title' => 'Troubleshooting',
|
|
||||||
'file' => $docs_dir . '/troubleshooting.md',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'slug' => 'faq',
|
|
||||||
'title' => 'FAQ',
|
|
||||||
'file' => $docs_dir . '/faq.md',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'configuration' => [
|
|
||||||
'label' => 'Configuration',
|
|
||||||
'icon' => 'settings',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'slug' => 'configuration/appearance',
|
|
||||||
'title' => 'Appearance Settings',
|
|
||||||
'file' => $docs_dir . '/configuration/appearance.md',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'slug' => 'configuration/spa-mode',
|
|
||||||
'title' => 'SPA Mode',
|
|
||||||
'file' => $docs_dir . '/configuration/spa-mode.md',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'features' => [
|
|
||||||
'label' => 'Features',
|
|
||||||
'icon' => 'layers',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'slug' => 'features/shop',
|
|
||||||
'title' => 'Shop Page',
|
|
||||||
'file' => $docs_dir . '/features/shop.md',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'slug' => 'features/checkout',
|
|
||||||
'title' => 'Checkout',
|
|
||||||
'file' => $docs_dir . '/features/checkout.md',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: woonoow_docs_registry
|
* Filter: woonoow_docs_registry
|
||||||
*
|
*
|
||||||
@@ -111,9 +66,10 @@ function get_docs_registry() {
|
|||||||
* @param string $slug Document slug
|
* @param string $slug Document slug
|
||||||
* @return array|null Document data with content, or null if not found
|
* @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();
|
$registry = get_docs_registry();
|
||||||
|
|
||||||
foreach ($registry as $section) {
|
foreach ($registry as $section) {
|
||||||
foreach ($section['items'] as $item) {
|
foreach ($section['items'] as $item) {
|
||||||
if ($item['slug'] === $slug) {
|
if ($item['slug'] === $slug) {
|
||||||
@@ -127,6 +83,6 @@ function get_doc_by_slug($slug) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
149
docs/faq.md
149
docs/faq.md
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
[**Visit docs.woonoow.com ↗**](https://docs.woonoow.com)
|
||||||
- 🎨 **Modern UI** - Beautiful, responsive design out of the box
|
|
||||||
- 🛠 **Easy Customization** - Configure colors, fonts, and layout from admin
|
|
||||||
- 📱 **Mobile-First** - Optimized for all devices
|
|
||||||
|
|
||||||
## Quick Setup (3 Steps)
|
---
|
||||||
|
|
||||||
### Step 1: Activate the Plugin
|
## 🚀 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:
|
## 🛠 Support & Resources
|
||||||
- Create a "Store" page for the SPA
|
|
||||||
- Configure basic settings
|
|
||||||
|
|
||||||
### 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.
|
> **Tip:** Ensure your license is active in **Settings > License** to receive automatic updates and premium support.
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,41 +1,45 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Admin;
|
namespace WooNooW\Admin;
|
||||||
|
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
class AppearanceController {
|
class AppearanceController
|
||||||
|
{
|
||||||
|
|
||||||
const OPTION_KEY = 'woonoow_appearance_settings';
|
const OPTION_KEY = 'woonoow_appearance_settings';
|
||||||
const API_NAMESPACE = 'woonoow/v1';
|
const API_NAMESPACE = 'woonoow/v1';
|
||||||
|
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
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)
|
// Get all settings (public access for frontend)
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/settings', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/settings', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_settings'],
|
'callback' => [__CLASS__, 'get_settings'],
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Save general settings
|
// Save general settings
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/general', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/general', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'save_general'],
|
'callback' => [__CLASS__, 'save_general'],
|
||||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Save header settings
|
// Save header settings
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/header', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/header', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'save_header'],
|
'callback' => [__CLASS__, 'save_header'],
|
||||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Save footer settings
|
// Save footer settings
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/footer', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/footer', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -49,7 +53,7 @@ class AppearanceController {
|
|||||||
'callback' => [__CLASS__, 'save_menus'],
|
'callback' => [__CLASS__, 'save_menus'],
|
||||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Save page-specific settings
|
// Save page-specific settings
|
||||||
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
|
register_rest_route(self::API_NAMESPACE, '/appearance/pages/(?P<page>[a-zA-Z0-9_-]+)', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -63,7 +67,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get all WordPress pages for page selector
|
// Get all WordPress pages for page selector
|
||||||
register_rest_route(self::API_NAMESPACE, '/pages/list', [
|
register_rest_route(self::API_NAMESPACE, '/pages/list', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -71,40 +75,45 @@ class AppearanceController {
|
|||||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function check_permission() {
|
public static function check_permission()
|
||||||
|
{
|
||||||
return current_user_can('manage_woocommerce');
|
return current_user_can('manage_woocommerce');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all appearance settings
|
* 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, []);
|
$stored = get_option(self::OPTION_KEY, []);
|
||||||
$defaults = self::get_default_settings();
|
$defaults = self::get_default_settings();
|
||||||
|
|
||||||
// Merge stored with defaults to ensure all fields exist (recursive)
|
// Merge stored with defaults to ensure all fields exist (recursive)
|
||||||
$settings = array_replace_recursive($defaults, $stored);
|
$settings = array_replace_recursive($defaults, $stored);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $settings,
|
'data' => $settings,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save general settings
|
* 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, []);
|
$settings = get_option(self::OPTION_KEY, []);
|
||||||
$defaults = self::get_default_settings();
|
$defaults = self::get_default_settings();
|
||||||
$settings = array_replace_recursive($defaults, $settings);
|
$settings = array_replace_recursive($defaults, $settings);
|
||||||
|
|
||||||
$colors = $request->get_param('colors') ?? [];
|
$colors = $request->get_param('colors') ?? [];
|
||||||
|
|
||||||
$general_data = [
|
$general_data = [
|
||||||
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
|
||||||
'spa_page' => absint($request->get_param('spaPage') ?? 0),
|
'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'),
|
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
|
||||||
@@ -125,23 +134,25 @@ class AppearanceController {
|
|||||||
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
|
'gradientEnd' => sanitize_hex_color($colors['gradientEnd'] ?? '#3b82f6'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$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);
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'General settings saved successfully',
|
'message' => 'General settings saved successfully',
|
||||||
'data' => $general_data,
|
'data' => $general_data,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save header settings
|
* 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());
|
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||||
|
|
||||||
$header_data = [
|
$header_data = [
|
||||||
'style' => sanitize_text_field($request->get_param('style')),
|
'style' => sanitize_text_field($request->get_param('style')),
|
||||||
'sticky' => (bool) $request->get_param('sticky'),
|
'sticky' => (bool) $request->get_param('sticky'),
|
||||||
@@ -159,23 +170,24 @@ class AppearanceController {
|
|||||||
'wishlist' => (bool) ($request->get_param('elements')['wishlist'] ?? false),
|
'wishlist' => (bool) ($request->get_param('elements')['wishlist'] ?? false),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$settings['header'] = $header_data;
|
$settings['header'] = $header_data;
|
||||||
update_option(self::OPTION_KEY, $settings);
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Header settings saved successfully',
|
'message' => 'Header settings saved successfully',
|
||||||
'data' => $header_data,
|
'data' => $header_data,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save footer settings
|
* 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());
|
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||||
|
|
||||||
$social_links = $request->get_param('socialLinks') ?? [];
|
$social_links = $request->get_param('socialLinks') ?? [];
|
||||||
$sanitized_links = [];
|
$sanitized_links = [];
|
||||||
foreach ($social_links as $link) {
|
foreach ($social_links as $link) {
|
||||||
@@ -185,7 +197,7 @@ class AppearanceController {
|
|||||||
'url' => esc_url_raw($link['url'] ?? ''),
|
'url' => esc_url_raw($link['url'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize contact data
|
// Sanitize contact data
|
||||||
$contact_data = $request->get_param('contactData');
|
$contact_data = $request->get_param('contactData');
|
||||||
$sanitized_contact = [
|
$sanitized_contact = [
|
||||||
@@ -196,7 +208,7 @@ class AppearanceController {
|
|||||||
'show_phone' => (bool) ($contact_data['show_phone'] ?? true),
|
'show_phone' => (bool) ($contact_data['show_phone'] ?? true),
|
||||||
'show_address' => (bool) ($contact_data['show_address'] ?? true),
|
'show_address' => (bool) ($contact_data['show_address'] ?? true),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sanitize labels
|
// Sanitize labels
|
||||||
$labels = $request->get_param('labels');
|
$labels = $request->get_param('labels');
|
||||||
$sanitized_labels = [
|
$sanitized_labels = [
|
||||||
@@ -206,7 +218,7 @@ class AppearanceController {
|
|||||||
'newsletter_title' => sanitize_text_field($labels['newsletter_title'] ?? 'Newsletter'),
|
'newsletter_title' => sanitize_text_field($labels['newsletter_title'] ?? 'Newsletter'),
|
||||||
'newsletter_description' => sanitize_text_field($labels['newsletter_description'] ?? 'Subscribe to get updates'),
|
'newsletter_description' => sanitize_text_field($labels['newsletter_description'] ?? 'Subscribe to get updates'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sanitize custom sections
|
// Sanitize custom sections
|
||||||
$sections = $request->get_param('sections') ?? [];
|
$sections = $request->get_param('sections') ?? [];
|
||||||
$sanitized_sections = [];
|
$sanitized_sections = [];
|
||||||
@@ -219,28 +231,44 @@ class AppearanceController {
|
|||||||
'visible' => (bool) ($section['visible'] ?? true),
|
'visible' => (bool) ($section['visible'] ?? true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$footer_data = [
|
$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')),
|
'columns' => sanitize_text_field($request->get_param('columns')),
|
||||||
'style' => sanitize_text_field($request->get_param('style')),
|
'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' => [
|
'elements' => [
|
||||||
'newsletter' => (bool) ($request->get_param('elements')['newsletter'] ?? true),
|
'newsletter' => (bool) ($request->get_param('elements')['newsletter'] ?? true),
|
||||||
'social' => (bool) ($request->get_param('elements')['social'] ?? 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),
|
'menu' => (bool) ($request->get_param('elements')['menu'] ?? true),
|
||||||
'contact' => (bool) ($request->get_param('elements')['contact'] ?? true),
|
'contact' => (bool) ($request->get_param('elements')['contact'] ?? true),
|
||||||
|
// Payment and Copyright moved
|
||||||
],
|
],
|
||||||
'social_links' => $sanitized_links,
|
'social_links' => $sanitized_links,
|
||||||
'contact_data' => $sanitized_contact,
|
'contact_data' => $sanitized_contact,
|
||||||
'labels' => $sanitized_labels,
|
'labels' => $sanitized_labels,
|
||||||
'sections' => $sanitized_sections,
|
'sections' => $sanitized_sections,
|
||||||
];
|
];
|
||||||
|
|
||||||
$settings['footer'] = $footer_data;
|
$settings['footer'] = $footer_data;
|
||||||
update_option(self::OPTION_KEY, $settings);
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Footer settings saved successfully',
|
'message' => 'Footer settings saved successfully',
|
||||||
@@ -251,11 +279,12 @@ class AppearanceController {
|
|||||||
/**
|
/**
|
||||||
* Save menu settings
|
* 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());
|
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||||
|
|
||||||
$menus = $request->get_param('menus') ?? [];
|
$menus = $request->get_param('menus') ?? [];
|
||||||
|
|
||||||
// Sanitize menus
|
// Sanitize menus
|
||||||
$sanitized_menus = [
|
$sanitized_menus = [
|
||||||
'primary' => [],
|
'primary' => [],
|
||||||
@@ -265,7 +294,7 @@ class AppearanceController {
|
|||||||
foreach (['primary', 'mobile'] as $location) {
|
foreach (['primary', 'mobile'] as $location) {
|
||||||
if (isset($menus[$location]) && is_array($menus[$location])) {
|
if (isset($menus[$location]) && is_array($menus[$location])) {
|
||||||
foreach ($menus[$location] as $item) {
|
foreach ($menus[$location] as $item) {
|
||||||
$sanitized_menus[$location][] = [
|
$sanitized_menus[$location][] = [
|
||||||
'id' => sanitize_text_field($item['id'] ?? uniqid()),
|
'id' => sanitize_text_field($item['id'] ?? uniqid()),
|
||||||
'label' => sanitize_text_field($item['label'] ?? ''),
|
'label' => sanitize_text_field($item['label'] ?? ''),
|
||||||
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
|
'type' => sanitize_text_field($item['type'] ?? 'page'), // page, custom
|
||||||
@@ -275,46 +304,48 @@ class AppearanceController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings['menus'] = $sanitized_menus;
|
$settings['menus'] = $sanitized_menus;
|
||||||
update_option(self::OPTION_KEY, $settings);
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Menu settings saved successfully',
|
'message' => 'Menu settings saved successfully',
|
||||||
'data' => $sanitized_menus,
|
'data' => $sanitized_menus,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save page-specific settings
|
* 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());
|
$settings = get_option(self::OPTION_KEY, self::get_default_settings());
|
||||||
$page = $request->get_param('page');
|
$page = $request->get_param('page');
|
||||||
|
|
||||||
// Get all parameters from request
|
// Get all parameters from request
|
||||||
$page_data = $request->get_json_params();
|
$page_data = $request->get_json_params();
|
||||||
|
|
||||||
// Sanitize based on page type
|
// Sanitize based on page type
|
||||||
$sanitized_data = self::sanitize_page_data($page, $page_data);
|
$sanitized_data = self::sanitize_page_data($page, $page_data);
|
||||||
|
|
||||||
$settings['pages'][$page] = $sanitized_data;
|
$settings['pages'][$page] = $sanitized_data;
|
||||||
update_option(self::OPTION_KEY, $settings);
|
update_option(self::OPTION_KEY, $settings);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => ucfirst($page) . ' page settings saved successfully',
|
'message' => ucfirst($page) . ' page settings saved successfully',
|
||||||
'data' => $sanitized_data,
|
'data' => $sanitized_data,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize page-specific data
|
* Sanitize page-specific data
|
||||||
*/
|
*/
|
||||||
private static function sanitize_page_data($page, $data) {
|
private static function sanitize_page_data($page, $data)
|
||||||
|
{
|
||||||
$sanitized = [];
|
$sanitized = [];
|
||||||
|
|
||||||
switch ($page) {
|
switch ($page) {
|
||||||
case 'shop':
|
case 'shop':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
@@ -342,7 +373,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'product':
|
case 'product':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
'layout' => [
|
'layout' => [
|
||||||
@@ -366,7 +397,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cart':
|
case 'cart':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
'layout' => [
|
'layout' => [
|
||||||
@@ -381,7 +412,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'checkout':
|
case 'checkout':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
'layout' => [
|
'layout' => [
|
||||||
@@ -399,7 +430,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'thankyou':
|
case 'thankyou':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
'template' => sanitize_text_field($data['template'] ?? 'basic'),
|
'template' => sanitize_text_field($data['template'] ?? 'basic'),
|
||||||
@@ -414,7 +445,7 @@ class AppearanceController {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'account':
|
case 'account':
|
||||||
$sanitized = [
|
$sanitized = [
|
||||||
'layout' => [
|
'layout' => [
|
||||||
@@ -430,31 +461,32 @@ class AppearanceController {
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of WordPress pages for page selector
|
* 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([
|
$pages = get_pages([
|
||||||
'post_status' => 'publish',
|
'post_status' => 'publish',
|
||||||
'sort_column' => 'post_title',
|
'sort_column' => 'post_title',
|
||||||
'sort_order' => 'ASC',
|
'sort_order' => 'ASC',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$store_pages = [
|
$store_pages = [
|
||||||
(int) get_option('woocommerce_shop_page_id'),
|
(int) get_option('woocommerce_shop_page_id'),
|
||||||
(int) get_option('woocommerce_cart_page_id'),
|
(int) get_option('woocommerce_cart_page_id'),
|
||||||
(int) get_option('woocommerce_checkout_page_id'),
|
(int) get_option('woocommerce_checkout_page_id'),
|
||||||
(int) get_option('woocommerce_myaccount_page_id'),
|
(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_woonoow = !empty(get_post_meta($page->ID, '_wn_page_structure', true));
|
||||||
$is_store = in_array((int)$page->ID, $store_pages, true);
|
$is_store = in_array((int)$page->ID, $store_pages, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $page->ID,
|
'id' => $page->ID,
|
||||||
'title' => $page->post_title,
|
'title' => $page->post_title,
|
||||||
@@ -463,21 +495,24 @@ class AppearanceController {
|
|||||||
'is_store_page' => $is_store,
|
'is_store_page' => $is_store,
|
||||||
];
|
];
|
||||||
}, $pages);
|
}, $pages);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $pages_list,
|
'data' => $pages_list,
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default settings structure
|
* Get default settings structure
|
||||||
*/
|
*/
|
||||||
public static function get_default_settings() {
|
public static function get_default_settings()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'general' => [
|
'general' => [
|
||||||
'spa_mode' => 'full',
|
'spa_mode' => 'full',
|
||||||
'spa_page' => 0,
|
'spa_page' => 0,
|
||||||
|
|
||||||
|
'container_width' => 'boxed',
|
||||||
'toast_position' => 'top-right',
|
'toast_position' => 'top-right',
|
||||||
'typography' => [
|
'typography' => [
|
||||||
'mode' => 'predefined',
|
'mode' => 'predefined',
|
||||||
@@ -516,12 +551,22 @@ class AppearanceController {
|
|||||||
'footer' => [
|
'footer' => [
|
||||||
'columns' => '4',
|
'columns' => '4',
|
||||||
'style' => 'detailed',
|
'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' => [
|
'elements' => [
|
||||||
'newsletter' => true,
|
'newsletter' => true,
|
||||||
'social' => true,
|
'social' => true,
|
||||||
'payment' => true,
|
|
||||||
'copyright' => true,
|
|
||||||
'menu' => true,
|
'menu' => true,
|
||||||
'contact' => true,
|
'contact' => true,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Campaigns REST Controller
|
* Campaigns REST Controller
|
||||||
*
|
*
|
||||||
@@ -13,64 +14,67 @@ use WP_REST_Request;
|
|||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WooNooW\Core\Campaigns\CampaignManager;
|
use WooNooW\Core\Campaigns\CampaignManager;
|
||||||
|
use WooNooW\Core\ActivityLog\Logger;
|
||||||
|
|
||||||
|
class CampaignsController
|
||||||
|
{
|
||||||
|
|
||||||
class CampaignsController {
|
|
||||||
|
|
||||||
const API_NAMESPACE = 'woonoow/v1';
|
const API_NAMESPACE = 'woonoow/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register REST routes
|
* Register REST routes
|
||||||
*/
|
*/
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
// List campaigns
|
// List campaigns
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_campaigns'],
|
'callback' => [__CLASS__, 'get_campaigns'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create campaign
|
// Create campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'create_campaign'],
|
'callback' => [__CLASS__, 'create_campaign'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get single campaign
|
// Get single campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_campaign'],
|
'callback' => [__CLASS__, 'get_campaign'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update campaign
|
// Update campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
'methods' => 'PUT',
|
'methods' => 'PUT',
|
||||||
'callback' => [__CLASS__, 'update_campaign'],
|
'callback' => [__CLASS__, 'update_campaign'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Delete campaign
|
// Delete campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
|
||||||
'methods' => 'DELETE',
|
'methods' => 'DELETE',
|
||||||
'callback' => [__CLASS__, 'delete_campaign'],
|
'callback' => [__CLASS__, 'delete_campaign'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Send campaign
|
// Send campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'send_campaign'],
|
'callback' => [__CLASS__, 'send_campaign'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Send test email
|
// Send test email
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'send_test_email'],
|
'callback' => [__CLASS__, 'send_test_email'],
|
||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Preview campaign
|
// Preview campaign
|
||||||
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
|
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -78,30 +82,33 @@ class CampaignsController {
|
|||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check admin permission
|
* Check admin permission
|
||||||
*/
|
*/
|
||||||
public static function check_admin_permission() {
|
public static function check_admin_permission()
|
||||||
|
{
|
||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all campaigns
|
* 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();
|
$campaigns = CampaignManager::get_all();
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $campaigns,
|
'data' => $campaigns,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create campaign
|
* Create campaign
|
||||||
*/
|
*/
|
||||||
public static function create_campaign(WP_REST_Request $request) {
|
public static function create_campaign(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'title' => $request->get_param('title'),
|
'title' => $request->get_param('title'),
|
||||||
'subject' => $request->get_param('subject'),
|
'subject' => $request->get_param('subject'),
|
||||||
@@ -109,52 +116,54 @@ class CampaignsController {
|
|||||||
'status' => $request->get_param('status') ?: 'draft',
|
'status' => $request->get_param('status') ?: 'draft',
|
||||||
'scheduled_at' => $request->get_param('scheduled_at'),
|
'scheduled_at' => $request->get_param('scheduled_at'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$campaign_id = CampaignManager::create($data);
|
$campaign_id = CampaignManager::create($data);
|
||||||
|
|
||||||
if (is_wp_error($campaign_id)) {
|
if (is_wp_error($campaign_id)) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $campaign_id->get_error_message(),
|
'error' => $campaign_id->get_error_message(),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$campaign = CampaignManager::get($campaign_id);
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $campaign,
|
'data' => $campaign,
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single campaign
|
* 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_id = (int) $request->get_param('id');
|
||||||
$campaign = CampaignManager::get($campaign_id);
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
if (!$campaign) {
|
if (!$campaign) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => __('Campaign not found', 'woonoow'),
|
'error' => __('Campaign not found', 'woonoow'),
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $campaign,
|
'data' => $campaign,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update campaign
|
* 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');
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
$data = [];
|
$data = [];
|
||||||
|
|
||||||
if ($request->has_param('title')) {
|
if ($request->has_param('title')) {
|
||||||
$data['title'] = $request->get_param('title');
|
$data['title'] = $request->get_param('title');
|
||||||
}
|
}
|
||||||
@@ -170,60 +179,62 @@ class CampaignsController {
|
|||||||
if ($request->has_param('scheduled_at')) {
|
if ($request->has_param('scheduled_at')) {
|
||||||
$data['scheduled_at'] = $request->get_param('scheduled_at');
|
$data['scheduled_at'] = $request->get_param('scheduled_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = CampaignManager::update($campaign_id, $data);
|
$result = CampaignManager::update($campaign_id, $data);
|
||||||
|
|
||||||
if (is_wp_error($result)) {
|
if (is_wp_error($result)) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result->get_error_message(),
|
'error' => $result->get_error_message(),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$campaign = CampaignManager::get($campaign_id);
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $campaign,
|
'data' => $campaign,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete campaign
|
* 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');
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
$result = CampaignManager::delete($campaign_id);
|
$result = CampaignManager::delete($campaign_id);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => __('Failed to delete campaign', 'woonoow'),
|
'error' => __('Failed to delete campaign', 'woonoow'),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => __('Campaign deleted', 'woonoow'),
|
'message' => __('Campaign deleted', 'woonoow'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send campaign
|
* 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');
|
$campaign_id = (int) $request->get_param('id');
|
||||||
|
|
||||||
$result = CampaignManager::send($campaign_id);
|
$result = CampaignManager::send($campaign_id);
|
||||||
|
|
||||||
if (!$result['success']) {
|
if (!$result['success']) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error'],
|
'error' => $result['error'],
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf(
|
'message' => sprintf(
|
||||||
@@ -236,63 +247,80 @@ class CampaignsController {
|
|||||||
'total' => $result['total'],
|
'total' => $result['total'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send test email
|
* 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');
|
$campaign_id = (int) $request->get_param('id');
|
||||||
$email = sanitize_email($request->get_param('email'));
|
$email = sanitize_email($request->get_param('email'));
|
||||||
|
|
||||||
if (!is_email($email)) {
|
if (!is_email($email)) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => __('Invalid email address', 'woonoow'),
|
'error' => __('Invalid email address', 'woonoow'),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = CampaignManager::send_test($campaign_id, $email);
|
$result = CampaignManager::send_test($campaign_id, $email);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => __('Failed to send test email', 'woonoow'),
|
'error' => __('Failed to send test email', 'woonoow'),
|
||||||
], 400);
|
], 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([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
|
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preview campaign
|
* 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_id = (int) $request->get_param('id');
|
||||||
$campaign = CampaignManager::get($campaign_id);
|
$campaign = CampaignManager::get($campaign_id);
|
||||||
|
|
||||||
if (!$campaign) {
|
if (!$campaign) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => __('Campaign not found', 'woonoow'),
|
'error' => __('Campaign not found', 'woonoow'),
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use reflection to call private render method or make it public
|
// Use reflection to call private render method or make it public
|
||||||
// For now, return a simple preview
|
// For now, return a simple preview
|
||||||
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||||
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||||
|
|
||||||
$content = $campaign['content'];
|
$content = $campaign['content'];
|
||||||
$subject = $campaign['subject'] ?: $campaign['title'];
|
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||||
|
|
||||||
if ($template) {
|
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('{content}', $campaign['content'], $template['body']);
|
||||||
$content = str_replace('{campaign_title}', $campaign['title'], $content);
|
$content = str_replace('{campaign_title}', $campaign['title'], $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace campaign_title in subject
|
||||||
|
$subject = str_replace('{campaign_title}', $campaign['title'], $subject);
|
||||||
|
|
||||||
// Replace placeholders
|
// Replace placeholders
|
||||||
$site_name = get_bloginfo('name');
|
$site_name = get_bloginfo('name');
|
||||||
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
|
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
|
||||||
@@ -301,7 +329,10 @@ class CampaignsController {
|
|||||||
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
|
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
|
||||||
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
|
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
|
||||||
$content = str_replace('{current_year}', date('Y'), $content);
|
$content = str_replace('{current_year}', date('Y'), $content);
|
||||||
|
|
||||||
|
// Parse card shortcodes before rendering
|
||||||
|
$content = $renderer->parse_cards($content);
|
||||||
|
|
||||||
// Render with design template
|
// Render with design template
|
||||||
$design_path = $renderer->get_design_template();
|
$design_path = $renderer->get_design_template();
|
||||||
if (file_exists($design_path)) {
|
if (file_exists($design_path)) {
|
||||||
@@ -310,7 +341,7 @@ class CampaignsController {
|
|||||||
'site_url' => home_url(),
|
'site_url' => home_url(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
|
|||||||
@@ -311,12 +311,27 @@ class CheckoutController
|
|||||||
return ['error' => __('No items provided', 'woonoow')];
|
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
|
// Create order
|
||||||
$order = wc_create_order();
|
$order = wc_create_order();
|
||||||
if (is_wp_error($order)) {
|
if (is_wp_error($order)) {
|
||||||
return ['error' => $order->get_error_message()];
|
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
|
// Set customer ID if user is logged in
|
||||||
if (is_user_logged_in()) {
|
if (is_user_logged_in()) {
|
||||||
$user_id = get_current_user_id();
|
$user_id = get_current_user_id();
|
||||||
@@ -358,8 +373,9 @@ class CheckoutController
|
|||||||
$existing_user = get_user_by('email', $email);
|
$existing_user = get_user_by('email', $email);
|
||||||
|
|
||||||
if ($existing_user) {
|
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);
|
$order->set_customer_id($existing_user->ID);
|
||||||
|
// Note: user_logged_in stays false - existing users must authenticate separately
|
||||||
} else {
|
} else {
|
||||||
// Create new user account
|
// Create new user account
|
||||||
$password = wp_generate_password(12, true, true);
|
$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
|
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
|
||||||
wp_set_auth_cookie($new_user_id, true);
|
wp_set_auth_cookie($new_user_id, true);
|
||||||
wp_set_current_user($new_user_id);
|
wp_set_current_user($new_user_id);
|
||||||
|
$user_logged_in = true;
|
||||||
|
|
||||||
// Set WooCommerce customer billing data
|
// Set WooCommerce customer billing data
|
||||||
$customer = new \WC_Customer($new_user_id);
|
$customer = new \WC_Customer($new_user_id);
|
||||||
@@ -509,6 +526,9 @@ class CheckoutController
|
|||||||
WC()->cart->empty_cart();
|
WC()->cart->empty_cart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record this order attempt for rate limiting
|
||||||
|
\WooNooW\Compat\SecuritySettingsProvider::record_order_attempt();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'order_id' => $order->get_id(),
|
'order_id' => $order->get_id(),
|
||||||
@@ -516,6 +536,7 @@ class CheckoutController
|
|||||||
'status' => $order->get_status(),
|
'status' => $order->get_status(),
|
||||||
'pay_url' => $order->get_checkout_payment_url(),
|
'pay_url' => $order->get_checkout_payment_url(),
|
||||||
'thankyou_url' => $order->get_checkout_order_received_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)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\API;
|
namespace WooNooW\API;
|
||||||
|
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WooNooW\Core\Validation;
|
use WooNooW\Core\Validation;
|
||||||
|
use WooNooW\Database\SubscriberTable;
|
||||||
|
|
||||||
class NewsletterController {
|
class NewsletterController
|
||||||
|
{
|
||||||
const API_NAMESPACE = 'woonoow/v1';
|
const API_NAMESPACE = 'woonoow/v1';
|
||||||
|
|
||||||
public static function register_routes() {
|
public static function register_routes()
|
||||||
|
{
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribe', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribe', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'subscribe'],
|
'callback' => [__CLASS__, 'subscribe'],
|
||||||
@@ -18,45 +22,45 @@ class NewsletterController {
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'validate_callback' => function($param) {
|
'validate_callback' => function ($param) {
|
||||||
return is_email($param);
|
return is_email($param);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_subscribers'],
|
'callback' => [__CLASS__, 'get_subscribers'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function () {
|
||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers/(?P<email>[^/]+)', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/subscribers/(?P<email>[^/]+)', [
|
||||||
'methods' => 'DELETE',
|
'methods' => 'DELETE',
|
||||||
'callback' => [__CLASS__, 'delete_subscriber'],
|
'callback' => [__CLASS__, 'delete_subscriber'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function () {
|
||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => [__CLASS__, 'get_template'],
|
'callback' => [__CLASS__, 'get_template'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function () {
|
||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/template/(?P<template>[^/]+)', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => [__CLASS__, 'save_template'],
|
'callback' => [__CLASS__, 'save_template'],
|
||||||
'permission_callback' => function() {
|
'permission_callback' => function () {
|
||||||
return current_user_can('manage_options');
|
return current_user_can('manage_options');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Public unsubscribe endpoint (no auth needed, uses token)
|
// Public unsubscribe endpoint (no auth needed, uses token)
|
||||||
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
|
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
@@ -73,139 +77,381 @@ 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');
|
$template = $request->get_param('template');
|
||||||
$option_key = "woonoow_newsletter_{$template}_template";
|
$option_key = "woonoow_newsletter_{$template}_template";
|
||||||
|
|
||||||
$data = get_option($option_key, [
|
$data = get_option($option_key, [
|
||||||
'subject' => $template === 'welcome' ? 'Welcome to {site_name} Newsletter!' : 'Confirm your newsletter subscription',
|
'subject' => $template === 'welcome' ? 'Welcome to {site_name} Newsletter!' : 'Confirm your newsletter subscription',
|
||||||
'content' => $template === 'welcome'
|
'content' => $template === 'welcome'
|
||||||
? "Thank you for subscribing to our newsletter!\n\nYou'll receive updates about our latest products and offers.\n\nBest regards,\n{site_name}"
|
? "Thank you for subscribing to our newsletter!\n\nYou'll receive updates about our latest products and offers.\n\nBest regards,\n{site_name}"
|
||||||
: "Please confirm your newsletter subscription by clicking the link below:\n\n{confirmation_url}\n\nBest regards,\n{site_name}",
|
: "Please confirm your newsletter subscription by clicking the link below:\n\n{confirmation_url}\n\nBest regards,\n{site_name}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'subject' => $data['subject'] ?? '',
|
'subject' => $data['subject'] ?? '',
|
||||||
'content' => $data['content'] ?? '',
|
'content' => $data['content'] ?? '',
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function save_template(WP_REST_Request $request) {
|
public static function save_template(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$template = $request->get_param('template');
|
$template = $request->get_param('template');
|
||||||
$subject = sanitize_text_field($request->get_param('subject'));
|
$subject = sanitize_text_field($request->get_param('subject'));
|
||||||
$content = wp_kses_post($request->get_param('content'));
|
$content = wp_kses_post($request->get_param('content'));
|
||||||
|
|
||||||
$option_key = "woonoow_newsletter_{$template}_template";
|
$option_key = "woonoow_newsletter_{$template}_template";
|
||||||
|
|
||||||
update_option($option_key, [
|
update_option($option_key, [
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Template saved successfully',
|
'message' => 'Template saved successfully',
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function delete_subscriber(WP_REST_Request $request) {
|
public static function delete_subscriber(WP_REST_Request $request)
|
||||||
$email = urldecode($request->get_param('email'));
|
{
|
||||||
|
$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 = 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;
|
return isset($sub['email']) && $sub['email'] !== $email;
|
||||||
});
|
});
|
||||||
|
|
||||||
update_option('woonoow_newsletter_subscribers', array_values($subscribers));
|
update_option('woonoow_newsletter_subscribers', array_values($subscribers));
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Subscriber removed successfully',
|
'message' => 'Subscriber removed successfully',
|
||||||
], 200);
|
], 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'));
|
$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
|
// Use centralized validation with extensible filter hooks
|
||||||
$validation = Validation::validate_email($email, 'newsletter_subscribe');
|
$validation = Validation::validate_email($email, 'newsletter_subscribe');
|
||||||
|
|
||||||
if (is_wp_error($validation)) {
|
if (is_wp_error($validation)) {
|
||||||
return $validation;
|
return $validation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing subscribers (now stored as objects with metadata)
|
// Check GDPR consent requirement
|
||||||
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
$gdpr_required = get_option('woonoow_newsletter_gdpr_consent', false);
|
||||||
|
if ($gdpr_required && !$consent) {
|
||||||
// Check if already subscribed
|
return new WP_Error('consent_required', __('Please accept the terms to subscribe.', 'woonoow'), ['status' => 400]);
|
||||||
$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 if email belongs to a WP user
|
// Check if email belongs to a WP user
|
||||||
$user = get_user_by('email', $email);
|
$user = get_user_by('email', $email);
|
||||||
$user_id = $user ? $user->ID : null;
|
$user_id = $user ? $user->ID : null;
|
||||||
|
|
||||||
// Add new subscriber with metadata
|
// Check double opt-in setting
|
||||||
$subscribers[] = [
|
$double_opt_in = get_option('woonoow_newsletter_double_opt_in', true);
|
||||||
'email' => $email,
|
$status = $double_opt_in ? 'pending' : 'active';
|
||||||
'user_id' => $user_id,
|
|
||||||
'status' => 'active',
|
if (self::use_custom_table()) {
|
||||||
'subscribed_at' => current_time('mysql'),
|
// Use custom table
|
||||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
$existing = SubscriberTable::get_by_email($email);
|
||||||
];
|
|
||||||
|
if ($existing) {
|
||||||
update_option('woonoow_newsletter_subscribers', $subscribers);
|
if ($existing['status'] === 'active') {
|
||||||
|
return new WP_REST_Response([
|
||||||
// Trigger notification events
|
'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);
|
do_action('woonoow_newsletter_subscribed', $email, $user_id);
|
||||||
|
|
||||||
// Trigger notification system events (uses email builder)
|
|
||||||
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
|
do_action('woonoow/notification/event', 'newsletter_welcome', 'customer', [
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'subscribed_at' => current_time('mysql'),
|
'subscribed_at' => current_time('mysql'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
do_action('woonoow/notification/event', 'newsletter_subscribed_admin', 'staff', [
|
do_action('woonoow/notification/event', 'newsletter_subscribed_admin', 'staff', [
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'subscribed_at' => current_time('mysql'),
|
'subscribed_at' => current_time('mysql'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Successfully subscribed! Check your email for confirmation.',
|
'message' => __('Successfully subscribed to our newsletter!', 'woonoow'),
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function send_welcome_email($email) {
|
/**
|
||||||
$site_name = get_bloginfo('name');
|
* Send confirmation email for double opt-in
|
||||||
$template = get_option('woonoow_newsletter_welcome_template', '');
|
*/
|
||||||
|
private static function send_confirmation_email($email, $user_id = null)
|
||||||
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}";
|
$confirmation_url = self::generate_confirmation_url($email);
|
||||||
}
|
|
||||||
|
do_action('woonoow/notification/event', 'newsletter_confirm', 'customer', [
|
||||||
$subject = sprintf('Welcome to %s Newsletter!', $site_name);
|
'email' => $email,
|
||||||
$message = str_replace('{site_name}', $site_name, $template);
|
'user_id' => $user_id,
|
||||||
|
'confirmation_url' => $confirmation_url,
|
||||||
wp_mail($email, $subject, $message);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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', []);
|
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
@@ -214,14 +460,15 @@ class NewsletterController {
|
|||||||
],
|
],
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle unsubscribe request
|
* 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')));
|
$email = sanitize_email(urldecode($request->get_param('email')));
|
||||||
$token = sanitize_text_field($request->get_param('token'));
|
$token = sanitize_text_field($request->get_param('token'));
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
$expected_token = self::generate_unsubscribe_token($email);
|
$expected_token = self::generate_unsubscribe_token($email);
|
||||||
if (!hash_equals($expected_token, $token)) {
|
if (!hash_equals($expected_token, $token)) {
|
||||||
@@ -230,31 +477,45 @@ class NewsletterController {
|
|||||||
'message' => __('Invalid unsubscribe link', 'woonoow'),
|
'message' => __('Invalid unsubscribe link', 'woonoow'),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscribers
|
|
||||||
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
|
||||||
$found = false;
|
$found = false;
|
||||||
|
|
||||||
foreach ($subscribers as &$sub) {
|
if (self::use_custom_table()) {
|
||||||
if (isset($sub['email']) && $sub['email'] === $email) {
|
$existing = SubscriberTable::get_by_email($email);
|
||||||
$sub['status'] = 'unsubscribed';
|
if ($existing) {
|
||||||
$sub['unsubscribed_at'] = current_time('mysql');
|
SubscriberTable::update_by_email($email, [
|
||||||
|
'status' => 'unsubscribed',
|
||||||
|
'unsubscribed_at' => current_time('mysql'),
|
||||||
|
]);
|
||||||
$found = true;
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$found) {
|
if (!$found) {
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => __('Email not found', 'woonoow'),
|
'message' => __('Email not found', 'woonoow'),
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
update_option('woonoow_newsletter_subscribers', $subscribers);
|
|
||||||
|
|
||||||
do_action('woonoow_newsletter_unsubscribed', $email);
|
do_action('woonoow_newsletter_unsubscribed', $email);
|
||||||
|
|
||||||
// Return HTML page for nice UX
|
// Return HTML page for nice UX
|
||||||
$site_name = get_bloginfo('name');
|
$site_name = get_bloginfo('name');
|
||||||
$html = sprintf(
|
$html = sprintf(
|
||||||
@@ -262,24 +523,26 @@ class NewsletterController {
|
|||||||
__('Unsubscribed', 'woonoow'),
|
__('Unsubscribed', 'woonoow'),
|
||||||
esc_html($site_name)
|
esc_html($site_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
echo $html;
|
echo $html;
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate secure unsubscribe token
|
* Generate secure unsubscribe token
|
||||||
*/
|
*/
|
||||||
private static function generate_unsubscribe_token($email) {
|
private static function generate_unsubscribe_token($email)
|
||||||
|
{
|
||||||
$secret = wp_salt('auth');
|
$secret = wp_salt('auth');
|
||||||
return hash_hmac('sha256', $email, $secret);
|
return hash_hmac('sha256', $email, $secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate unsubscribe URL for email templates
|
* 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);
|
$token = self::generate_unsubscribe_token($email);
|
||||||
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
|
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
|
||||||
return add_query_arg([
|
return add_query_arg([
|
||||||
@@ -288,4 +551,3 @@ class NewsletterController {
|
|||||||
], $base_url);
|
], $base_url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -371,13 +371,13 @@ class ProductsController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Virtual and downloadable
|
// Virtual and downloadable
|
||||||
if (isset($data['virtual'])) {
|
if (array_key_exists('virtual', $data)) {
|
||||||
$product->set_virtual((bool) $data['virtual']);
|
$product->set_virtual((bool) $data['virtual']);
|
||||||
}
|
}
|
||||||
if (isset($data['downloadable'])) {
|
if (array_key_exists('downloadable', $data)) {
|
||||||
$product->set_downloadable((bool) $data['downloadable']);
|
$product->set_downloadable((bool) $data['downloadable']);
|
||||||
}
|
}
|
||||||
if (isset($data['featured'])) {
|
if (array_key_exists('featured', $data)) {
|
||||||
$product->set_featured((bool) $data['featured']);
|
$product->set_featured((bool) $data['featured']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,13 +510,13 @@ class ProductsController
|
|||||||
if (isset($data['height'])) $product->set_height(self::sanitize_number($data['height']));
|
if (isset($data['height'])) $product->set_height(self::sanitize_number($data['height']));
|
||||||
|
|
||||||
// Virtual and downloadable
|
// Virtual and downloadable
|
||||||
if (isset($data['virtual'])) {
|
if (array_key_exists('virtual', $data)) {
|
||||||
$product->set_virtual((bool) $data['virtual']);
|
$product->set_virtual((bool) $data['virtual']);
|
||||||
}
|
}
|
||||||
if (isset($data['downloadable'])) {
|
if (array_key_exists('downloadable', $data)) {
|
||||||
$product->set_downloadable((bool) $data['downloadable']);
|
$product->set_downloadable((bool) $data['downloadable']);
|
||||||
}
|
}
|
||||||
if (isset($data['featured'])) {
|
if (array_key_exists('featured', $data)) {
|
||||||
$product->set_featured((bool) $data['featured']);
|
$product->set_featured((bool) $data['featured']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store REST API Controller
|
* Store REST API Controller
|
||||||
*
|
*
|
||||||
@@ -11,28 +12,31 @@ namespace WooNooW\API;
|
|||||||
|
|
||||||
use WooNooW\Compat\StoreSettingsProvider;
|
use WooNooW\Compat\StoreSettingsProvider;
|
||||||
use WooNooW\Compat\CustomerSettingsProvider;
|
use WooNooW\Compat\CustomerSettingsProvider;
|
||||||
|
use WooNooW\Compat\SecuritySettingsProvider;
|
||||||
use WP_REST_Controller;
|
use WP_REST_Controller;
|
||||||
use WP_REST_Server;
|
use WP_REST_Server;
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
class StoreController extends WP_REST_Controller {
|
class StoreController extends WP_REST_Controller
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Namespace
|
* Namespace
|
||||||
*/
|
*/
|
||||||
protected $namespace = 'woonoow/v1';
|
protected $namespace = 'woonoow/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rest base
|
* Rest base
|
||||||
*/
|
*/
|
||||||
protected $rest_base = 'store';
|
protected $rest_base = 'store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register routes
|
* Register routes
|
||||||
*/
|
*/
|
||||||
public function register_routes() {
|
public function register_routes()
|
||||||
|
{
|
||||||
// GET /woonoow/v1/store/branding (PUBLIC - for login page)
|
// GET /woonoow/v1/store/branding (PUBLIC - for login page)
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/branding', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/branding', [
|
||||||
[
|
[
|
||||||
@@ -41,7 +45,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => '__return_true', // Public endpoint
|
'permission_callback' => '__return_true', // Public endpoint
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET /woonoow/v1/store/settings
|
// GET /woonoow/v1/store/settings
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
|
||||||
[
|
[
|
||||||
@@ -50,7 +54,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// POST /woonoow/v1/store/settings
|
// POST /woonoow/v1/store/settings
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/settings', [
|
||||||
[
|
[
|
||||||
@@ -59,7 +63,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET /woonoow/v1/store/countries
|
// GET /woonoow/v1/store/countries
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/countries', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/countries', [
|
||||||
[
|
[
|
||||||
@@ -68,7 +72,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET /woonoow/v1/store/timezones
|
// GET /woonoow/v1/store/timezones
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/timezones', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/timezones', [
|
||||||
[
|
[
|
||||||
@@ -77,7 +81,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET /woonoow/v1/store/currencies
|
// GET /woonoow/v1/store/currencies
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/currencies', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/currencies', [
|
||||||
[
|
[
|
||||||
@@ -86,7 +90,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// GET /woonoow/v1/store/customer-settings
|
// GET /woonoow/v1/store/customer-settings
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
|
||||||
[
|
[
|
||||||
@@ -95,7 +99,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'permission_callback' => [$this, 'check_permission'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// POST /woonoow/v1/store/customer-settings
|
// POST /woonoow/v1/store/customer-settings
|
||||||
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
|
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
|
||||||
[
|
[
|
||||||
@@ -104,15 +108,34 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'permission_callback' => [$this, 'check_permission'],
|
'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'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store branding (PUBLIC - for login page)
|
* Get store branding (PUBLIC - for login page)
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response Response object
|
* @return WP_REST_Response Response object
|
||||||
*/
|
*/
|
||||||
public function get_branding(WP_REST_Request $request) {
|
public function get_branding(WP_REST_Request $request)
|
||||||
|
{
|
||||||
$branding = [
|
$branding = [
|
||||||
'store_name' => get_option('woonoow_store_name', '') ?: get_option('blogname', 'WooNooW'),
|
'store_name' => get_option('woonoow_store_name', '') ?: get_option('blogname', 'WooNooW'),
|
||||||
'store_logo' => get_option('woonoow_store_logo', ''),
|
'store_logo' => get_option('woonoow_store_logo', ''),
|
||||||
@@ -120,26 +143,27 @@ class StoreController extends WP_REST_Controller {
|
|||||||
'store_icon' => get_option('woonoow_store_icon', ''),
|
'store_icon' => get_option('woonoow_store_icon', ''),
|
||||||
'store_tagline' => get_option('woonoow_store_tagline', ''),
|
'store_tagline' => get_option('woonoow_store_tagline', ''),
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = rest_ensure_response($branding);
|
$response = rest_ensure_response($branding);
|
||||||
$response->header('Cache-Control', 'max-age=300'); // Cache for 5 minutes
|
$response->header('Cache-Control', 'max-age=300'); // Cache for 5 minutes
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store settings
|
* Get store settings
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$settings = StoreSettingsProvider::get_settings();
|
$settings = StoreSettingsProvider::get_settings();
|
||||||
|
|
||||||
$response = rest_ensure_response($settings);
|
$response = rest_ensure_response($settings);
|
||||||
$response->header('Cache-Control', 'max-age=60');
|
$response->header('Cache-Control', 'max-age=60');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
@@ -149,16 +173,17 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save store settings
|
* Save store settings
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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();
|
$settings = $request->get_json_params();
|
||||||
|
|
||||||
if (empty($settings)) {
|
if (empty($settings)) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'missing_settings',
|
'missing_settings',
|
||||||
@@ -166,10 +191,10 @@ class StoreController extends WP_REST_Controller {
|
|||||||
['status' => 400]
|
['status' => 400]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = StoreSettingsProvider::save_settings($settings);
|
$result = StoreSettingsProvider::save_settings($settings);
|
||||||
|
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'save_failed',
|
'save_failed',
|
||||||
@@ -177,7 +202,7 @@ class StoreController extends WP_REST_Controller {
|
|||||||
['status' => 500]
|
['status' => 500]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rest_ensure_response([
|
return rest_ensure_response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Settings saved successfully',
|
'message' => 'Settings saved successfully',
|
||||||
@@ -191,20 +216,21 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get countries
|
* Get countries
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$countries = StoreSettingsProvider::get_countries();
|
$countries = StoreSettingsProvider::get_countries();
|
||||||
|
|
||||||
$response = rest_ensure_response($countries);
|
$response = rest_ensure_response($countries);
|
||||||
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
@@ -214,20 +240,21 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get timezones
|
* Get timezones
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$timezones = StoreSettingsProvider::get_timezones();
|
$timezones = StoreSettingsProvider::get_timezones();
|
||||||
|
|
||||||
$response = rest_ensure_response($timezones);
|
$response = rest_ensure_response($timezones);
|
||||||
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
@@ -237,20 +264,21 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get currencies
|
* Get currencies
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$currencies = StoreSettingsProvider::get_currencies();
|
$currencies = StoreSettingsProvider::get_currencies();
|
||||||
|
|
||||||
$response = rest_ensure_response($currencies);
|
$response = rest_ensure_response($currencies);
|
||||||
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
$response->header('Cache-Control', 'max-age=3600'); // Cache for 1 hour
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
@@ -260,20 +288,21 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get customer settings
|
* Get customer settings
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$settings = CustomerSettingsProvider::get_settings();
|
$settings = CustomerSettingsProvider::get_settings();
|
||||||
|
|
||||||
$response = rest_ensure_response($settings);
|
$response = rest_ensure_response($settings);
|
||||||
$response->header('Cache-Control', 'max-age=60');
|
$response->header('Cache-Control', 'max-age=60');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
@@ -283,17 +312,18 @@ class StoreController extends WP_REST_Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save customer settings
|
* Save customer settings
|
||||||
*
|
*
|
||||||
* @param WP_REST_Request $request Request object
|
* @param WP_REST_Request $request Request object
|
||||||
* @return WP_REST_Response|WP_Error Response object or error
|
* @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 {
|
try {
|
||||||
$settings = $request->get_json_params();
|
$settings = $request->get_json_params();
|
||||||
|
|
||||||
if (empty($settings)) {
|
if (empty($settings)) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'invalid_settings',
|
'invalid_settings',
|
||||||
@@ -301,9 +331,9 @@ class StoreController extends WP_REST_Controller {
|
|||||||
['status' => 400]
|
['status' => 400]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = CustomerSettingsProvider::update_settings($settings);
|
$updated = CustomerSettingsProvider::update_settings($settings);
|
||||||
|
|
||||||
if (!$updated) {
|
if (!$updated) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'update_failed',
|
'update_failed',
|
||||||
@@ -311,16 +341,15 @@ class StoreController extends WP_REST_Controller {
|
|||||||
['status' => 500]
|
['status' => 500]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return updated settings
|
// Return updated settings
|
||||||
$new_settings = CustomerSettingsProvider::get_settings();
|
$new_settings = CustomerSettingsProvider::get_settings();
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => __('Customer settings updated successfully', 'woonoow'),
|
'message' => __('Customer settings updated successfully', 'woonoow'),
|
||||||
'settings' => $new_settings,
|
'settings' => $new_settings,
|
||||||
], 200);
|
], 200);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'save_customer_settings_failed',
|
'save_customer_settings_failed',
|
||||||
@@ -329,13 +358,84 @@ 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
|
* Check if user has permission
|
||||||
*
|
*
|
||||||
* @return bool True 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
|
// Check WooCommerce capability first, fallback to manage_options
|
||||||
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
|
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,6 +471,31 @@ class SubscriptionsController
|
|||||||
}
|
}
|
||||||
$enriched['billing_schedule'] = sprintf(__('Every %s%s', 'woonoow'), $interval, $period);
|
$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;
|
return $enriched;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Compat;
|
namespace WooNooW\Compat;
|
||||||
|
|
||||||
if ( ! defined('ABSPATH') ) exit;
|
if (! defined('ABSPATH')) exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation Registry
|
* Navigation Registry
|
||||||
@@ -11,36 +12,39 @@ if ( ! defined('ABSPATH') ) exit;
|
|||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
class NavigationRegistry {
|
class NavigationRegistry
|
||||||
|
{
|
||||||
const NAV_OPTION = 'wnw_nav_tree';
|
const NAV_OPTION = 'wnw_nav_tree';
|
||||||
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
|
const NAV_VERSION = '1.3.0'; // Added Subscriptions section
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize hooks
|
* 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+)
|
// 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('init', [__CLASS__, 'build_nav_tree'], 10);
|
||||||
add_action('activated_plugin', [__CLASS__, 'flush']);
|
add_action('activated_plugin', [__CLASS__, 'flush']);
|
||||||
add_action('deactivated_plugin', [__CLASS__, 'flush']);
|
add_action('deactivated_plugin', [__CLASS__, 'flush']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the complete navigation tree
|
* 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)
|
// Check if we need to rebuild (version mismatch)
|
||||||
$cached = get_option(self::NAV_OPTION, []);
|
$cached = get_option(self::NAV_OPTION, []);
|
||||||
$cached_version = $cached['version'] ?? '';
|
$cached_version = $cached['version'] ?? '';
|
||||||
|
|
||||||
if ($cached_version === self::NAV_VERSION && !empty($cached['tree'])) {
|
if ($cached_version === self::NAV_VERSION && !empty($cached['tree'])) {
|
||||||
// Cache is valid, no need to rebuild
|
// Cache is valid, no need to rebuild
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base navigation tree (core WooNooW sections)
|
// Base navigation tree (core WooNooW sections)
|
||||||
$tree = self::get_base_tree();
|
$tree = self::get_base_tree();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter: woonoow/nav_tree
|
* Filter: woonoow/nav_tree
|
||||||
*
|
*
|
||||||
@@ -64,7 +68,7 @@ class NavigationRegistry {
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
$tree = apply_filters('woonoow/nav_tree', $tree);
|
$tree = apply_filters('woonoow/nav_tree', $tree);
|
||||||
|
|
||||||
// Allow per-section modification
|
// Allow per-section modification
|
||||||
foreach ($tree as &$section) {
|
foreach ($tree as &$section) {
|
||||||
$key = $section['key'] ?? '';
|
$key = $section['key'] ?? '';
|
||||||
@@ -90,7 +94,7 @@ class NavigationRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in option
|
// Store in option
|
||||||
update_option(self::NAV_OPTION, [
|
update_option(self::NAV_OPTION, [
|
||||||
'version' => self::NAV_VERSION,
|
'version' => self::NAV_VERSION,
|
||||||
@@ -98,13 +102,14 @@ class NavigationRegistry {
|
|||||||
'updated' => time(),
|
'updated' => time(),
|
||||||
], false);
|
], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get base navigation tree (core sections)
|
* Get base navigation tree (core sections)
|
||||||
*
|
*
|
||||||
* @return array Base navigation tree
|
* @return array Base navigation tree
|
||||||
*/
|
*/
|
||||||
private static function get_base_tree(): array {
|
private static function get_base_tree(): array
|
||||||
|
{
|
||||||
$tree = [
|
$tree = [
|
||||||
[
|
[
|
||||||
'key' => 'dashboard',
|
'key' => 'dashboard',
|
||||||
@@ -198,37 +203,39 @@ class NavigationRegistry {
|
|||||||
'children' => [], // Empty array = no submenu bar
|
'children' => [], // Empty array = no submenu bar
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get marketing submenu children
|
* Get marketing submenu children
|
||||||
*
|
*
|
||||||
* @return array Marketing submenu items
|
* @return array Marketing submenu items
|
||||||
*/
|
*/
|
||||||
private static function get_marketing_children(): array {
|
private static function get_marketing_children(): array
|
||||||
|
{
|
||||||
$children = [];
|
$children = [];
|
||||||
|
|
||||||
// Newsletter - only if module enabled
|
// Newsletter - only if module enabled
|
||||||
if (\WooNooW\Core\ModuleRegistry::is_enabled('newsletter')) {
|
if (\WooNooW\Core\ModuleRegistry::is_enabled('newsletter')) {
|
||||||
$children[] = ['label' => __('Newsletter', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/newsletter'];
|
$children[] = ['label' => __('Newsletter', 'woonoow'), 'mode' => 'spa', 'path' => '/marketing/newsletter'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coupons - always available
|
// Coupons - always available
|
||||||
$children[] = ['label' => __('Coupons', 'woonoow'), 'mode' => 'spa', 'path' => '/coupons'];
|
$children[] = ['label' => __('Coupons', 'woonoow'), 'mode' => 'spa', 'path' => '/coupons'];
|
||||||
|
|
||||||
return $children;
|
return $children;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get settings submenu children
|
* Get settings submenu children
|
||||||
*
|
*
|
||||||
* @return array Settings submenu items
|
* @return array Settings submenu items
|
||||||
*/
|
*/
|
||||||
private static function get_settings_children(): array {
|
private static function get_settings_children(): array
|
||||||
|
{
|
||||||
$admin = admin_url('admin.php');
|
$admin = admin_url('admin.php');
|
||||||
|
|
||||||
$children = [
|
$children = [
|
||||||
// Core Settings (Shopify-inspired)
|
// Core Settings (Shopify-inspired)
|
||||||
['label' => __('Store Details', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/store'],
|
['label' => __('Store Details', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/store'],
|
||||||
@@ -236,25 +243,27 @@ class NavigationRegistry {
|
|||||||
['label' => __('Shipping & Delivery', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/shipping'],
|
['label' => __('Shipping & Delivery', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/shipping'],
|
||||||
['label' => __('Tax', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/tax'],
|
['label' => __('Tax', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/tax'],
|
||||||
['label' => __('Customers', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/customers'],
|
['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' => __('Notifications', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/notifications'],
|
||||||
['label' => __('Modules', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/modules'],
|
['label' => __('Modules', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/modules'],
|
||||||
['label' => __('Developer', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/developer'],
|
['label' => __('Developer', 'woonoow'), 'mode' => 'spa', 'path' => '/settings/developer'],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $children;
|
return $children;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscriptions navigation section
|
* Get subscriptions navigation section
|
||||||
* Returns empty array if module is not enabled
|
* Returns empty array if module is not enabled
|
||||||
*
|
*
|
||||||
* @return array Subscriptions section or empty array
|
* @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')) {
|
if (!\WooNooW\Core\ModuleRegistry::is_enabled('subscription')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'key' => 'subscriptions',
|
'key' => 'subscriptions',
|
||||||
@@ -267,24 +276,26 @@ class NavigationRegistry {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the complete navigation tree
|
* Get the complete navigation tree
|
||||||
*
|
*
|
||||||
* @return array Navigation tree
|
* @return array Navigation tree
|
||||||
*/
|
*/
|
||||||
public static function get_nav_tree(): array {
|
public static function get_nav_tree(): array
|
||||||
|
{
|
||||||
$data = get_option(self::NAV_OPTION, []);
|
$data = get_option(self::NAV_OPTION, []);
|
||||||
return $data['tree'] ?? self::get_base_tree();
|
return $data['tree'] ?? self::get_base_tree();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific section by key
|
* Get a specific section by key
|
||||||
*
|
*
|
||||||
* @param string $key Section key
|
* @param string $key Section key
|
||||||
* @return array|null Section data or null if not found
|
* @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();
|
$tree = self::get_nav_tree();
|
||||||
foreach ($tree as $section) {
|
foreach ($tree as $section) {
|
||||||
if (($section['key'] ?? '') === $key) {
|
if (($section['key'] ?? '') === $key) {
|
||||||
@@ -293,22 +304,24 @@ class NavigationRegistry {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush navigation cache
|
* Flush navigation cache
|
||||||
*/
|
*/
|
||||||
public static function flush() {
|
public static function flush()
|
||||||
|
{
|
||||||
delete_option(self::NAV_OPTION);
|
delete_option(self::NAV_OPTION);
|
||||||
// Rebuild immediately after flush
|
// Rebuild immediately after flush
|
||||||
self::build_nav_tree();
|
self::build_nav_tree();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get navigation tree for frontend
|
* Get navigation tree for frontend
|
||||||
*
|
*
|
||||||
* @return array Array suitable for JSON encoding
|
* @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();
|
return self::get_nav_tree();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
301
includes/Compat/SecuritySettingsProvider.php
Normal file
301
includes/Compat/SecuritySettingsProvider.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Campaign Manager
|
* Campaign Manager
|
||||||
*
|
*
|
||||||
@@ -9,37 +10,43 @@
|
|||||||
|
|
||||||
namespace WooNooW\Core\Campaigns;
|
namespace WooNooW\Core\Campaigns;
|
||||||
|
|
||||||
|
use WooNooW\Database\SubscriberTable;
|
||||||
|
|
||||||
if (!defined('ABSPATH')) exit;
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
class CampaignManager {
|
class CampaignManager
|
||||||
|
{
|
||||||
|
|
||||||
const POST_TYPE = 'wnw_campaign';
|
const POST_TYPE = 'wnw_campaign';
|
||||||
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
|
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
|
||||||
|
|
||||||
private static $instance = null;
|
private static $instance = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get instance
|
* Get instance
|
||||||
*/
|
*/
|
||||||
public static function instance() {
|
public static function instance()
|
||||||
|
{
|
||||||
if (self::$instance === null) {
|
if (self::$instance === null) {
|
||||||
self::$instance = new self();
|
self::$instance = new self();
|
||||||
}
|
}
|
||||||
return self::$instance;
|
return self::$instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
add_action('init', [__CLASS__, 'register_post_type']);
|
add_action('init', [__CLASS__, 'register_post_type']);
|
||||||
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
|
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register campaign post type
|
* Register campaign post type
|
||||||
*/
|
*/
|
||||||
public static function register_post_type() {
|
public static function register_post_type()
|
||||||
|
{
|
||||||
register_post_type(self::POST_TYPE, [
|
register_post_type(self::POST_TYPE, [
|
||||||
'labels' => [
|
'labels' => [
|
||||||
'name' => __('Campaigns', 'woonoow'),
|
'name' => __('Campaigns', 'woonoow'),
|
||||||
@@ -53,32 +60,33 @@ class CampaignManager {
|
|||||||
'map_meta_cap' => true,
|
'map_meta_cap' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new campaign
|
* Create a new campaign
|
||||||
*
|
*
|
||||||
* @param array $data Campaign data
|
* @param array $data Campaign data
|
||||||
* @return int|WP_Error Campaign ID or error
|
* @return int|WP_Error Campaign ID or error
|
||||||
*/
|
*/
|
||||||
public static function create($data) {
|
public static function create($data)
|
||||||
|
{
|
||||||
$post_data = [
|
$post_data = [
|
||||||
'post_type' => self::POST_TYPE,
|
'post_type' => self::POST_TYPE,
|
||||||
'post_status' => 'publish',
|
'post_status' => 'publish',
|
||||||
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
|
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$campaign_id = wp_insert_post($post_data, true);
|
$campaign_id = wp_insert_post($post_data, true);
|
||||||
|
|
||||||
if (is_wp_error($campaign_id)) {
|
if (is_wp_error($campaign_id)) {
|
||||||
return $campaign_id;
|
return $campaign_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save meta fields
|
// Save meta fields
|
||||||
self::update_meta($campaign_id, $data);
|
self::update_meta($campaign_id, $data);
|
||||||
|
|
||||||
return $campaign_id;
|
return $campaign_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update campaign
|
* Update campaign
|
||||||
*
|
*
|
||||||
@@ -86,13 +94,14 @@ class CampaignManager {
|
|||||||
* @param array $data Campaign data
|
* @param array $data Campaign data
|
||||||
* @return bool|WP_Error
|
* @return bool|WP_Error
|
||||||
*/
|
*/
|
||||||
public static function update($campaign_id, $data) {
|
public static function update($campaign_id, $data)
|
||||||
|
{
|
||||||
$post = get_post($campaign_id);
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
|
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update title if provided
|
// Update title if provided
|
||||||
if (isset($data['title'])) {
|
if (isset($data['title'])) {
|
||||||
wp_update_post([
|
wp_update_post([
|
||||||
@@ -100,31 +109,32 @@ class CampaignManager {
|
|||||||
'post_title' => sanitize_text_field($data['title']),
|
'post_title' => sanitize_text_field($data['title']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update meta fields
|
// Update meta fields
|
||||||
self::update_meta($campaign_id, $data);
|
self::update_meta($campaign_id, $data);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update campaign meta
|
* Update campaign meta
|
||||||
*
|
*
|
||||||
* @param int $campaign_id
|
* @param int $campaign_id
|
||||||
* @param array $data
|
* @param array $data
|
||||||
*/
|
*/
|
||||||
private static function update_meta($campaign_id, $data) {
|
private static function update_meta($campaign_id, $data)
|
||||||
|
{
|
||||||
$meta_fields = [
|
$meta_fields = [
|
||||||
'subject' => '_wnw_subject',
|
'subject' => '_wnw_subject',
|
||||||
'content' => '_wnw_content',
|
'content' => '_wnw_content',
|
||||||
'status' => '_wnw_status',
|
'status' => '_wnw_status',
|
||||||
'scheduled_at' => '_wnw_scheduled_at',
|
'scheduled_at' => '_wnw_scheduled_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($meta_fields as $key => $meta_key) {
|
foreach ($meta_fields as $key => $meta_key) {
|
||||||
if (isset($data[$key])) {
|
if (isset($data[$key])) {
|
||||||
$value = $data[$key];
|
$value = $data[$key];
|
||||||
|
|
||||||
// Sanitize based on field type
|
// Sanitize based on field type
|
||||||
if ($key === 'content') {
|
if ($key === 'content') {
|
||||||
$value = wp_kses_post($value);
|
$value = wp_kses_post($value);
|
||||||
@@ -136,40 +146,42 @@ class CampaignManager {
|
|||||||
} else {
|
} else {
|
||||||
$value = sanitize_text_field($value);
|
$value = sanitize_text_field($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
update_post_meta($campaign_id, $meta_key, $value);
|
update_post_meta($campaign_id, $meta_key, $value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default status if not provided
|
// Set default status if not provided
|
||||||
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
|
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
|
||||||
update_post_meta($campaign_id, '_wnw_status', 'draft');
|
update_post_meta($campaign_id, '_wnw_status', 'draft');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get campaign by ID
|
* Get campaign by ID
|
||||||
*
|
*
|
||||||
* @param int $campaign_id
|
* @param int $campaign_id
|
||||||
* @return array|null
|
* @return array|null
|
||||||
*/
|
*/
|
||||||
public static function get($campaign_id) {
|
public static function get($campaign_id)
|
||||||
|
{
|
||||||
$post = get_post($campaign_id);
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::format_campaign($post);
|
return self::format_campaign($post);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all campaigns
|
* Get all campaigns
|
||||||
*
|
*
|
||||||
* @param array $args Query args
|
* @param array $args Query args
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_all($args = []) {
|
public static function get_all($args = [])
|
||||||
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'post_type' => self::POST_TYPE,
|
'post_type' => self::POST_TYPE,
|
||||||
'post_status' => 'any',
|
'post_status' => 'any',
|
||||||
@@ -177,22 +189,23 @@ class CampaignManager {
|
|||||||
'orderby' => 'date',
|
'orderby' => 'date',
|
||||||
'order' => 'DESC',
|
'order' => 'DESC',
|
||||||
];
|
];
|
||||||
|
|
||||||
$query_args = wp_parse_args($args, $defaults);
|
$query_args = wp_parse_args($args, $defaults);
|
||||||
$query_args['post_type'] = self::POST_TYPE; // Force post type
|
$query_args['post_type'] = self::POST_TYPE; // Force post type
|
||||||
|
|
||||||
$posts = get_posts($query_args);
|
$posts = get_posts($query_args);
|
||||||
|
|
||||||
return array_map([__CLASS__, 'format_campaign'], $posts);
|
return array_map([__CLASS__, 'format_campaign'], $posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format campaign post to array
|
* Format campaign post to array
|
||||||
*
|
*
|
||||||
* @param WP_Post $post
|
* @param WP_Post $post
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function format_campaign($post) {
|
private static function format_campaign($post)
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $post->ID,
|
'id' => $post->ID,
|
||||||
'title' => $post->post_title,
|
'title' => $post->post_title,
|
||||||
@@ -208,69 +221,71 @@ class CampaignManager {
|
|||||||
'updated_at' => $post->post_modified,
|
'updated_at' => $post->post_modified,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete campaign
|
* Delete campaign
|
||||||
*
|
*
|
||||||
* @param int $campaign_id
|
* @param int $campaign_id
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function delete($campaign_id) {
|
public static function delete($campaign_id)
|
||||||
|
{
|
||||||
$post = get_post($campaign_id);
|
$post = get_post($campaign_id);
|
||||||
|
|
||||||
if (!$post || $post->post_type !== self::POST_TYPE) {
|
if (!$post || $post->post_type !== self::POST_TYPE) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return wp_delete_post($campaign_id, true) !== false;
|
return wp_delete_post($campaign_id, true) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send campaign
|
* Send campaign
|
||||||
*
|
*
|
||||||
* @param int $campaign_id
|
* @param int $campaign_id
|
||||||
* @return array Result with sent/failed counts
|
* @return array Result with sent/failed counts
|
||||||
*/
|
*/
|
||||||
public static function send($campaign_id) {
|
public static function send($campaign_id)
|
||||||
|
{
|
||||||
$campaign = self::get($campaign_id);
|
$campaign = self::get($campaign_id);
|
||||||
|
|
||||||
if (!$campaign) {
|
if (!$campaign) {
|
||||||
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
|
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($campaign['status'] === 'sent') {
|
if ($campaign['status'] === 'sent') {
|
||||||
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
|
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscribers
|
// Get subscribers
|
||||||
$subscribers = self::get_subscribers();
|
$subscribers = self::get_subscribers();
|
||||||
|
|
||||||
if (empty($subscribers)) {
|
if (empty($subscribers)) {
|
||||||
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
|
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status to sending
|
// Update status to sending
|
||||||
update_post_meta($campaign_id, '_wnw_status', 'sending');
|
update_post_meta($campaign_id, '_wnw_status', 'sending');
|
||||||
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
|
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
|
||||||
|
|
||||||
$sent = 0;
|
$sent = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
|
||||||
// Get email template
|
// Get email template
|
||||||
$template = self::render_campaign_email($campaign);
|
$template = self::render_campaign_email($campaign);
|
||||||
|
|
||||||
// Send in batches
|
// Send in batches
|
||||||
$batch_size = 50;
|
$batch_size = 50;
|
||||||
$batches = array_chunk($subscribers, $batch_size);
|
$batches = array_chunk($subscribers, $batch_size);
|
||||||
|
|
||||||
foreach ($batches as $batch) {
|
foreach ($batches as $batch) {
|
||||||
foreach ($batch as $subscriber) {
|
foreach ($batch as $subscriber) {
|
||||||
$email = $subscriber['email'];
|
$email = $subscriber['email'];
|
||||||
|
|
||||||
// Replace subscriber-specific variables
|
// Replace subscriber-specific variables
|
||||||
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||||
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
|
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
$result = wp_mail(
|
$result = wp_mail(
|
||||||
$email,
|
$email,
|
||||||
@@ -278,26 +293,26 @@ class CampaignManager {
|
|||||||
$body,
|
$body,
|
||||||
['Content-Type: text/html; charset=UTF-8']
|
['Content-Type: text/html; charset=UTF-8']
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$sent++;
|
$sent++;
|
||||||
} else {
|
} else {
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between batches
|
// Small delay between batches
|
||||||
if (count($batches) > 1) {
|
if (count($batches) > 1) {
|
||||||
sleep(2);
|
sleep(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update campaign stats
|
// Update campaign stats
|
||||||
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
|
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
|
||||||
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
|
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
|
||||||
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
|
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
|
||||||
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
|
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'sent' => $sent,
|
'sent' => $sent,
|
||||||
@@ -305,7 +320,7 @@ class CampaignManager {
|
|||||||
'total' => count($subscribers),
|
'total' => count($subscribers),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send test email
|
* Send test email
|
||||||
*
|
*
|
||||||
@@ -313,19 +328,20 @@ class CampaignManager {
|
|||||||
* @param string $email Test email address
|
* @param string $email Test email address
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function send_test($campaign_id, $email) {
|
public static function send_test($campaign_id, $email)
|
||||||
|
{
|
||||||
$campaign = self::get($campaign_id);
|
$campaign = self::get($campaign_id);
|
||||||
|
|
||||||
if (!$campaign) {
|
if (!$campaign) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$template = self::render_campaign_email($campaign);
|
$template = self::render_campaign_email($campaign);
|
||||||
|
|
||||||
// Replace subscriber-specific variables
|
// Replace subscriber-specific variables
|
||||||
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
$body = str_replace('{subscriber_email}', $email, $template['body']);
|
||||||
$body = str_replace('{unsubscribe_url}', '#', $body);
|
$body = str_replace('{unsubscribe_url}', '#', $body);
|
||||||
|
|
||||||
return wp_mail(
|
return wp_mail(
|
||||||
$email,
|
$email,
|
||||||
'[TEST] ' . $template['subject'],
|
'[TEST] ' . $template['subject'],
|
||||||
@@ -333,43 +349,49 @@ class CampaignManager {
|
|||||||
['Content-Type: text/html; charset=UTF-8']
|
['Content-Type: text/html; charset=UTF-8']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render campaign email using EmailRenderer
|
* Render campaign email using EmailRenderer
|
||||||
*
|
*
|
||||||
* @param array $campaign
|
* @param array $campaign
|
||||||
* @return array ['subject' => string, 'body' => string]
|
* @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();
|
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
|
||||||
|
|
||||||
// Get the campaign email template
|
// Get the campaign email template
|
||||||
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
|
||||||
|
|
||||||
// Fallback if no template configured
|
// Fallback if no template configured
|
||||||
if (!$template) {
|
if (!$template) {
|
||||||
$subject = $campaign['subject'] ?: $campaign['title'];
|
$subject = $campaign['subject'] ?: $campaign['title'];
|
||||||
$body = $campaign['content'];
|
$body = $campaign['content'];
|
||||||
} else {
|
} else {
|
||||||
$subject = $template['subject'] ?: $campaign['subject'];
|
$subject = $template['subject'] ?: $campaign['subject'];
|
||||||
|
|
||||||
// Replace {content} with campaign content
|
// Replace {content} with campaign content
|
||||||
$body = str_replace('{content}', $campaign['content'], $template['body']);
|
$body = str_replace('{content}', $campaign['content'], $template['body']);
|
||||||
|
|
||||||
// Replace {campaign_title}
|
// Replace {campaign_title}
|
||||||
$body = str_replace('{campaign_title}', $campaign['title'], $body);
|
$body = str_replace('{campaign_title}', $campaign['title'], $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace common variables
|
// Replace common variables
|
||||||
$site_name = get_bloginfo('name');
|
$site_name = get_bloginfo('name');
|
||||||
$site_url = home_url();
|
$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);
|
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
|
||||||
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
|
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
|
||||||
$body = str_replace('{site_url}', $site_url, $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_date}', date_i18n(get_option('date_format')), $body);
|
||||||
$body = str_replace('{current_year}', date('Y'), $body);
|
$body = str_replace('{current_year}', date('Y'), $body);
|
||||||
|
|
||||||
|
// Parse card shortcodes before rendering
|
||||||
|
$body = $renderer->parse_cards($body);
|
||||||
|
|
||||||
// Render through email design template
|
// Render through email design template
|
||||||
$design_path = $renderer->get_design_template();
|
$design_path = $renderer->get_design_template();
|
||||||
if (file_exists($design_path)) {
|
if (file_exists($design_path)) {
|
||||||
@@ -378,69 +400,66 @@ class CampaignManager {
|
|||||||
'site_url' => $site_url,
|
'site_url' => $site_url,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'subject' => $subject,
|
'subject' => $subject,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscribers
|
* Get subscribers
|
||||||
*
|
*
|
||||||
|
* @param array $filters Optional audience filters
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_subscribers() {
|
private static function get_subscribers($filters = [])
|
||||||
// Check if using custom table
|
{
|
||||||
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
|
// Use SubscriberTable if available
|
||||||
|
if (SubscriberTable::table_exists()) {
|
||||||
if ($use_table && self::has_subscribers_table()) {
|
return SubscriberTable::get_active($filters);
|
||||||
global $wpdb;
|
|
||||||
$table = $wpdb->prefix . 'woonoow_subscribers';
|
|
||||||
return $wpdb->get_results(
|
|
||||||
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
|
|
||||||
ARRAY_A
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use wp_options storage
|
// Legacy: use wp_options storage
|
||||||
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
$subscribers = get_option('woonoow_newsletter_subscribers', []);
|
||||||
return array_filter($subscribers, function($sub) {
|
return array_filter($subscribers, function ($sub) {
|
||||||
return ($sub['status'] ?? 'active') === 'active';
|
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
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private static function has_subscribers_table() {
|
private static function has_subscribers_table()
|
||||||
global $wpdb;
|
{
|
||||||
$table = $wpdb->prefix . 'woonoow_subscribers';
|
return SubscriberTable::table_exists();
|
||||||
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unsubscribe URL
|
* Get unsubscribe URL
|
||||||
*
|
*
|
||||||
* @param string $email
|
* @param string $email
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function get_unsubscribe_url($email) {
|
private static function get_unsubscribe_url($email)
|
||||||
|
{
|
||||||
// Use NewsletterController's secure token-based URL
|
// Use NewsletterController's secure token-based URL
|
||||||
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
|
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process scheduled campaigns (WP-Cron)
|
* Process scheduled campaigns (WP-Cron)
|
||||||
*/
|
*/
|
||||||
public static function process_scheduled_campaigns() {
|
public static function process_scheduled_campaigns()
|
||||||
|
{
|
||||||
// Only if scheduling is enabled
|
// Only if scheduling is enabled
|
||||||
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
|
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$campaigns = self::get_all([
|
$campaigns = self::get_all([
|
||||||
'meta_query' => [
|
'meta_query' => [
|
||||||
[
|
[
|
||||||
@@ -455,25 +474,27 @@ class CampaignManager {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($campaigns as $campaign) {
|
foreach ($campaigns as $campaign) {
|
||||||
self::send($campaign['id']);
|
self::send($campaign['id']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable scheduling (registers cron)
|
* Enable scheduling (registers cron)
|
||||||
*/
|
*/
|
||||||
public static function enable_scheduling() {
|
public static function enable_scheduling()
|
||||||
|
{
|
||||||
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||||
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
|
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable scheduling (clears cron)
|
* Disable scheduling (clears cron)
|
||||||
*/
|
*/
|
||||||
public static function disable_scheduling() {
|
public static function disable_scheduling()
|
||||||
|
{
|
||||||
wp_clear_scheduled_hook(self::CRON_HOOK);
|
wp_clear_scheduled_hook(self::CRON_HOOK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
includes/Core/Notifications/ChannelRegistry.php
Normal file
107
includes/Core/Notifications/ChannelRegistry.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
includes/Core/Notifications/Channels/ChannelInterface.php
Normal file
57
includes/Core/Notifications/Channels/ChannelInterface.php
Normal 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();
|
||||||
|
}
|
||||||
252
includes/Core/Notifications/Channels/WhatsAppChannel.example.php
Normal file
252
includes/Core/Notifications/Channels/WhatsAppChannel.example.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Email Templates (DEPRECATED)
|
* Default Email Templates (DEPRECATED)
|
||||||
*
|
*
|
||||||
@@ -17,8 +18,9 @@ namespace WooNooW\Core\Notifications;
|
|||||||
|
|
||||||
use WooNooW\Email\DefaultTemplates as NewDefaultTemplates;
|
use WooNooW\Email\DefaultTemplates as NewDefaultTemplates;
|
||||||
|
|
||||||
class DefaultEmailTemplates {
|
class DefaultEmailTemplates
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default template for an event and recipient type
|
* Get default template for an event and recipient type
|
||||||
*
|
*
|
||||||
@@ -26,28 +28,30 @@ class DefaultEmailTemplates {
|
|||||||
* @param string $recipient_type 'staff' or 'customer'
|
* @param string $recipient_type 'staff' or 'customer'
|
||||||
* @return array ['subject' => string, 'body' => string]
|
* @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
|
// Get templates directly from this class
|
||||||
$allTemplates = self::get_all_templates();
|
$allTemplates = self::get_all_templates();
|
||||||
|
|
||||||
// Check if event exists for this recipient type
|
// Check if event exists for this recipient type
|
||||||
if (isset($allTemplates[$event_id][$recipient_type])) {
|
if (isset($allTemplates[$event_id][$recipient_type])) {
|
||||||
return $allTemplates[$event_id][$recipient_type];
|
return $allTemplates[$event_id][$recipient_type];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback
|
// Fallback
|
||||||
return [
|
return [
|
||||||
'subject' => __('Notification from {store_name}', 'woonoow'),
|
'subject' => __('Notification from {store_name}', 'woonoow'),
|
||||||
'body' => '[card]' . __('You have a new notification.', 'woonoow') . '[/card]',
|
'body' => '[card]' . __('You have a new notification.', 'woonoow') . '[/card]',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all default templates (legacy method - kept for backwards compatibility)
|
* Get all default templates (legacy method - kept for backwards compatibility)
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_all_templates() {
|
private static function get_all_templates()
|
||||||
|
{
|
||||||
// This method is now deprecated but kept for backwards compatibility
|
// This method is now deprecated but kept for backwards compatibility
|
||||||
// Use WooNooW\Email\DefaultTemplates instead
|
// Use WooNooW\Email\DefaultTemplates instead
|
||||||
return [
|
return [
|
||||||
@@ -83,7 +87,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
|
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'order_processing' => [
|
'order_processing' => [
|
||||||
'customer' => [
|
'customer' => [
|
||||||
'subject' => __('Your Order #{order_number} is Being Processed', 'woonoow'),
|
'subject' => __('Your Order #{order_number} is Being Processed', 'woonoow'),
|
||||||
@@ -112,7 +116,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{order_url}" style="solid"]' . __('Track Your Order', 'woonoow') . '[/button]',
|
[button url="{order_url}" style="solid"]' . __('Track Your Order', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'order_completed' => [
|
'order_completed' => [
|
||||||
'customer' => [
|
'customer' => [
|
||||||
'subject' => __('Your Order #{order_number} is Complete', 'woonoow'),
|
'subject' => __('Your Order #{order_number} is Complete', 'woonoow'),
|
||||||
@@ -135,10 +139,10 @@ class DefaultEmailTemplates {
|
|||||||
[/card]
|
[/card]
|
||||||
|
|
||||||
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]
|
[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]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'order_cancelled' => [
|
'order_cancelled' => [
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'subject' => __('Order #{order_number} Cancelled', 'woonoow'),
|
'subject' => __('Order #{order_number} Cancelled', 'woonoow'),
|
||||||
@@ -158,7 +162,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
|
[button url="{order_url}" style="solid"]' . __('View Order Details', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'order_refunded' => [
|
'order_refunded' => [
|
||||||
'customer' => [
|
'customer' => [
|
||||||
'subject' => __('Your Order #{order_number} Has Been Refunded', 'woonoow'),
|
'subject' => __('Your Order #{order_number} Has Been Refunded', 'woonoow'),
|
||||||
@@ -183,7 +187,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]',
|
[button url="{order_url}" style="solid"]' . __('View Order', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
// PRODUCT EVENTS
|
// PRODUCT EVENTS
|
||||||
'low_stock' => [
|
'low_stock' => [
|
||||||
'staff' => [
|
'staff' => [
|
||||||
@@ -209,7 +213,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{product_url}" style="solid"]' . __('View Product', 'woonoow') . '[/button]',
|
[button url="{product_url}" style="solid"]' . __('View Product', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'out_of_stock' => [
|
'out_of_stock' => [
|
||||||
'staff' => [
|
'staff' => [
|
||||||
'subject' => __('Out of Stock Alert: {product_name}', 'woonoow'),
|
'subject' => __('Out of Stock Alert: {product_name}', 'woonoow'),
|
||||||
@@ -233,7 +237,7 @@ class DefaultEmailTemplates {
|
|||||||
[button url="{product_url}" style="solid"]' . __('Manage Product', 'woonoow') . '[/button]',
|
[button url="{product_url}" style="solid"]' . __('Manage Product', 'woonoow') . '[/button]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
// CUSTOMER EVENTS
|
// CUSTOMER EVENTS
|
||||||
'new_customer' => [
|
'new_customer' => [
|
||||||
'customer' => [
|
'customer' => [
|
||||||
@@ -261,10 +265,10 @@ class DefaultEmailTemplates {
|
|||||||
[/card]
|
[/card]
|
||||||
|
|
||||||
[button url="{account_url}" style="solid"]' . __('Go to My Account', 'woonoow') . '[/button]
|
[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]',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'customer_note' => [
|
'customer_note' => [
|
||||||
'customer' => [
|
'customer' => [
|
||||||
'subject' => __('Note Added to Your Order #{order_number}', 'woonoow'),
|
'subject' => __('Note Added to Your Order #{order_number}', 'woonoow'),
|
||||||
@@ -289,16 +293,17 @@ class DefaultEmailTemplates {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all new templates (direct access to new class)
|
* Get all new templates (direct access to new class)
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_new_templates() {
|
public static function get_new_templates()
|
||||||
|
{
|
||||||
return NewDefaultTemplates::get_all_templates();
|
return NewDefaultTemplates::get_all_templates();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default subject from new templates
|
* Get default subject from new templates
|
||||||
*
|
*
|
||||||
@@ -306,7 +311,8 @@ class DefaultEmailTemplates {
|
|||||||
* @param string $event_id Event ID
|
* @param string $event_id Event ID
|
||||||
* @return string
|
* @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);
|
return NewDefaultTemplates::get_default_subject($recipient_type, $event_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class EmailRenderer
|
|||||||
* @param string $recipient_type
|
* @param string $recipient_type
|
||||||
* @return array|null
|
* @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)
|
// Get saved template (with recipient_type for proper default template lookup)
|
||||||
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
|
$template = TemplateProvider::get_template($event_id, 'email', $recipient_type);
|
||||||
@@ -187,7 +187,7 @@ class EmailRenderer
|
|||||||
'site_name' => get_bloginfo('name'),
|
'site_name' => get_bloginfo('name'),
|
||||||
'site_title' => get_bloginfo('name'),
|
'site_title' => get_bloginfo('name'),
|
||||||
'store_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')),
|
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||||
'support_email' => get_option('admin_email'),
|
'support_email' => get_option('admin_email'),
|
||||||
@@ -381,7 +381,7 @@ class EmailRenderer
|
|||||||
* @param string $content
|
* @param string $content
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function parse_cards($content)
|
public function parse_cards($content)
|
||||||
{
|
{
|
||||||
// Use a single unified regex to match BOTH syntaxes in document order
|
// Use a single unified regex to match BOTH syntaxes in document order
|
||||||
// This ensures cards are rendered in the order they appear
|
// This ensures cards are rendered in the order they appear
|
||||||
@@ -473,8 +473,31 @@ class EmailRenderer
|
|||||||
$hero_text_color = '#ffffff'; // Always white on gradient
|
$hero_text_color = '#ffffff'; // Always white on gradient
|
||||||
|
|
||||||
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
|
// 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
|
// 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') {
|
if ($style === 'outline') {
|
||||||
// Outline button - transparent background with border
|
// Outline button - transparent background with border
|
||||||
$button_style = sprintf(
|
$button_style = sprintf(
|
||||||
@@ -494,7 +517,7 @@ class EmailRenderer
|
|||||||
// Use table-based button for better email client compatibility
|
// Use table-based button for better email client compatibility
|
||||||
return sprintf(
|
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>',
|
'<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,
|
$button_style,
|
||||||
esc_html($text)
|
esc_html($text)
|
||||||
);
|
);
|
||||||
@@ -542,9 +565,25 @@ class EmailRenderer
|
|||||||
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
|
$content_style .= sprintf(' color: %s;', esc_attr($hero_text_color));
|
||||||
|
|
||||||
// Add inline color to all headings and paragraphs for email client compatibility
|
// Add inline color to all headings and paragraphs for email client compatibility
|
||||||
$content = preg_replace(
|
// Preserve existing style attributes (like text-align) by appending to them
|
||||||
'/<(h[1-6]|p)([^>]*)>/',
|
$content = preg_replace_callback(
|
||||||
'<$1$2 style="color: ' . esc_attr($hero_text_color) . ';">',
|
'/<(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
|
$content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -560,6 +599,11 @@ class EmailRenderer
|
|||||||
elseif ($type === 'warning') {
|
elseif ($type === 'warning') {
|
||||||
$style .= ' background-color: #fff8e1;';
|
$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
|
// Add background image
|
||||||
@@ -616,7 +660,7 @@ class EmailRenderer
|
|||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function get_design_template()
|
public function get_design_template()
|
||||||
{
|
{
|
||||||
// Use single base template (theme-agnostic)
|
// Use single base template (theme-agnostic)
|
||||||
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
$template_path = WOONOOW_PATH . 'templates/emails/base.html';
|
||||||
@@ -641,7 +685,7 @@ class EmailRenderer
|
|||||||
* @param array $variables All variables
|
* @param array $variables All variables
|
||||||
* @return string
|
* @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)) {
|
if (!file_exists($template_path)) {
|
||||||
// Fallback to plain HTML
|
// Fallback to plain HTML
|
||||||
@@ -654,6 +698,10 @@ class EmailRenderer
|
|||||||
// Get email customization settings
|
// Get email customization settings
|
||||||
$email_settings = get_option('woonoow_email_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
|
// Email body background
|
||||||
$body_bg = '#f8f8f8';
|
$body_bg = '#f8f8f8';
|
||||||
|
|
||||||
@@ -668,7 +716,7 @@ class EmailRenderer
|
|||||||
if (!empty($logo_url)) {
|
if (!empty($logo_url)) {
|
||||||
$header = sprintf(
|
$header = sprintf(
|
||||||
'<a href="%s"><img src="%s" alt="%s" style="max-width: 200px; max-height: 60px;"></a>',
|
'<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_url($logo_url),
|
||||||
esc_attr($variables['store_name'])
|
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_text = !empty($email_settings['header_text']) ? $email_settings['header_text'] : $variables['store_name'];
|
||||||
$header = sprintf(
|
$header = sprintf(
|
||||||
'<a href="%s" style="font-size: 24px; font-weight: 700; color: #333; text-decoration: none;">%s</a>',
|
'<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)
|
esc_html($header_text)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -724,7 +772,7 @@ class EmailRenderer
|
|||||||
$html = str_replace('{{email_content}}', $content, $html);
|
$html = str_replace('{{email_content}}', $content, $html);
|
||||||
$html = str_replace('{{email_footer}}', $footer, $html);
|
$html = str_replace('{{email_footer}}', $footer, $html);
|
||||||
$html = str_replace('{{store_name}}', esc_html($variables['store_name']), $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);
|
$html = str_replace('{{current_year}}', date('Y'), $html);
|
||||||
|
|
||||||
// Replace all other variables
|
// Replace all other variables
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown to Email HTML Parser
|
* Markdown to Email HTML Parser
|
||||||
*
|
*
|
||||||
@@ -17,21 +18,23 @@
|
|||||||
|
|
||||||
namespace WooNooW\Core\Notifications;
|
namespace WooNooW\Core\Notifications;
|
||||||
|
|
||||||
class MarkdownParser {
|
class MarkdownParser
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse markdown to email HTML
|
* Parse markdown to email HTML
|
||||||
*
|
*
|
||||||
* @param string $markdown
|
* @param string $markdown
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function parse($markdown) {
|
public static function parse($markdown)
|
||||||
|
{
|
||||||
$html = $markdown;
|
$html = $markdown;
|
||||||
|
|
||||||
// Parse card blocks first (:::card or :::card[type])
|
// Parse card blocks first (:::card or :::card[type])
|
||||||
$html = preg_replace_callback(
|
$html = preg_replace_callback(
|
||||||
'/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/s',
|
'/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/s',
|
||||||
function($matches) {
|
function ($matches) {
|
||||||
$type = $matches[1] ?? '';
|
$type = $matches[1] ?? '';
|
||||||
$content = trim($matches[2]);
|
$content = trim($matches[2]);
|
||||||
$parsed_content = self::parse_basics($content);
|
$parsed_content = self::parse_basics($content);
|
||||||
@@ -39,12 +42,12 @@ class MarkdownParser {
|
|||||||
},
|
},
|
||||||
$html
|
$html
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse button blocks [button url="..."]Text[/button] - already in correct format
|
// Parse button blocks [button url="..."]Text[/button] - already in correct format
|
||||||
// Also support legacy [button](url){text} syntax
|
// Also support legacy [button](url){text} syntax
|
||||||
$html = preg_replace_callback(
|
$html = preg_replace_callback(
|
||||||
'/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/',
|
'/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/',
|
||||||
function($matches) {
|
function ($matches) {
|
||||||
$style = $matches[1] ?? '';
|
$style = $matches[1] ?? '';
|
||||||
$url = $matches[2];
|
$url = $matches[2];
|
||||||
$text = $matches[3];
|
$text = $matches[3];
|
||||||
@@ -52,71 +55,88 @@ class MarkdownParser {
|
|||||||
},
|
},
|
||||||
$html
|
$html
|
||||||
);
|
);
|
||||||
|
|
||||||
// Horizontal rules
|
// Horizontal rules
|
||||||
$html = preg_replace('/^---$/m', '<hr>', $html);
|
$html = preg_replace('/^---$/m', '<hr>', $html);
|
||||||
|
|
||||||
// Parse remaining markdown (outside cards)
|
// Parse remaining markdown (outside cards)
|
||||||
$html = self::parse_basics($html);
|
$html = self::parse_basics($html);
|
||||||
|
|
||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse basic markdown syntax
|
* Parse basic markdown syntax
|
||||||
*
|
*
|
||||||
* @param string $text
|
* @param string $text
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function parse_basics($text) {
|
private static function parse_basics($text)
|
||||||
|
{
|
||||||
$html = $text;
|
$html = $text;
|
||||||
|
|
||||||
// Protect variables from markdown parsing by temporarily replacing them
|
// Protect variables from markdown parsing by temporarily replacing them
|
||||||
$variables = [];
|
$variables = [];
|
||||||
$var_index = 0;
|
$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 . '-->';
|
$placeholder = '<!--VAR' . $var_index . '-->';
|
||||||
$variables[$placeholder] = $matches[0];
|
$variables[$placeholder] = $matches[0];
|
||||||
$var_index++;
|
$var_index++;
|
||||||
return $placeholder;
|
return $placeholder;
|
||||||
}, $html);
|
}, $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)
|
// 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', '<h4>$1</h4>', $html);
|
||||||
$html = preg_replace('/^### (.*)$/m', '<h3>$1</h3>', $html);
|
$html = preg_replace('/^### (.*)$/m', '<h3>$1</h3>', $html);
|
||||||
$html = preg_replace('/^## (.*)$/m', '<h2>$1</h2>', $html);
|
$html = preg_replace('/^## (.*)$/m', '<h2>$1</h2>', $html);
|
||||||
$html = preg_replace('/^# (.*)$/m', '<h1>$1</h1>', $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)
|
// Bold (don't match across newlines)
|
||||||
$html = preg_replace('/\*\*([^\n*]+?)\*\*/', '<strong>$1</strong>', $html);
|
$html = preg_replace('/\*\*([^\n*]+?)\*\*/', '<strong>$1</strong>', $html);
|
||||||
$html = preg_replace('/__([^\n_]+?)__/', '<strong>$1</strong>', $html);
|
$html = preg_replace('/__([^\n_]+?)__/', '<strong>$1</strong>', $html);
|
||||||
|
|
||||||
// Italic (don't match across newlines)
|
// Italic (don't match across newlines)
|
||||||
$html = preg_replace('/\*([^\n*]+?)\*/', '<em>$1</em>', $html);
|
$html = preg_replace('/\*([^\n*]+?)\*/', '<em>$1</em>', $html);
|
||||||
$html = preg_replace('/_([^\n_]+?)_/', '<em>$1</em>', $html);
|
$html = preg_replace('/_([^\n_]+?)_/', '<em>$1</em>', $html);
|
||||||
|
|
||||||
// Horizontal rules
|
// Horizontal rules
|
||||||
$html = preg_replace('/^---$/m', '<hr>', $html);
|
$html = preg_replace('/^---$/m', '<hr>', $html);
|
||||||
|
|
||||||
// Links (but not button syntax)
|
// Links (but not button syntax)
|
||||||
$html = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $html);
|
$html = preg_replace('/\[(?!button)([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $html);
|
||||||
|
|
||||||
// Process lines for paragraphs and lists
|
// Process lines for paragraphs and lists
|
||||||
$lines = explode("\n", $html);
|
$lines = explode("\n", $html);
|
||||||
$in_list = false;
|
$in_list = false;
|
||||||
$paragraph_content = '';
|
$paragraph_content = '';
|
||||||
$processed_lines = [];
|
$processed_lines = [];
|
||||||
|
|
||||||
$close_paragraph = function() use (&$paragraph_content, &$processed_lines) {
|
$close_paragraph = function () use (&$paragraph_content, &$processed_lines) {
|
||||||
if ($paragraph_content) {
|
if ($paragraph_content) {
|
||||||
$processed_lines[] = '<p>' . $paragraph_content . '</p>';
|
$processed_lines[] = '<p>' . $paragraph_content . '</p>';
|
||||||
$paragraph_content = '';
|
$paragraph_content = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
foreach ($lines as $line) {
|
||||||
$trimmed = trim($line);
|
$trimmed = trim($line);
|
||||||
|
|
||||||
// Empty line - close paragraph or list
|
// Empty line - close paragraph or list
|
||||||
if (empty($trimmed)) {
|
if (empty($trimmed)) {
|
||||||
if ($in_list) {
|
if ($in_list) {
|
||||||
@@ -127,7 +147,7 @@ class MarkdownParser {
|
|||||||
$processed_lines[] = '';
|
$processed_lines[] = '';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if line is a list item
|
// Check if line is a list item
|
||||||
if (preg_match('/^[\*\-•✓✔]\s/', $trimmed)) {
|
if (preg_match('/^[\*\-•✓✔]\s/', $trimmed)) {
|
||||||
$close_paragraph();
|
$close_paragraph();
|
||||||
@@ -139,20 +159,20 @@ class MarkdownParser {
|
|||||||
$processed_lines[] = '<li>' . $content . '</li>';
|
$processed_lines[] = '<li>' . $content . '</li>';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close list if we're in one
|
// Close list if we're in one
|
||||||
if ($in_list) {
|
if ($in_list) {
|
||||||
$processed_lines[] = '</ul>';
|
$processed_lines[] = '</ul>';
|
||||||
$in_list = false;
|
$in_list = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block-level HTML tags - don't wrap in paragraph
|
// Block-level HTML tags - don't wrap in paragraph
|
||||||
if (preg_match('/^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i', $trimmed)) {
|
if (preg_match('/^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i', $trimmed)) {
|
||||||
$close_paragraph();
|
$close_paragraph();
|
||||||
$processed_lines[] = $line;
|
$processed_lines[] = $line;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular text line - accumulate in paragraph
|
// Regular text line - accumulate in paragraph
|
||||||
if ($paragraph_content) {
|
if ($paragraph_content) {
|
||||||
// Add line break before continuation (THIS IS THE KEY FIX!)
|
// Add line break before continuation (THIS IS THE KEY FIX!)
|
||||||
@@ -162,30 +182,31 @@ class MarkdownParser {
|
|||||||
$paragraph_content = $trimmed;
|
$paragraph_content = $trimmed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close any open tags
|
// Close any open tags
|
||||||
if ($in_list) {
|
if ($in_list) {
|
||||||
$processed_lines[] = '</ul>';
|
$processed_lines[] = '</ul>';
|
||||||
}
|
}
|
||||||
$close_paragraph();
|
$close_paragraph();
|
||||||
|
|
||||||
$html = implode("\n", $processed_lines);
|
$html = implode("\n", $processed_lines);
|
||||||
|
|
||||||
// Restore variables
|
// Restore variables
|
||||||
foreach ($variables as $placeholder => $original) {
|
foreach ($variables as $placeholder => $original) {
|
||||||
$html = str_replace($placeholder, $original, $html);
|
$html = str_replace($placeholder, $original, $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert newlines to <br> tags for email rendering
|
* Convert newlines to <br> tags for email rendering
|
||||||
*
|
*
|
||||||
* @param string $html
|
* @param string $html
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function nl2br_email($html) {
|
public static function nl2br_email($html)
|
||||||
|
{
|
||||||
// Don't convert newlines inside HTML tags
|
// Don't convert newlines inside HTML tags
|
||||||
$html = preg_replace('/(?<!>)\n(?!<)/', '<br>', $html);
|
$html = preg_replace('/(?<!>)\n(?!<)/', '<br>', $html);
|
||||||
return $html;
|
return $html;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification Manager
|
* Notification Manager
|
||||||
*
|
*
|
||||||
@@ -9,32 +10,43 @@
|
|||||||
|
|
||||||
namespace WooNooW\Core\Notifications;
|
namespace WooNooW\Core\Notifications;
|
||||||
|
|
||||||
class NotificationManager {
|
use WooNooW\Core\Notifications\ChannelRegistry;
|
||||||
|
|
||||||
|
class NotificationManager
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a channel is enabled globally
|
* Check if a channel is enabled globally
|
||||||
*
|
*
|
||||||
* @param string $channel_id Channel ID (email, push, etc.)
|
* @param string $channel_id Channel ID (email, push, etc.)
|
||||||
* @return bool
|
* @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') {
|
if ($channel_id === 'email') {
|
||||||
return (bool) get_option('woonoow_email_notifications_enabled', true);
|
return (bool) get_option('woonoow_email_notifications_enabled', true);
|
||||||
} elseif ($channel_id === 'push') {
|
} elseif ($channel_id === 'push') {
|
||||||
return (bool) get_option('woonoow_push_notifications_enabled', true);
|
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', []);
|
$channels = apply_filters('woonoow_notification_channels', []);
|
||||||
foreach ($channels as $channel) {
|
foreach ($channels as $channel) {
|
||||||
if ($channel['id'] === $channel_id) {
|
if ($channel['id'] === $channel_id) {
|
||||||
return isset($channel['enabled']) ? (bool) $channel['enabled'] : true;
|
return isset($channel['enabled']) ? (bool) $channel['enabled'] : true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a channel is enabled for a specific event
|
* Check if a channel is enabled for a specific event
|
||||||
*
|
*
|
||||||
@@ -42,24 +54,25 @@ class NotificationManager {
|
|||||||
* @param string $channel_id Channel ID
|
* @param string $channel_id Channel ID
|
||||||
* @return bool
|
* @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', []);
|
$settings = get_option('woonoow_notification_settings', []);
|
||||||
|
|
||||||
if (!isset($settings[$event_id])) {
|
if (!isset($settings[$event_id])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$event = $settings[$event_id];
|
$event = $settings[$event_id];
|
||||||
|
|
||||||
if (!isset($event['channels'][$channel_id])) {
|
if (!isset($event['channels'][$channel_id])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isset($event['channels'][$channel_id]['enabled'])
|
return isset($event['channels'][$channel_id]['enabled'])
|
||||||
? (bool) $event['channels'][$channel_id]['enabled']
|
? (bool) $event['channels'][$channel_id]['enabled']
|
||||||
: false;
|
: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if notification should be sent
|
* Check if notification should be sent
|
||||||
*
|
*
|
||||||
@@ -69,26 +82,27 @@ class NotificationManager {
|
|||||||
* @param string $channel_id Channel ID
|
* @param string $channel_id Channel ID
|
||||||
* @return bool
|
* @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
|
// Check if WooNooW notification system is enabled
|
||||||
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
|
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
|
||||||
if ($system_mode !== 'woonoow') {
|
if ($system_mode !== 'woonoow') {
|
||||||
return false; // Use WooCommerce default emails instead
|
return false; // Use WooCommerce default emails instead
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if channel is globally enabled
|
// Check if channel is globally enabled
|
||||||
if (!self::is_channel_enabled($channel_id)) {
|
if (!self::is_channel_enabled($channel_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if channel is enabled for this specific event
|
// Check if channel is enabled for this specific event
|
||||||
if (!self::is_event_channel_enabled($event_id, $channel_id)) {
|
if (!self::is_event_channel_enabled($event_id, $channel_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get recipient for event channel
|
* Get recipient for event channel
|
||||||
*
|
*
|
||||||
@@ -96,16 +110,17 @@ class NotificationManager {
|
|||||||
* @param string $channel_id Channel ID
|
* @param string $channel_id Channel ID
|
||||||
* @return string Recipient type (admin, customer, both)
|
* @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', []);
|
$settings = get_option('woonoow_notification_settings', []);
|
||||||
|
|
||||||
if (!isset($settings[$event_id]['channels'][$channel_id]['recipient'])) {
|
if (!isset($settings[$event_id]['channels'][$channel_id]['recipient'])) {
|
||||||
return 'admin';
|
return 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $settings[$event_id]['channels'][$channel_id]['recipient'];
|
return $settings[$event_id]['channels'][$channel_id]['recipient'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification through specified channel
|
* Send notification through specified channel
|
||||||
*
|
*
|
||||||
@@ -114,16 +129,25 @@ class NotificationManager {
|
|||||||
* @param array $data Notification data
|
* @param array $data Notification data
|
||||||
* @return bool Success status
|
* @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
|
// Validate if notification should be sent
|
||||||
if (!self::should_send_notification($event_id, $channel_id)) {
|
if (!self::should_send_notification($event_id, $channel_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recipient
|
// Get recipient
|
||||||
$recipient = self::get_recipient($event_id, $channel_id);
|
$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(
|
$sent = apply_filters(
|
||||||
'woonoow_send_notification',
|
'woonoow_send_notification',
|
||||||
false,
|
false,
|
||||||
@@ -132,22 +156,22 @@ class NotificationManager {
|
|||||||
$recipient,
|
$recipient,
|
||||||
$data
|
$data
|
||||||
);
|
);
|
||||||
|
|
||||||
// If addon handled it, return
|
// If addon handled it, return
|
||||||
if ($sent !== false) {
|
if ($sent !== false) {
|
||||||
return $sent;
|
return $sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle built-in channels
|
// Handle built-in channels (email, push)
|
||||||
if ($channel_id === 'email') {
|
if ($channel_id === 'email') {
|
||||||
return self::send_email($event_id, $recipient, $data);
|
return self::send_email($event_id, $recipient, $data);
|
||||||
} elseif ($channel_id === 'push') {
|
} elseif ($channel_id === 'push') {
|
||||||
return self::send_push($event_id, $recipient, $data);
|
return self::send_push($event_id, $recipient, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send email notification
|
* Send email notification
|
||||||
*
|
*
|
||||||
@@ -156,25 +180,26 @@ class NotificationManager {
|
|||||||
* @param array $data Notification data
|
* @param array $data Notification data
|
||||||
* @return bool
|
* @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
|
// Use EmailRenderer to render the email
|
||||||
$renderer = EmailRenderer::instance();
|
$renderer = EmailRenderer::instance();
|
||||||
$email_data = $renderer->render($event_id, $recipient, $data['order'] ?? $data['product'] ?? $data['customer'] ?? null, $data);
|
$email_data = $renderer->render($event_id, $recipient, $data['order'] ?? $data['product'] ?? $data['customer'] ?? null, $data);
|
||||||
|
|
||||||
if (!$email_data) {
|
if (!$email_data) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email using wp_mail
|
// Send email using wp_mail
|
||||||
$headers = ['Content-Type: text/html; charset=UTF-8'];
|
$headers = ['Content-Type: text/html; charset=UTF-8'];
|
||||||
$sent = wp_mail($email_data['to'], $email_data['subject'], $email_data['body'], $headers);
|
$sent = wp_mail($email_data['to'], $email_data['subject'], $email_data['body'], $headers);
|
||||||
|
|
||||||
// Trigger action for logging/tracking
|
// Trigger action for logging/tracking
|
||||||
do_action('woonoow_email_sent', $event_id, $recipient, $email_data, $sent);
|
do_action('woonoow_email_sent', $event_id, $recipient, $email_data, $sent);
|
||||||
|
|
||||||
return $sent;
|
return $sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send push notification
|
* Send push notification
|
||||||
*
|
*
|
||||||
@@ -183,7 +208,8 @@ class NotificationManager {
|
|||||||
* @param array $data Notification data
|
* @param array $data Notification data
|
||||||
* @return bool
|
* @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
|
// Push notification sending will be implemented later
|
||||||
// This is a placeholder for future implementation
|
// This is a placeholder for future implementation
|
||||||
do_action('woonoow_send_push_notification', $event_id, $recipient, $data);
|
do_action('woonoow_send_push_notification', $event_id, $recipient, $data);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification Template Provider
|
* Notification Template Provider
|
||||||
*
|
*
|
||||||
@@ -11,27 +12,29 @@ namespace WooNooW\Core\Notifications;
|
|||||||
|
|
||||||
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
|
use WooNooW\Email\DefaultTemplates as EmailDefaultTemplates;
|
||||||
|
|
||||||
class TemplateProvider {
|
class TemplateProvider
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Option key for storing templates
|
* Option key for storing templates
|
||||||
*/
|
*/
|
||||||
const OPTION_KEY = 'woonoow_notification_templates';
|
const OPTION_KEY = 'woonoow_notification_templates';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all templates
|
* Get all templates
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_templates() {
|
public static function get_templates()
|
||||||
|
{
|
||||||
$templates = get_option(self::OPTION_KEY, []);
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
// Merge with defaults
|
// Merge with defaults
|
||||||
$defaults = self::get_default_templates();
|
$defaults = self::get_default_templates();
|
||||||
|
|
||||||
return array_merge($defaults, $templates);
|
return array_merge($defaults, $templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get template for specific event and channel
|
* Get template for specific event and channel
|
||||||
*
|
*
|
||||||
@@ -40,25 +43,26 @@ class TemplateProvider {
|
|||||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
* @return array|null
|
* @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();
|
$templates = self::get_templates();
|
||||||
|
|
||||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
if (isset($templates[$key])) {
|
if (isset($templates[$key])) {
|
||||||
return $templates[$key];
|
return $templates[$key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return default if exists
|
// Return default if exists
|
||||||
$defaults = self::get_default_templates();
|
$defaults = self::get_default_templates();
|
||||||
|
|
||||||
if (isset($defaults[$key])) {
|
if (isset($defaults[$key])) {
|
||||||
return $defaults[$key];
|
return $defaults[$key];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save template
|
* Save template
|
||||||
*
|
*
|
||||||
@@ -68,11 +72,12 @@ class TemplateProvider {
|
|||||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
* @return bool
|
* @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, []);
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
$templates[$key] = [
|
$templates[$key] = [
|
||||||
'event_id' => $event_id,
|
'event_id' => $event_id,
|
||||||
'channel_id' => $channel_id,
|
'channel_id' => $channel_id,
|
||||||
@@ -82,10 +87,10 @@ class TemplateProvider {
|
|||||||
'variables' => $template['variables'] ?? [],
|
'variables' => $template['variables'] ?? [],
|
||||||
'updated_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql'),
|
||||||
];
|
];
|
||||||
|
|
||||||
return update_option(self::OPTION_KEY, $templates);
|
return update_option(self::OPTION_KEY, $templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete template (revert to default)
|
* Delete template (revert to default)
|
||||||
*
|
*
|
||||||
@@ -94,46 +99,48 @@ class TemplateProvider {
|
|||||||
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
* @param string $recipient_type Recipient type ('customer' or 'staff')
|
||||||
* @return bool
|
* @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, []);
|
$templates = get_option(self::OPTION_KEY, []);
|
||||||
|
|
||||||
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
$key = "{$recipient_type}_{$event_id}_{$channel_id}";
|
||||||
|
|
||||||
if (isset($templates[$key])) {
|
if (isset($templates[$key])) {
|
||||||
unset($templates[$key]);
|
unset($templates[$key]);
|
||||||
return update_option(self::OPTION_KEY, $templates);
|
return update_option(self::OPTION_KEY, $templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default templates
|
* Get default templates
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_default_templates() {
|
public static function get_default_templates()
|
||||||
|
{
|
||||||
$templates = [];
|
$templates = [];
|
||||||
|
|
||||||
// Get all events from EventRegistry (single source of truth)
|
// Get all events from EventRegistry (single source of truth)
|
||||||
$all_events = EventRegistry::get_all_events();
|
$all_events = EventRegistry::get_all_events();
|
||||||
|
|
||||||
// Get email templates from DefaultTemplates
|
// Get email templates from DefaultTemplates
|
||||||
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
|
$allEmailTemplates = EmailDefaultTemplates::get_all_templates();
|
||||||
|
|
||||||
foreach ($all_events as $event) {
|
foreach ($all_events as $event) {
|
||||||
$event_id = $event['id'];
|
$event_id = $event['id'];
|
||||||
$recipient_type = $event['recipient_type'];
|
$recipient_type = $event['recipient_type'];
|
||||||
// Get template body from the new clean markdown source
|
// Get template body from the new clean markdown source
|
||||||
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
|
$body = $allEmailTemplates[$recipient_type][$event_id] ?? '';
|
||||||
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
|
$subject = EmailDefaultTemplates::get_default_subject($recipient_type, $event_id);
|
||||||
|
|
||||||
// If template doesn't exist, create a simple fallback
|
// If template doesn't exist, create a simple fallback
|
||||||
if (empty($body)) {
|
if (empty($body)) {
|
||||||
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
|
$body = "[card]\n\n## Notification\n\nYou have a new notification about {$event_id}.\n\n[/card]";
|
||||||
$subject = __('Notification from {store_name}', 'woonoow');
|
$subject = __('Notification from {store_name}', 'woonoow');
|
||||||
}
|
}
|
||||||
|
|
||||||
$templates["{$recipient_type}_{$event_id}_email"] = [
|
$templates["{$recipient_type}_{$event_id}_email"] = [
|
||||||
'event_id' => $event_id,
|
'event_id' => $event_id,
|
||||||
'channel_id' => 'email',
|
'channel_id' => 'email',
|
||||||
@@ -143,7 +150,7 @@ class TemplateProvider {
|
|||||||
'variables' => self::get_variables_for_event($event_id),
|
'variables' => self::get_variables_for_event($event_id),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add push notification templates
|
// Add push notification templates
|
||||||
$templates['staff_order_placed_push'] = [
|
$templates['staff_order_placed_push'] = [
|
||||||
'event_id' => 'order_placed',
|
'event_id' => 'order_placed',
|
||||||
@@ -217,42 +224,44 @@ class TemplateProvider {
|
|||||||
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
|
'body' => __('A note has been added to order #{order_number}', 'woonoow'),
|
||||||
'variables' => self::get_order_variables(),
|
'variables' => self::get_order_variables(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $templates;
|
return $templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get variables for a specific event
|
* Get variables for a specific event
|
||||||
*
|
*
|
||||||
* @param string $event_id Event ID
|
* @param string $event_id Event ID
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
private static function get_variables_for_event($event_id) {
|
private static function get_variables_for_event($event_id)
|
||||||
|
{
|
||||||
// Product events
|
// Product events
|
||||||
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
|
if (in_array($event_id, ['low_stock', 'out_of_stock'])) {
|
||||||
return self::get_product_variables();
|
return self::get_product_variables();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer events (but not order-related)
|
// Customer events (but not order-related)
|
||||||
if ($event_id === 'new_customer') {
|
if ($event_id === 'new_customer') {
|
||||||
return self::get_customer_variables();
|
return self::get_customer_variables();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription events
|
// Subscription events
|
||||||
if (strpos($event_id, 'subscription_') === 0) {
|
if (strpos($event_id, 'subscription_') === 0) {
|
||||||
return self::get_subscription_variables();
|
return self::get_subscription_variables();
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other events are order-related
|
// All other events are order-related
|
||||||
return self::get_order_variables();
|
return self::get_order_variables();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available order variables
|
* Get available order variables
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_order_variables() {
|
public static function get_order_variables()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'order_number' => __('Order Number', 'woonoow'),
|
'order_number' => __('Order Number', 'woonoow'),
|
||||||
'order_total' => __('Order Total', 'woonoow'),
|
'order_total' => __('Order Total', 'woonoow'),
|
||||||
@@ -272,49 +281,52 @@ class TemplateProvider {
|
|||||||
'billing_address' => __('Billing Address', 'woonoow'),
|
'billing_address' => __('Billing Address', 'woonoow'),
|
||||||
'shipping_address' => __('Shipping Address', 'woonoow'),
|
'shipping_address' => __('Shipping Address', 'woonoow'),
|
||||||
'store_name' => __('Store Name', 'woonoow'),
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
'store_url' => __('Store URL', 'woonoow'),
|
'site_url' => __('Site URL', 'woonoow'),
|
||||||
'store_email' => __('Store Email', 'woonoow'),
|
'store_email' => __('Store Email', 'woonoow'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available product variables
|
* Get available product variables
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_product_variables() {
|
public static function get_product_variables()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'product_name' => __('Product Name', 'woonoow'),
|
'product_name' => __('Product Name', 'woonoow'),
|
||||||
'product_sku' => __('Product SKU', 'woonoow'),
|
'product_sku' => __('Product SKU', 'woonoow'),
|
||||||
'product_url' => __('Product URL', 'woonoow'),
|
'product_url' => __('Product URL', 'woonoow'),
|
||||||
'stock_quantity' => __('Stock Quantity', 'woonoow'),
|
'stock_quantity' => __('Stock Quantity', 'woonoow'),
|
||||||
'store_name' => __('Store Name', 'woonoow'),
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
'store_url' => __('Store URL', 'woonoow'),
|
'site_url' => __('Site URL', 'woonoow'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available customer variables
|
* Get available customer variables
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_customer_variables() {
|
public static function get_customer_variables()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'customer_name' => __('Customer Name', 'woonoow'),
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
'customer_email' => __('Customer Email', 'woonoow'),
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
'customer_phone' => __('Customer Phone', 'woonoow'),
|
'customer_phone' => __('Customer Phone', 'woonoow'),
|
||||||
'store_name' => __('Store Name', 'woonoow'),
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
'store_url' => __('Store URL', 'woonoow'),
|
'site_url' => __('Site URL', 'woonoow'),
|
||||||
'store_email' => __('Store Email', 'woonoow'),
|
'store_email' => __('Store Email', 'woonoow'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available subscription variables
|
* Get available subscription variables
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function get_subscription_variables() {
|
public static function get_subscription_variables()
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'subscription_id' => __('Subscription ID', 'woonoow'),
|
'subscription_id' => __('Subscription ID', 'woonoow'),
|
||||||
'subscription_status' => __('Subscription Status', 'woonoow'),
|
'subscription_status' => __('Subscription Status', 'woonoow'),
|
||||||
@@ -327,11 +339,11 @@ class TemplateProvider {
|
|||||||
'customer_name' => __('Customer Name', 'woonoow'),
|
'customer_name' => __('Customer Name', 'woonoow'),
|
||||||
'customer_email' => __('Customer Email', 'woonoow'),
|
'customer_email' => __('Customer Email', 'woonoow'),
|
||||||
'store_name' => __('Store Name', 'woonoow'),
|
'store_name' => __('Store Name', 'woonoow'),
|
||||||
'store_url' => __('Store URL', 'woonoow'),
|
'site_url' => __('Site URL', 'woonoow'),
|
||||||
'my_account_url' => __('My Account URL', 'woonoow'),
|
'my_account_url' => __('My Account URL', 'woonoow'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace variables in template
|
* Replace variables in template
|
||||||
*
|
*
|
||||||
@@ -339,11 +351,12 @@ class TemplateProvider {
|
|||||||
* @param array $data Data to replace variables
|
* @param array $data Data to replace variables
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function replace_variables($content, $data) {
|
public static function replace_variables($content, $data)
|
||||||
|
{
|
||||||
foreach ($data as $key => $value) {
|
foreach ($data as $key => $value) {
|
||||||
$content = str_replace('{' . $key . '}', $value, $content);
|
$content = str_replace('{' . $key . '}', $value, $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
366
includes/Database/SubscriberTable.php
Normal file
366
includes/Database/SubscriberTable.php
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,55 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace WooNooW\Frontend;
|
namespace WooNooW\Frontend;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frontend Assets Manager
|
* Frontend Assets Manager
|
||||||
* Handles loading of customer-spa assets
|
* Handles loading of customer-spa assets
|
||||||
*/
|
*/
|
||||||
class Assets {
|
class Assets
|
||||||
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize
|
* Initialize
|
||||||
*/
|
*/
|
||||||
public static function init() {
|
public static function init()
|
||||||
|
{
|
||||||
add_action('wp_enqueue_scripts', [self::class, 'enqueue_assets'], 20);
|
add_action('wp_enqueue_scripts', [self::class, 'enqueue_assets'], 20);
|
||||||
add_action('wp_head', [self::class, 'add_inline_config'], 5);
|
add_action('wp_head', [self::class, 'add_inline_config'], 5);
|
||||||
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||||
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
||||||
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add type="module" to customer-spa scripts
|
* 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
|
// Add type="module" to our Vite scripts
|
||||||
if (strpos($handle, 'woonoow-customer') !== false) {
|
if (strpos($handle, 'woonoow-customer') !== false) {
|
||||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||||
}
|
}
|
||||||
return $tag;
|
return $tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue customer-spa 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
|
// Only load on pages with WooNooW shortcodes or in full SPA mode
|
||||||
if (!self::should_load_assets()) {
|
if (!self::should_load_assets()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if dev mode is enabled
|
// Check if dev mode is enabled
|
||||||
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
|
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
|
||||||
|
|
||||||
if ($is_dev) {
|
if ($is_dev) {
|
||||||
// Dev mode: Load from Vite dev server
|
// Dev mode: Load from Vite dev server
|
||||||
$dev_server = 'https://woonoow.local:5174';
|
$dev_server = 'https://woonoow.local:5174';
|
||||||
|
|
||||||
// Vite client for HMR
|
// Vite client for HMR
|
||||||
wp_enqueue_script(
|
wp_enqueue_script(
|
||||||
'woonoow-customer-vite',
|
'woonoow-customer-vite',
|
||||||
@@ -53,7 +58,7 @@ class Assets {
|
|||||||
null,
|
null,
|
||||||
false // Load in header
|
false // Load in header
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main entry point
|
// Main entry point
|
||||||
wp_enqueue_script(
|
wp_enqueue_script(
|
||||||
'woonoow-customer-spa',
|
'woonoow-customer-spa',
|
||||||
@@ -66,16 +71,16 @@ class Assets {
|
|||||||
// Production mode: Load from build
|
// Production mode: Load from build
|
||||||
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
||||||
$dist_path = plugin_dir_path(dirname(dirname(__FILE__))) . 'customer-spa/dist/';
|
$dist_path = plugin_dir_path(dirname(dirname(__FILE__))) . 'customer-spa/dist/';
|
||||||
|
|
||||||
// Check if build exists
|
// Check if build exists
|
||||||
if (!file_exists($dist_path)) {
|
if (!file_exists($dist_path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production build - load app.js and app.css directly
|
// Production build - load app.js and app.css directly
|
||||||
$js_url = $plugin_url . 'customer-spa/dist/app.js';
|
$js_url = $plugin_url . 'customer-spa/dist/app.js';
|
||||||
$css_url = $plugin_url . 'customer-spa/dist/app.css';
|
$css_url = $plugin_url . 'customer-spa/dist/app.css';
|
||||||
|
|
||||||
wp_enqueue_script(
|
wp_enqueue_script(
|
||||||
'woonoow-customer-spa',
|
'woonoow-customer-spa',
|
||||||
$js_url,
|
$js_url,
|
||||||
@@ -83,15 +88,15 @@ class Assets {
|
|||||||
null,
|
null,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add type="module" for Vite build
|
// 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') {
|
if ($handle === 'woonoow-customer-spa') {
|
||||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||||
}
|
}
|
||||||
return $tag;
|
return $tag;
|
||||||
}, 10, 3);
|
}, 10, 3);
|
||||||
|
|
||||||
wp_enqueue_style(
|
wp_enqueue_style(
|
||||||
'woonoow-customer-spa',
|
'woonoow-customer-spa',
|
||||||
$css_url,
|
$css_url,
|
||||||
@@ -100,33 +105,35 @@ class Assets {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject SPA mounting point for full mode
|
* 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()) {
|
if (!self::should_load_assets()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're in full mode and not on a page with shortcode
|
// Check if we're in full mode and not on a page with shortcode
|
||||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||||
|
|
||||||
if ($mode === 'full') {
|
if ($mode === 'full') {
|
||||||
// Only inject if the mount point doesn't already exist (from shortcode)
|
// Only inject if the mount point doesn't already exist (from shortcode)
|
||||||
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add inline config and scripts to page head
|
* 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()) {
|
if (!self::should_load_assets()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Customer SPA settings
|
// Get Customer SPA settings
|
||||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||||
$default_settings = [
|
$default_settings = [
|
||||||
@@ -142,14 +149,14 @@ class Assets {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
|
$theme_settings = array_replace_recursive($default_settings, $spa_settings);
|
||||||
|
|
||||||
// Get appearance settings and preload them
|
// Get appearance settings and preload them
|
||||||
$stored_settings = get_option('woonoow_appearance_settings', []);
|
$stored_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
|
$default_appearance = \WooNooW\Admin\AppearanceController::get_default_settings();
|
||||||
|
|
||||||
// Merge stored settings with defaults to ensure new fields (like gradient colors) exist
|
// Merge stored settings with defaults to ensure new fields (like gradient colors) exist
|
||||||
$appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
|
$appearance_settings = array_replace_recursive($default_appearance, $stored_settings);
|
||||||
|
|
||||||
// Get WooCommerce currency settings
|
// Get WooCommerce currency settings
|
||||||
$currency_settings = [
|
$currency_settings = [
|
||||||
'code' => get_woocommerce_currency(),
|
'code' => get_woocommerce_currency(),
|
||||||
@@ -159,16 +166,16 @@ class Assets {
|
|||||||
'decimalSeparator' => wc_get_price_decimal_separator(),
|
'decimalSeparator' => wc_get_price_decimal_separator(),
|
||||||
'decimals' => wc_get_price_decimals(),
|
'decimals' => wc_get_price_decimals(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get store logo from WooNooW Store Details (Settings > Store Details)
|
// Get store logo from WooNooW Store Details (Settings > Store Details)
|
||||||
$logo_url = get_option('woonoow_store_logo', '');
|
$logo_url = get_option('woonoow_store_logo', '');
|
||||||
|
|
||||||
// Get user billing/shipping data if logged in
|
// Get user billing/shipping data if logged in
|
||||||
$user_data = [
|
$user_data = [
|
||||||
'isLoggedIn' => is_user_logged_in(),
|
'isLoggedIn' => is_user_logged_in(),
|
||||||
'id' => get_current_user_id(),
|
'id' => get_current_user_id(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (is_user_logged_in()) {
|
if (is_user_logged_in()) {
|
||||||
$customer = new \WC_Customer(get_current_user_id());
|
$customer = new \WC_Customer(get_current_user_id());
|
||||||
$user_data['email'] = $customer->get_email();
|
$user_data['email'] = $customer->get_email();
|
||||||
@@ -193,15 +200,15 @@ class Assets {
|
|||||||
'country' => $customer->get_shipping_country(),
|
'country' => $customer->get_shipping_country(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine SPA base path for BrowserRouter
|
// Determine SPA base path for BrowserRouter
|
||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
|
||||||
|
|
||||||
// Check if SPA Entry Page is set as WordPress frontpage
|
// Check if SPA Entry Page is set as WordPress frontpage
|
||||||
$frontpage_id = (int) get_option('page_on_front');
|
$frontpage_id = (int) get_option('page_on_front');
|
||||||
$is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
$is_spa_wp_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
|
||||||
|
|
||||||
// Get SPA Landing page (explicit setting, separate from Entry Page)
|
// Get SPA Landing page (explicit setting, separate from Entry Page)
|
||||||
// This determines what content to show at the SPA root route "/"
|
// This determines what content to show at the SPA root route "/"
|
||||||
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
|
$spa_frontpage_id = $appearance_settings['general']['spa_frontpage'] ?? 0;
|
||||||
@@ -211,14 +218,18 @@ class Assets {
|
|||||||
if ($spa_frontpage) {
|
if ($spa_frontpage) {
|
||||||
$front_page_slug = $spa_frontpage->post_name;
|
$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
|
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
|
||||||
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||||
|
|
||||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||||
|
|
||||||
$config = [
|
$config = [
|
||||||
'apiUrl' => rest_url('woonoow/v1'),
|
'apiUrl' => rest_url('woonoow/v1'),
|
||||||
'apiRoot' => rest_url('woonoow/v1'),
|
'apiRoot' => rest_url('woonoow/v1'),
|
||||||
@@ -236,19 +247,20 @@ class Assets {
|
|||||||
'useBrowserRouter' => $use_browser_router,
|
'useBrowserRouter' => $use_browser_router,
|
||||||
'frontPageSlug' => $front_page_slug,
|
'frontPageSlug' => $front_page_slug,
|
||||||
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
'spaMode' => $appearance_settings['general']['spa_mode'] ?? 'full',
|
||||||
|
'security' => \WooNooW\Compat\SecuritySettingsProvider::get_public_settings(),
|
||||||
];
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.woonoowCustomer = <?php echo wp_json_encode($config); ?>;
|
window.woonoowCustomer = <?php echo wp_json_encode($config); ?>;
|
||||||
</script>
|
</script>
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
// If dev mode, output scripts directly
|
// If dev mode, output scripts directly
|
||||||
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
|
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
|
||||||
if ($is_dev) {
|
if ($is_dev) {
|
||||||
$dev_server = 'https://woonoow.local:5174';
|
$dev_server = 'https://woonoow.local:5174';
|
||||||
?>
|
?>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import RefreshRuntime from '<?php echo $dev_server; ?>/@react-refresh'
|
import RefreshRuntime from '<?php echo $dev_server; ?>/@react-refresh'
|
||||||
RefreshRuntime.injectIntoGlobalHook(window)
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
@@ -258,35 +270,36 @@ class Assets {
|
|||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></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>
|
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we should load customer-spa assets
|
* Check if we should load customer-spa assets
|
||||||
*/
|
*/
|
||||||
private static function should_load_assets() {
|
private static function should_load_assets()
|
||||||
|
{
|
||||||
global $post;
|
global $post;
|
||||||
|
|
||||||
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
|
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
|
||||||
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
|
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're on a frontpage SPA route (by URL detection)
|
// Check if we're on a frontpage SPA route (by URL detection)
|
||||||
if (self::is_frontpage_spa_route()) {
|
if (self::is_frontpage_spa_route()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check: Is this a designated SPA page?
|
// First check: Is this a designated SPA page?
|
||||||
if (self::is_spa_page()) {
|
if (self::is_spa_page()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get SPA mode from appearance settings (the correct source)
|
// Get SPA mode from appearance settings (the correct source)
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
// If disabled, only load for pages with shortcodes
|
// If disabled, only load for pages with shortcodes
|
||||||
if ($mode === 'disabled') {
|
if ($mode === 'disabled') {
|
||||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||||
@@ -299,7 +312,7 @@ class Assets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for shortcodes on regular pages
|
// Check for shortcodes on regular pages
|
||||||
if ($post) {
|
if ($post) {
|
||||||
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||||
@@ -317,7 +330,7 @@ class Assets {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full SPA mode - load on all WooCommerce pages
|
// Full SPA mode - load on all WooCommerce pages
|
||||||
if ($mode === 'full') {
|
if ($mode === 'full') {
|
||||||
if (function_exists('is_shop') && is_shop()) {
|
if (function_exists('is_shop') && is_shop()) {
|
||||||
@@ -337,11 +350,11 @@ class Assets {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkout-Only mode - load only on specific pages
|
// Checkout-Only mode - load only on specific pages
|
||||||
if ($mode === 'checkout_only') {
|
if ($mode === 'checkout_only') {
|
||||||
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
|
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
|
||||||
|
|
||||||
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
|
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -356,7 +369,7 @@ class Assets {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if current page has WooNooW shortcodes
|
// Check if current page has WooNooW shortcodes
|
||||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||||
return true;
|
return true;
|
||||||
@@ -370,65 +383,67 @@ class Assets {
|
|||||||
if ($post && has_shortcode($post->post_content, 'woonoow_account')) {
|
if ($post && has_shortcode($post->post_content, 'woonoow_account')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current page is the designated SPA page
|
* Check if current page is the designated SPA page
|
||||||
*/
|
*/
|
||||||
private static function is_spa_page() {
|
private static function is_spa_page()
|
||||||
|
{
|
||||||
global $post;
|
global $post;
|
||||||
if (!$post) {
|
if (!$post) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get SPA page ID from appearance settings
|
// Get SPA page ID from appearance settings
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
|
||||||
|
|
||||||
// Check if current page matches the SPA page
|
// Check if current page matches the SPA page
|
||||||
if ($spa_page_id && $post->ID == $spa_page_id) {
|
if ($spa_page_id && $post->ID == $spa_page_id) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current request is a frontpage SPA route
|
* Check if current request is a frontpage SPA route
|
||||||
* Used to detect SPA routes by URL when SPA page is set as frontpage
|
* 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
|
// Get SPA settings
|
||||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||||
|
|
||||||
// Only run in full SPA mode
|
// Only run in full SPA mode
|
||||||
if ($spa_mode !== 'full' || !$spa_page_id) {
|
if ($spa_mode !== 'full' || !$spa_page_id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if SPA page is set as WordPress frontpage
|
// Check if SPA page is set as WordPress frontpage
|
||||||
$frontpage_id = (int) get_option('page_on_front');
|
$frontpage_id = (int) get_option('page_on_front');
|
||||||
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
|
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current request path
|
// Get the current request path
|
||||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
$path = parse_url($request_uri, PHP_URL_PATH);
|
$path = parse_url($request_uri, PHP_URL_PATH);
|
||||||
$path = '/' . trim($path, '/');
|
$path = '/' . trim($path, '/');
|
||||||
|
|
||||||
// Define SPA routes
|
// Define SPA routes
|
||||||
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
|
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
|
||||||
|
|
||||||
// Check exact matches
|
// Check exact matches
|
||||||
if (in_array($path, $spa_routes)) {
|
if (in_array($path, $spa_routes)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefixes
|
// Check path prefixes
|
||||||
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
|
||||||
foreach ($prefix_routes as $prefix) {
|
foreach ($prefix_routes as $prefix) {
|
||||||
@@ -436,30 +451,31 @@ class Assets {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dequeue conflicting scripts when SPA is active
|
* Dequeue conflicting scripts when SPA is active
|
||||||
*/
|
*/
|
||||||
public static function dequeue_conflicting_scripts() {
|
public static function dequeue_conflicting_scripts()
|
||||||
|
{
|
||||||
if (!self::should_load_assets()) {
|
if (!self::should_load_assets()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dequeue WooCommerce scripts that conflict with SPA
|
// Dequeue WooCommerce scripts that conflict with SPA
|
||||||
wp_dequeue_script('wc-cart-fragments');
|
wp_dequeue_script('wc-cart-fragments');
|
||||||
wp_dequeue_script('woocommerce');
|
wp_dequeue_script('woocommerce');
|
||||||
wp_dequeue_script('wc-add-to-cart');
|
wp_dequeue_script('wc-add-to-cart');
|
||||||
wp_dequeue_script('wc-add-to-cart-variation');
|
wp_dequeue_script('wc-add-to-cart-variation');
|
||||||
|
|
||||||
// Dequeue WordPress block scripts that cause errors in SPA
|
// Dequeue WordPress block scripts that cause errors in SPA
|
||||||
wp_dequeue_script('wp-block-library');
|
wp_dequeue_script('wp-block-library');
|
||||||
wp_dequeue_script('wp-block-navigation');
|
wp_dequeue_script('wp-block-navigation');
|
||||||
wp_dequeue_script('wp-interactivity');
|
wp_dequeue_script('wp-interactivity');
|
||||||
wp_dequeue_script('wp-interactivity-router');
|
wp_dequeue_script('wp-interactivity-router');
|
||||||
|
|
||||||
// Keep only essential WooCommerce styles, dequeue others if needed
|
// Keep only essential WooCommerce styles, dequeue others if needed
|
||||||
// wp_dequeue_style('woocommerce-general');
|
// wp_dequeue_style('woocommerce-general');
|
||||||
// wp_dequeue_style('woocommerce-layout');
|
// wp_dequeue_style('woocommerce-layout');
|
||||||
|
|||||||
@@ -781,4 +781,21 @@ class LicenseManager
|
|||||||
$license_id
|
$license_id
|
||||||
), ARRAY_A);
|
), 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class TemplateRegistry
|
|||||||
*/
|
*/
|
||||||
public static function get_templates()
|
public static function get_templates()
|
||||||
{
|
{
|
||||||
return [
|
return apply_filters('woonoow_page_templates', [
|
||||||
[
|
[
|
||||||
'id' => 'blank',
|
'id' => 'blank',
|
||||||
'label' => 'Blank Page',
|
'label' => 'Blank Page',
|
||||||
@@ -40,7 +40,7 @@ class TemplateRegistry
|
|||||||
'icon' => 'mail',
|
'icon' => 'mail',
|
||||||
'sections' => self::get_contact_structure()
|
'sections' => self::get_contact_structure()
|
||||||
]
|
]
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user