Compare commits

17 Commits

Author SHA1 Message Date
Dwindi Ramadhana
5f08c18ec7 fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout 2026-02-05 00:09:40 +07:00
Dwindi Ramadhana
a0b5f8496d feat: Implement OAuth license activation flow
- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA
- Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints
- Update App.tsx to render license-connect outside BaseLayout (no header/footer)
- Add license_activation_method field to product settings in Admin SPA
- Create LICENSING_MODULE.md with comprehensive OAuth flow documentation
- Update API_ROUTES.md with license module endpoints
2026-01-31 22:22:22 +07:00
Dwindi Ramadhana
d80f34c8b9 finalizing subscription moduile, ready to test 2026-01-29 11:54:42 +07:00
Dwindi Ramadhana
6d2136d3b5 Fix button roundtrip in editor, alignment persistence, and test email rendering 2026-01-17 13:10:50 +07:00
Dwindi Ramadhana
0e9ace902d feat: Drag-and-drop section reordering
- Add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- SortableSectionCard component with useSortable hook
- DndContext and SortableContext wrappers
- PointerSensor with 8px distance activation
- KeyboardSensor for accessibility
- Visual feedback: opacity change AND ring during drag
- GripVertical handle for intuitive dragging
- onReorderSections callback using arrayMove
2026-01-12 12:10:57 +07:00
Dwindi Ramadhana
f4f7ff10f0 feat: Page Editor live preview
- Add POST /preview/page/{slug} and /preview/template/{cpt} endpoints
- Render full HTML using PageSSR for iframe preview
- Templates use sample post for dynamic placeholder resolution
- PageSettings iframe with debounced section updates (500ms)
- Desktop/Mobile toggle with scaled iframe view
- Show/Hide preview toggle button
- Refresh button for manual preview reload
- Preview indicator banner in iframe
2026-01-12 12:08:03 +07:00
Dwindi Ramadhana
8e53a9d65b fix: Dropdown menus rendering outside SPA container
- Add getPortalContainer to DropdownMenuContent (like Dialog)
- Portal container created inside #woonoow-admin-app
- Copy theme class (light/dark) for proper CSS variable inheritance
- Fixes Add Section dropdown styling in Page Editor
2026-01-11 23:56:30 +07:00
Dwindi Ramadhana
c5b572b2c2 fix: Page list not refreshing and dialog styling
- Fix api response handling in pages query (api returns JSON directly)
- Fix page-structure query response handling
- Add theme class copying to dialog portal for proper CSS variable inheritance
- Portal container now syncs with document theme (light/dark)
2026-01-11 23:44:25 +07:00
Dwindi Ramadhana
75cd338c60 fix: Dialog not closing after successful page creation
- Fixed response handling in mutationFn
- api.post() returns JSON directly, not wrapped in { data: ... }
- Return response instead of response.data
2026-01-11 23:34:10 +07:00
Dwindi Ramadhana
e66f5e54a1 fix: Prevent double submission in Create Page dialog
- Add ref-based double submission protection (isSubmittingRef)
- Extract handleSubmit function with isPending checks
- Add loading spinner during submission
- Disable inputs during submission
- Suppress error toast for duplicate prevention errors
2026-01-11 23:22:47 +07:00
Dwindi Ramadhana
fe243a42cb fix: Create Page dialog improvements
- Add horizontal padding to dialog content (px-1)
- Show real site URL from WNW_CONFIG.siteUrl instead of 'yoursite.com'
- Improve error handling to extract message from response.data.message
- Better slug generation regex (removes special chars properly)
- Reset form when modal closes
- Use theme colors (text-muted-foreground, hover:bg-accent/50)
2026-01-11 23:15:59 +07:00
Dwindi Ramadhana
6c79e7cbac feat: Collapsible admin sidebar with auto-collapse for Page Editor
- Add SidebarProps interface with collapsed/onToggle props
- Add PanelLeft/PanelLeftClose icons for toggle button
- Sidebar auto-collapses when entering /appearance/pages
- Sidebar auto-expands when leaving (if auto-collapsed)
- Manual toggle persists to localStorage
- Smooth transition animation
- Show tooltips when collapsed
2026-01-11 23:08:30 +07:00
Dwindi Ramadhana
f3540a8448 feat: Page Editor Phase 3 - SSR integration and navigation
- Implement serve_ssr_content with full PageSSR rendering
  - SEO meta tags (title, description, og:*)
  - Minimal CSS for bot-friendly presentation
  - Yoast/Rank Math SEO data integration
- Add maybe_serve_ssr_for_bots hook (priority 2 on template_redirect)
  - Serves SSR for structural pages with WooNooW structure
  - Serves SSR for CPT items with templates
- Add use statements for PageSSR and PlaceholderRenderer
- Add Pages link to Appearance submenu in NavigationRegistry
- Bump NAV_VERSION to 1.1.0
2026-01-11 22:55:16 +07:00
Dwindi Ramadhana
bdded61221 feat: Page Editor Phase 2 - Admin UI
- Add AppearancePages component with 3-column layout
- Add PageSidebar for listing structural pages and CPT templates
- Add SectionEditor with add/delete/reorder functionality
- Add PageSettings with layout/color scheme and static/dynamic toggle
- Add CreatePageModal for creating new structural pages
- Add route at /appearance/pages in admin App.tsx
- Build admin-spa successfully
2026-01-11 22:44:00 +07:00
Dwindi Ramadhana
749cfb3f92 feat: Page Editor Phase 1 - React DynamicPageRenderer
- Add DynamicPageRenderer component for structural pages and CPT content
- Add 6 section components:
  - HeroSection with multiple layout variants
  - ContentSection for rich text/HTML content
  - ImageTextSection with image-left/right layouts
  - FeatureGridSection with grid-2/3/4 layouts
  - CTABannerSection with color schemes
  - ContactFormSection with webhook POST and redirect
- Add dynamic routes to App.tsx for /:slug and /:pathBase/:slug
- Build customer-spa successfully
2026-01-11 22:35:15 +07:00
Dwindi Ramadhana
9331989102 feat: Page Editor Phase 1 - Core Infrastructure
- Add is_bot() detection in TemplateOverride.php (30+ bot patterns)
- Add PageSSR.php for server-side rendering of page sections
- Add PlaceholderRenderer.php for dynamic content resolution
- Add PagesController.php REST API for pages/templates CRUD
- Register PagesController routes in Routes.php

API Endpoints:
- GET /pages - list all pages/templates
- GET /pages/{slug} - get page structure
- POST /pages/{slug} - save page
- GET /templates/{cpt} - get CPT template
- POST /templates/{cpt} - save template
- GET /content/{type}/{slug} - get content with template applied
2026-01-11 22:29:30 +07:00
Dwindi Ramadhana
1ff9a36af3 fix: React Router basename - use ?? instead of || for empty string support
When SPA is frontpage, basePath is empty string. JavaScript || treats '' as falsy
and falls back to /store. Changed to ?? (nullish coalescing) so empty string works.
2026-01-10 01:00:46 +07:00
156 changed files with 27694 additions and 5844 deletions

View File

@@ -0,0 +1,228 @@
# Email Notification System Audit
**Date:** January 29, 2026
**Status:** ✅ System Architecture Sound, Minor Issues Identified
---
## Executive Summary
The WooNooW email notification system is **well-architected** with proper async handling, template rendering, and event management. The main components work together correctly. However, some potential gaps and improvements were identified.
---
## System Architecture
```mermaid
flowchart TD
A[WooCommerce Hooks] --> B[EmailManager]
B --> C{Is WooNooW Mode?}
C -->|Yes| D[EmailRenderer]
C -->|No| E[WC Default Emails]
D --> F[TemplateProvider]
F --> G[Get Template]
G --> H[Replace Variables]
H --> I[Parse Markdown/Cards]
I --> J[wp_mail]
J --> K[WooEmailOverride Intercepts]
K --> L[MailQueue::enqueue]
L --> M[Action Scheduler]
M --> N[MailQueue::sendNow]
N --> O[Actual wp_mail]
```
---
## Core Components
| File | Purpose |
|------|---------|
| [EmailManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailManager.php) | Hooks WC order events, disables WC emails, routes to renderer |
| [EmailRenderer.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php) | Renders templates, replaces variables, parses markdown |
| [TemplateProvider.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php) | Manages templates, defaults, variable definitions |
| [EventRegistry.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EventRegistry.php) | Central registry of all notification events |
| [NotificationManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/NotificationManager.php) | Validates settings, dispatches to channels |
| [WooEmailOverride.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/WooEmailOverride.php) | Intercepts wp_mail via `pre_wp_mail` filter |
| [MailQueue.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/MailQueue.php) | Async queue via Action Scheduler |
---
## Email Flow Trace
### 1. Event Trigger
- WooCommerce fires hooks like `woocommerce_order_status_pending_to_processing`
- `EmailManager::init_hooks()` registers callbacks for these hooks
### 2. EmailManager Processing
```php
// In EmailManager.php
add_action('woocommerce_order_status_pending_to_processing', [$this, 'send_order_processing_email']);
```
- Checks if WooNooW mode enabled: `is_enabled()`
- Checks if event enabled: `is_event_enabled()`
- Calls `send_email($event_id, $recipient_type, $order)`
### 3. Email Rendering
- `EmailRenderer::render()` called
- Gets template from `TemplateProvider::get_template()`
- Gets variables from `get_variables()` (order, customer, product data)
- Replaces `{variable}` placeholders
- Parses `[card]` markdown syntax
- Wraps in HTML template from `templates/emails/base.html`
### 4. wp_mail Interception
- `wp_mail()` is called with rendered HTML
- `WooEmailOverride::interceptMail()` catches via `pre_wp_mail` filter
- Returns `true` to short-circuit synchronous send
### 5. Queue & Async Send
- `MailQueue::enqueue()` stores payload in `wp_options` (temp)
- Schedules `woonoow/mail/send` action via Action Scheduler
- `MailQueue::sendNow()` runs asynchronously:
- Retrieves payload from options
- Disables `WooEmailOverride` to prevent loop
- Calls actual `wp_mail()`
- Deletes temp option
---
## Findings
### ✅ Working Correctly
1. **Async Email Queue**: Properly prevents timeout issues
2. **Template System**: Variables replaced correctly
3. **Event Registry**: Single source of truth
4. **Subscription Events**: Registered via `woonoow_notification_events_registry` filter
5. **Global Toggle**: WooNooW vs WooCommerce mode works
6. **WC Email Disable**: Default emails properly disabled when WooNooW active
### ⚠️ Potential Issues
#### 1. Missing Subscription Variable Population in EmailRenderer
**Location:** [EmailRenderer.php:147-299](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L147-L299)
**Issue:** `get_variables()` handles `WC_Order`, `WC_Product`, `WC_Customer` but NOT subscription objects. Subscription notifications pass data like:
```php
$data = [
'subscription' => $subscription, // Custom subscription object
'customer' => $user,
'product' => $product,
...
]
```
**Impact:** Subscription email variables like `{subscription_id}`, `{billing_period}`, `{next_payment_date}` may not be replaced.
**Recommendation:** Add subscription variable population in `EmailRenderer::get_variables()`.
---
#### 2. EmailRenderer Type Check for Subscription
**Location:** [EmailRenderer.php:121-137](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L121-L137)
**Issue:** `get_recipient_email()` only checks for `WC_Order` and `WC_Customer`. For subscriptions, `$data` is an array, so recipient email extraction fails.
**Impact:** Subscription emails may not find recipient email.
**Recommendation:** Handle array data or subscription object in `get_recipient_email()`.
---
#### 3. SubscriptionModule Sends to NotificationManager, Not EmailManager
**Location:** [SubscriptionModule.php:529-531](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L529-L531)
**Code:**
```php
\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data);
```
**Issue:** This goes through `NotificationManager`, which calls its own `send_email()` that uses `EmailRenderer::render()`. The `EmailRenderer::render()` method receives `$data['subscription']` but doesn't know how to handle it.
**Impact:** Subscription email rendering may fail silently.
---
#### 4. No Error Logging in Email Rendering Failures
**Location:** [EmailRenderer.php:48-57](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L48-L57)
**Issue:** When `get_template_settings()` returns null or `get_recipient_email()` returns null, the function returns null silently with only an empty debug log statement.
**Recommendation:** Add proper `error_log()` calls for debugging.
---
#### 5. Duplicate wp_mail Calls
**Location:** Multiple places call `wp_mail()` directly:
- `EmailManager::send_email()` (line 521)
- `EmailManager::send_password_reset_email()` (line 406)
- `NotificationManager::send_email()` (line 170)
- `NotificationsController` test endpoint (line 1013)
- `CampaignManager` (lines 275, 329)
- `NewsletterController` (line 203)
**Issue:** All these are intercepted by `WooEmailOverride`, which is correct. However, if `WooEmailOverride` is disabled (testing mode), all send synchronously.
**Status:** Working as designed.
---
## Subscription Email Gap Analysis
The subscription module has these events defined but needs variable population:
| Event | Variables Needed |
|-------|-----------------|
| `subscription_pending_cancellation` | subscription_id, product_name, end_date |
| `subscription_cancelled` | subscription_id, cancel_reason |
| `subscription_expired` | subscription_id, product_name |
| `subscription_paused` | subscription_id, product_name |
| `subscription_resumed` | subscription_id, product_name |
| `subscription_renewal_failed` | subscription_id, failed_count, payment_link |
| `subscription_renewal_payment_due` | subscription_id, payment_link |
| `subscription_renewal_reminder` | subscription_id, next_payment_date |
**Required Fix:** Add subscription data handling to `EmailRenderer::get_variables()`.
---
## Recommendations
### High Priority
1. **Fix `EmailRenderer::get_variables()`** - Add handling for subscription data arrays
2. **Fix `EmailRenderer::get_recipient_email()`** - Handle array data with customer key
### Medium Priority
3. **Add error logging** - Replace empty debug conditions with actual logging
4. **Clean up debug conditions** - Many `if (defined('WP_DEBUG') && WP_DEBUG) {}` are empty
### Low Priority
5. **Consolidate email sending paths** - Consider routing all through one method
6. **Add email send failure tracking** - Log failed sends for troubleshooting
---
## Test Scripts Available
| Script | Purpose |
|--------|---------|
| `check-settings.php` | Diagnose notification settings |
| `test-email-flow.php` | Interactive email testing dashboard |
| `test-email-direct.php` | Direct wp_mail testing |
---
## Documentation
Comprehensive docs exist:
- [NOTIFICATION_SYSTEM.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/NOTIFICATION_SYSTEM.md)
- [EMAIL_DEBUGGING_GUIDE.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/EMAIL_DEBUGGING_GUIDE.md)
---
## Conclusion
The email notification system is **production-ready** for order-related notifications. The main gap is **subscription email variable population**, which requires updates to `EmailRenderer.php` to properly handle subscription data and extract variables.

View File

@@ -0,0 +1,391 @@
# OAuth-Style License Activation Research Report
**Date:** January 31, 2026
**Objective:** Design a strict license activation system requiring vendor site authentication
---
## Executive Summary
After researching Elementor Pro, Tutor LMS, EDD Software Licensing, and industry standards, the **redirect-based OAuth-like activation flow** is the most secure and user-friendly approach. This pattern:
- Prevents license key sharing by tying activation to user accounts
- Provides better UX than manual key entry
- Enables flexible license management
- Creates an anti-piracy layer beyond just key validation
---
## Industry Analysis
### 1. Elementor Pro
| Aspect | Implementation |
|--------|----------------|
| **Flow** | "Connect & Activate" button → redirect to Elementor.com → login required → authorize connection → return to WP Admin |
| **Why Listed** | Market leader with 5M+ users; sets the standard for premium plugin activation |
| **Anti-Piracy** | Account-tied activation; no ability to share just a license key |
| **Fallback** | Manual key entry via hidden URL parameter `?mode=manually` |
**Key Pattern:** Elementor never shows the license key in the normal flow—users authenticate with their account, not a key.
---
### 2. Tutor LMS (Themeum)
| Aspect | Implementation |
|--------|----------------|
| **Flow** | License settings → Enter key → "Connect" button → redirect to Themeum → login → confirm connection |
| **Why Listed** | Popular LMS plugin; hybrid approach (key + account verification) |
| **Anti-Piracy** | License keys tied to specific domains registered in user account |
| **License Display** | Keys visible in account dashboard for copy-paste |
**Key Pattern:** Requires domain registration in vendor account before activation works.
---
### 3. Easy Digital Downloads (EDD) Software Licensing
| Aspect | Implementation |
|--------|----------------|
| **Flow** | API-based: plugin sends key + site URL to vendor → server validates → returns activation status |
| **Why Listed** | Powers many WordPress plugin vendors (WPForms, MonsterInsights, etc.) |
| **Anti-Piracy** | Activation limits (e.g., 1 site, 5 sites, unlimited); site URL tracking |
| **Management** | Customer can manage activations in their EDD account |
**Key Pattern:** Traditional key-based but with strict activation limits and site tracking.
---
### 4. WooCommerce Software License Manager
| Aspect | Implementation |
|--------|----------------|
| **Flow** | REST API with key + secret authentication |
| **Why Listed** | Common for WooCommerce-based vendors |
| **Anti-Piracy** | API-key authentication; activation records |
**Key Pattern:** Programmatic API access, less user-facing UX focus.
---
## Best Practices Identified
### Anti-Piracy Measures
| Measure | Effectiveness | UX Impact |
|---------|---------------|-----------|
| **Account authentication required** | ★★★★★ | Minor inconvenience |
| **Activation limits per license** | ★★★★☆ | None |
| **Domain/URL binding** | ★★★★☆ | None |
| **Tying updates/support to valid license** | ★★★★★ | Incentivizes purchase |
| **Periodic license re-validation** | ★★★☆☆ | Can cause issues |
| **Encrypted API communication (HTTPS)** | ★★★★★ | None |
### UX Considerations
| Consideration | Priority |
|---------------|----------|
| One-click activation (minimal friction) | High |
| Clear error messages | High |
| License status visibility in WP Admin | Medium |
| Easy deactivation for site migrations | High |
| Fallback manual activation | Medium |
---
## Security Comparison
| Method | Piracy Resistance | Implementation Complexity |
|--------|-------------------|---------------------------|
| **Simple key validation** | Low | Simple |
| **Key + site URL binding** | Medium | Medium |
| **Key + activation limits** | Medium-High | Medium |
| **OAuth redirect + account tie** | High | Complex |
| **OAuth + key + activation limits** | Very High | Complex |
---
## Your Proposed Flow Analysis
### Original Flow Points
1. User navigates to license page → clicks [ACTIVATE]
2. Redirect to vendor site (licensing.woonoow.com or similar)
3. Vendor site: login required
4. Vendor shows licenses for user's account, filtered by product
5. User selects license to connect
6. Click "Connect This Site"
7. Return to `return_url` after short delay
### Identified Gaps
| Gap | Risk | Solution |
|-----|------|----------|
| No state parameter | CSRF attack possible | Add signed `state` token |
| No nonce verification | Replay attacks | Include one-time nonce |
| Return URL manipulation | Redirect hijacking | Validate return URL on server |
| No deactivation flow | User can't migrate | Add disconnect button |
---
## Perfected Implementation Plan
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ WP ADMIN (Client Site) │
├─────────────────────────────────────────────────────────────────┤
│ Settings → License │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Status: Not Connected │ │
│ │ [🔗 Connect & Activate] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
▼ Redirect with signed params
┌──────────────────────────────────────────────────────────────────┐
│ VENDOR SITE (License Server) │
├──────────────────────────────────────────────────────────────────┤
│ /license/connect? │
│ product_id=woonoow-pro& │
│ site_url=https://customer-site.com& │
│ return_url=https://customer-site.com/wp-admin/...& │
│ state=<signed_token>& │
│ nonce=<one_time_code> │
├──────────────────────────────────────────────────────────────────┤
│ 1. Force login if not authenticated │
│ 2. Show licenses owned by user for this product │
│ 3. User selects: "Pro License (3/5 sites used)" │
│ 4. Click [Connect This Site] │
│ 5. Server records activation │
│ 6. Redirect back with activation token │
└──────────────────────────────────────────────────────────────────┘
▼ Callback with activation token
┌──────────────────────────────────────────────────────────────────┐
│ WP ADMIN (Client Site) │
├──────────────────────────────────────────────────────────────────┤
│ Callback handler: │
│ 1. Verify state matches stored value │
│ 2. Exchange activation_token for license_key via API │
│ 3. Store license_key securely │
│ 4. Show success: "License activated successfully!" │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Status: ✅ Active │ │
│ │ License: Pro (expires Dec 31, 2026) │ │
│ │ Sites: 4/5 activated │ │
│ │ [Disconnect] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
---
### Detailed Flow
#### Phase 1: Initiation (Client Plugin)
```php
// User clicks "Connect & Activate"
$params = [
'product_id' => 'woonoow-pro',
'site_url' => home_url(),
'return_url' => admin_url('admin.php?page=woonoow-license&action=callback'),
'nonce' => wp_create_nonce('woonoow_license_connect'),
'state' => $this->generate_state_token(), // Signed, stored in transient
'timestamp' => time(),
];
$redirect_url = 'https://licensing.woonoow.com/connect?' . http_build_query($params);
wp_redirect($redirect_url);
```
#### Phase 2: Authentication (Vendor Server)
1. **Login Gate**: If user not logged in → redirect to login with `?redirect=/connect?...`
2. **Validate Request**: Check `state`, `nonce`, `timestamp` (reject if >10 min old)
3. **Fetch User Licenses**: Query licenses owned by authenticated user for `product_id`
4. **Display License Selector**:
```
┌─────────────────────────────────────────────────┐
│ Connect site-name.com to your license │
├─────────────────────────────────────────────────┤
│ ○ WooNooW Pro - Agency (Unlimited sites) │
│ ● WooNooW Pro - Business (3/5 sites) ←selected │
│ ○ WooNooW Pro - Personal (1/1 sites) [FULL] │
├─────────────────────────────────────────────────┤
│ [Cancel] [Connect This Site] │
└─────────────────────────────────────────────────┘
```
5. **Record Activation**: Insert into `license_activations` table
6. **Generate Callback**: Redirect to `return_url` with:
- `activation_token`: Short-lived token (5 min expiry)
- `state`: Original state for verification
#### Phase 3: Callback (Client Plugin)
```php
// Handle callback
$activation_token = sanitize_text_field($_GET['activation_token']);
$state = sanitize_text_field($_GET['state']);
// 1. Verify state matches stored transient
if (!$this->verify_state_token($state)) {
wp_die('Invalid state. Possible CSRF attack.');
}
// 2. Exchange token for license details via secure API
$response = wp_remote_post('https://licensing.woonoow.com/api/v1/token/exchange', [
'body' => [
'activation_token' => $activation_token,
'site_url' => home_url(),
],
]);
// 3. Store license data
$license_data = json_decode(wp_remote_retrieve_body($response), true);
update_option('woonoow_license', [
'key' => $license_data['license_key'],
'status' => 'active',
'expires' => $license_data['expires_at'],
'tier' => $license_data['tier'],
'sites_used' => $license_data['sites_used'],
'sites_max' => $license_data['sites_max'],
]);
// 4. Redirect with success
wp_redirect(admin_url('admin.php?page=woonoow-license&activated=1'));
```
---
### Security Parameters
| Parameter | Purpose | Implementation |
|-----------|---------|----------------|
| `state` | CSRF protection | HMAC-signed, stored in transient, expires 10 min |
| `nonce` | Replay prevention | One-time use, verified on server |
| `timestamp` | Request freshness | Reject requests >10 min old |
| `activation_token` | Secure exchange | Short-lived (5 min), single-use |
| `site_url` | Domain binding | Stored with activation record |
---
### Database Schema (Vendor Server)
```sql
CREATE TABLE license_activations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
license_id BIGINT NOT NULL,
site_url VARCHAR(255) NOT NULL,
activation_token VARCHAR(64),
token_expires_at DATETIME,
activated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_check DATETIME,
status ENUM('active', 'deactivated') DEFAULT 'active',
metadata JSON,
UNIQUE KEY unique_license_site (license_id, site_url),
FOREIGN KEY (license_id) REFERENCES licenses(id)
);
```
---
### Deactivation Flow
```
Client: [Disconnect] button clicked
→ POST /api/v1/license/deactivate
→ Body: { license_key, site_url }
→ Server removes activation record
→ Client clears stored license
→ Show "Disconnected" status
```
---
### Periodic Validation
```php
// Cron check every 24 hours
add_action('woonoow_daily_license_check', function() {
$license = get_option('woonoow_license');
if (!$license) return;
$response = wp_remote_post('https://licensing.woonoow.com/api/v1/license/validate', [
'body' => [
'license_key' => $license['key'],
'site_url' => home_url(),
],
]);
$data = json_decode(wp_remote_retrieve_body($response), true);
if ($data['status'] !== 'active') {
update_option('woonoow_license', ['status' => 'invalid']);
// Optionally disable premium features
}
});
```
---
## API Endpoints (Vendor Server)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/connect` | GET | OAuth-like authorization page |
| `/api/v1/token/exchange` | POST | Exchange activation token for license |
| `/api/v1/license/validate` | POST | Validate license status |
| `/api/v1/license/deactivate` | POST | Remove site activation |
| `/api/v1/license/info` | GET | Get license details |
---
## Comparison: Your Flow vs. Perfected
| Aspect | Your Original | Perfected |
|--------|---------------|-----------|
| CSRF Protection | ❌ None | ✅ State token |
| Replay Prevention | ❌ None | ✅ Nonce + timestamp |
| Token Exchange | ❌ Direct return | ✅ Secure exchange |
| Return URL Security | ❌ Unvalidated | ✅ Server whitelist |
| Deactivation | ❌ Not mentioned | ✅ Full flow |
| Periodic Validation | ❌ Not mentioned | ✅ Daily cron |
| Fallback | ❌ None | ✅ Manual key entry |
---
## Implementation Phases
### Phase 1: Server-Side (Licensing Portal)
1. Create `/connect` authorization page
2. Build license selection UI
3. Implement activation recording
4. Create token exchange API
### Phase 2: Client-Side (WooNooW Plugin)
1. Create Settings → License admin page
2. Implement connect redirect
3. Handle callback and token exchange
4. Store license securely
5. Add disconnect functionality
### Phase 3: Validation & Updates
1. Implement periodic license checks
2. Gate premium features behind valid license
3. Integrate with plugin update checker
---
## References
| Source | Relevance |
|--------|-----------|
| Elementor Pro Activation | Primary reference for UX flow |
| Tutor LMS / Themeum | Hybrid key+account approach |
| OAuth 2.0 Authorization Code Flow | Security pattern basis |
| EDD Software Licensing | Activation limits pattern |
| OWASP API Security | State/nonce implementation |

View File

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

View File

@@ -0,0 +1,212 @@
# Product Create/Update Flow Audit Report
**Date:** 2026-01-29
**Scope:** Full trace of product creation, update, SKU validation, variation handling, virtual product setting, and customer-facing add-to-cart
---
## Executive Summary
**Total Issues Found: 4**
- **CRITICAL:** 2
- **WARNING:** 1
- **INFO:** 1
---
## Critical Issues
### 🔴 Issue #1: SKU Validation Blocks Variation Updates
**Severity:** CRITICAL
**Location:** [ProductsController.php#L1009](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L1009)
**Problem:**
When updating a variable product, the `save_product_variations` method sets SKU unconditionally:
```php
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
```
WooCommerce validates that SKU must be unique across all products. When updating a variation that already has that SKU, WooCommerce throws an exception because it sees the SKU as a duplicate.
**Root Cause:**
WooCommerce's `set_sku()` method checks for uniqueness but doesn't know the variation already owns that SKU during the update.
**Fix Required:**
Before setting SKU, check if the new SKU is the same as the current SKU:
```php
if (isset($var_data['sku'])) {
$current_sku = $variation->get_sku();
$new_sku = $var_data['sku'];
// Only set if different (to avoid WC duplicate check issue)
if ($current_sku !== $new_sku) {
$variation->set_sku($new_sku);
}
}
```
---
### 🔴 Issue #2: Variation Selection Fails (Attribute Format Mismatch)
**Severity:** CRITICAL
**Location:**
- Backend: [ShopController.php#L363-365](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Frontend/ShopController.php#L363)
- Frontend: [Product/index.tsx#L97-127](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Product/index.tsx#L97)
**Problem:**
"Please select all product options" error appears even when a variation is selected.
**Root Cause:**
Format mismatch between backend API and frontend matching logic:
| Source | Format | Example |
|--------|--------|---------|
| API `variations.attributes` | `attribute_pa_color: "red"` | Lowercase, prefixed |
| API `attributes` | `name: "Color"` | Human-readable |
| Frontend `selectedAttributes` | `Color: "Red"` | Human-readable, case preserved |
The matching logic at lines 100-120 has complex normalization but may fail at edge cases:
- Taxonomy attributes use `pa_` prefix (e.g., `attribute_pa_color`)
- Custom attributes use direct prefix (e.g., `attribute_size`)
- The comparison normalizes both sides but attribute names in `selectedAttributes` are human-readable labels
**Fix Required:**
Improve variation matching by normalizing attribute names consistently:
```typescript
// In find matching variation logic:
const variation = (product.variations as any[]).find(v => {
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
const normalizedAttrName = attrName.toLowerCase();
const normalizedValue = attrValue.toLowerCase();
// Try all possible attribute key formats
const possibleKeys = [
`attribute_${normalizedAttrName}`,
`attribute_pa_${normalizedAttrName}`,
normalizedAttrName
];
for (const key of possibleKeys) {
if (key in v.attributes) {
return v.attributes[key].toLowerCase() === normalizedValue;
}
}
return false;
});
});
```
---
## Warning Issues
### 🟡 Issue #3: Virtual Product Setting May Not Persist for Variable Products
**Severity:** WARNING
**Location:** [ProductsController.php#L496-498](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L496)
**Problem:**
User reports cannot change product to virtual. Investigation shows:
- Admin-SPA correctly sends `virtual: true` in payload
- Backend `update_product` correctly calls `$product->set_virtual()`
- However, for variable products, virtual status may need to be set on each variation
**Observation:**
The backend code at lines 496-498 handles virtual correctly:
```php
if (isset($data['virtual'])) {
$product->set_virtual((bool) $data['virtual']);
}
```
**Potential Issue:**
WooCommerce may ignore parent product's virtual flag for variable products. Each variation may need to be set as virtual individually.
**Fix Required:**
When saving variations, also propagate virtual flag:
```php
// In save_product_variations, after setting other fields:
if ($product->is_virtual()) {
$variation->set_virtual(true);
}
```
---
## Info Issues
### Issue #4: Missing Error Handling in Add-to-Cart Backend
**Severity:** INFO
**Location:** [CartController.php#L202-203](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/Controllers/CartController.php#L202)
**Observation:**
When `add_to_cart()` returns false, the error message is generic:
```php
if (!$cart_item_key) {
return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
}
```
WooCommerce may have more specific notices in `wc_notice` stack that could provide better error messages.
**Enhancement:**
```php
if (!$cart_item_key) {
$notices = wc_get_notices('error');
$message = !empty($notices) ? $notices[0]['notice'] : 'Failed to add product to cart';
wc_clear_notices();
return new WP_Error('add_to_cart_failed', $message, ['status' => 400]);
}
```
---
## Code Flow Summary
### Product Update Flow
```mermaid
sequenceDiagram
Admin SPA->>ProductsController: PUT /products/{id}
ProductsController->>WC_Product: set_name, set_sku, etc.
ProductsController->>WC_Product: set_virtual, set_downloadable
ProductsController->>ProductsController: save_product_variations()
ProductsController->>WC_Product_Variation: set_sku (BUG: no duplicate check)
WC_Product_Variation-->>WooCommerce: validate_sku()
WooCommerce-->>ProductsController: Exception (duplicate SKU)
```
### Add-to-Cart Flow
```mermaid
sequenceDiagram
Customer SPA->>Product Page: Select variation
Product Page->>useState: selectedAttributes = {Color: "Red"}
Product Page->>useEffect: Find matching variation
Note right of Product Page: Mismatch: API has attribute_pa_color
Product Page-->>useState: selectedVariation = null
Customer->>Product Page: Click Add to Cart
Product Page->>Customer: "Please select all product options"
```
---
## Files to Modify
| File | Change |
|------|--------|
| `ProductsController.php` | Fix SKU check in `save_product_variations` |
| `Product/index.tsx` | Fix variation matching logic |
| `ProductsController.php` | Propagate virtual to variations |
| `CartController.php` | (Optional) Improve error messages |
---
## Verification Plan
After fixes:
1. Create a variable product with SKU on variations
2. Edit the product without changing SKU → should save successfully
3. Add products to cart → verify variation selection works
4. Test virtual product setting on simple and variable products

View File

@@ -0,0 +1,173 @@
# Subscription Module Comprehensive Audit Report
**Date:** 2026-01-29
**Scope:** Full module trace including orders, notifications, permissions, payment gateway integration, auto/manual renewal, early renewal
---
## Executive Summary
I performed a comprehensive audit of the subscription module and implemented fixes for all Critical and Warning issues.
**Total Issues Found: 11**
- **CRITICAL:** 2 ✅ FIXED
- **WARNING:** 5 ✅ FIXED
- **INFO:** 4 (No action required)
---
## Fixes Implemented
### ✅ Critical Issue #1: `handle_renewal_success` Now Sets Status to Active
**File:** [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L708-L719)
**Change:**
```diff
$wpdb->update(
self::$table_subscriptions,
[
+ 'status' => 'active',
'next_payment_date' => $next_payment,
'last_payment_date' => current_time('mysql'),
'failed_payment_count' => 0,
],
['id' => $subscription_id],
- ['%s', '%s', '%d'],
+ ['%s', '%s', '%s', '%d'],
['%d']
);
```
---
### ✅ Critical Issue #2: Added Renewal Reminder Handler
**File:** [SubscriptionModule.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php)
**Changes:**
1. Added action hook registration:
```php
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
```
2. Added event registration:
```php
$events['subscription_renewal_reminder'] = [
'id' => 'subscription_renewal_reminder',
'label' => __('Subscription Renewal Reminder', 'woonoow'),
// ...
];
```
3. Added handler method:
```php
public static function on_renewal_reminder($subscription)
{
if (!$subscription || !isset($subscription->id)) {
return;
}
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
}
```
---
### ✅ Warning Issue #3: Added Duplicate Renewal Order Prevention
**File:** [SubscriptionManager.php::renew](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php#L511-L535)
**Change:** Before creating a new renewal order, the system now checks for existing pending orders:
```php
$existing_pending = $wpdb->get_row($wpdb->prepare(
"SELECT so.order_id FROM ... WHERE ... AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
$subscription_id
));
if ($existing_pending) {
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
}
```
Also allowed `on-hold` subscriptions to renew (in addition to `active`).
---
### ✅ Warning Issue #4: Removed Duplicate Route Registration
**File:** [CheckoutController.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/CheckoutController.php)
**Change:** Removed duplicate `/checkout/pay-order/{id}` route registration (was registered twice).
---
### ✅ Warning Issue #5: Added `has_settings` to Subscription Module
**File:** [ModuleRegistry.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/ModuleRegistry.php#L64-L78)
**Change:**
```diff
'subscription' => [
// ...
'default_enabled' => false,
+ 'has_settings' => true,
'features' => [...],
],
```
Now subscription settings will appear in Admin SPA > Settings > Modules > Subscription.
---
### ✅ Issue #10: Replaced Transient Tracking with Database Column
**Files:**
- [SubscriptionManager.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionManager.php) - Added `reminder_sent_at` column
- [SubscriptionScheduler.php](file:///Users/dwindown/Local Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionScheduler.php) - Updated to use database column
**Changes:**
1. Added column to table schema:
```sql
reminder_sent_at DATETIME DEFAULT NULL,
```
2. Updated scheduler logic:
```php
// Query now includes:
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR ...)
// After sending:
$wpdb->update($table, ['reminder_sent_at' => current_time('mysql')], ...);
```
---
## Remaining INFO Issues (No Action Required)
| # | Issue | Status |
|---|-------|--------|
| 6 | Payment gateway integration is placeholder only | Phase 2 - needs separate adapter classes |
| 7 | ThankYou page doesn't display subscription info | Enhancement for future |
| 9 | "Renew Early" only for active subscriptions | Confirmed as acceptable UX |
| 11 | API permissions correctly configured | Verified ✓ |
---
## Summary of Files Modified
| File | Changes |
|------|---------|
| `SubscriptionManager.php` | • Fixed `handle_renewal_success` to set status<br>• Added duplicate order prevention<br>• Added `reminder_sent_at` column |
| `SubscriptionModule.php` | • Added renewal reminder hook<br>• Added event registration<br>• Added handler method |
| `SubscriptionScheduler.php` | • Replaced transient tracking with database column |
| `CheckoutController.php` | • Removed duplicate route registration |
| `ModuleRegistry.php` | • Added `has_settings => true` for subscription |
---
## Database Migration Note
> [!IMPORTANT]
> The `reminder_sent_at` column has been added to the subscriptions table schema. Since `dbDelta()` is used, it should be added automatically on next module re-enable or table check. However, for existing installations, you may need to:
> 1. Disable and re-enable the Subscription module in Admin SPA, OR
> 2. Run: `ALTER TABLE wp_woonoow_subscriptions ADD COLUMN reminder_sent_at DATETIME DEFAULT NULL;`

View File

@@ -109,6 +109,31 @@ GET /analytics/orders # Order analytics
GET /analytics/customers # Customer analytics
```
### Licensing Module (`LicensesController.php`)
```
# Admin Endpoints (admin auth required)
GET /licenses # List licenses (with pagination, search)
GET /licenses/{id} # Get single license
POST /licenses # Create license
PUT /licenses/{id} # Update license
DELETE /licenses/{id} # Delete license
# Public Endpoints (for client software validation)
POST /licenses/validate # Validate license key
POST /licenses/activate # Activate license on domain
POST /licenses/deactivate # Deactivate license from domain
# OAuth Endpoints (user auth required)
GET /licenses/oauth/validate # Validate OAuth state and license ownership
POST /licenses/oauth/confirm # Confirm activation and generate token
```
**Implementation Details:**
- **List:** Supports pagination (`page`, `per_page`), search by key/email
- **activate:** Supports Simple API and OAuth modes
- **OAuth flow:** `oauth/validate` + `oauth/confirm` for secure user verification
- See `LICENSING_MODULE.md` for full OAuth flow documentation
---
## Conflict Prevention Rules

284
LICENSING_MODULE.md Normal file
View File

@@ -0,0 +1,284 @@
# Licensing Module Documentation
## Overview
WooNooW's Licensing Module provides software license management for digital products. It supports two activation methods:
1. **Simple API** - Direct license key validation via API
2. **Secure OAuth** - User verification via vendor portal before activation
---
## API Endpoints
### Admin Endpoints (Authenticated Admin)
```
GET /licenses # List all licenses (with pagination, search)
GET /licenses/{id} # Get single license
POST /licenses # Create license
PUT /licenses/{id} # Update license
DELETE /licenses/{id} # Delete license
```
### Public Endpoints (For Client Software)
```
POST /licenses/validate # Validate license key
POST /licenses/activate # Activate license on domain
POST /licenses/deactivate # Deactivate license from domain
```
### OAuth Endpoints (Authenticated User)
```
GET /licenses/oauth/validate # Validate OAuth state and license ownership
POST /licenses/oauth/confirm # Confirm activation and get token
```
---
## Activation Flows
### 1. Simple API Flow
Direct license activation without user verification. Suitable for trusted environments.
```
Client Vendor API
| |
|-- POST /licenses/activate -|
| {license_key, domain} |
| |
|<-- {success, activation_id}|
```
**Example Request:**
```bash
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
-H "Content-Type: application/json" \
-d '{
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
"domain": "https://customer-site.com",
"machine_id": "optional-unique-id"
}'
```
**Example Response:**
```json
{
"success": true,
"activation_id": 123,
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
"status": "active",
"expires_at": "2025-01-31T00:00:00Z",
"activation_limit": 3,
"activation_count": 1
}
```
---
### 2. Secure OAuth Flow (Recommended)
User must verify ownership on vendor portal before activation. More secure.
```
Client Vendor Portal Vendor API
| | |
|-- POST /licenses/activate -| |
| {license_key, domain} | |
| | |
|<-- {oauth_redirect, state}-| |
| | |
|== User redirects browser ==| |
| | |
|-------- BROWSER ---------->| |
| /my-account/license-connect?license_key=...&state=...|
| | |
| [User logs in if needed] |
| [User sees confirmation page] |
| [User clicks "Authorize"] |
| | |
| |-- POST /oauth/confirm -->|
| |<-- {token} --------------|
| | |
|<------- REDIRECT ----------| |
| {return_url}?activation_token=xxx |
| | |
|-- POST /licenses/activate -----------------------> |
| {license_key, activation_token} |
| | |
|<-- {success, activation_id} --------------------------|
```
---
## OAuth Flow Step by Step
### Step 1: Client Requests Activation (OAuth Mode)
```bash
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
-H "Content-Type: application/json" \
-d '{
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
"domain": "https://customer-site.com",
"return_url": "https://customer-site.com/activation-callback",
"activation_mode": "oauth"
}'
```
**Response (OAuth Required):**
```json
{
"success": false,
"oauth_required": true,
"oauth_redirect": "https://vendor.com/my-account/license-connect/?license_key=XXXX-YYYY-ZZZZ-WWWW&site_url=https://customer-site.com&return_url=https://customer-site.com/activation-callback&state=abc123&nonce=xyz789",
"state": "abc123"
}
```
### Step 2: User Opens Browser to OAuth URL
Client opens the `oauth_redirect` URL in user's browser. The user:
1. Logs into vendor portal (if not already)
2. Sees license activation confirmation page
3. Reviews license key and requesting site
4. Clicks "Authorize" to confirm
### Step 3: User Gets Redirected Back
After authorization, user is redirected to `return_url` with token:
```
https://customer-site.com/activation-callback?activation_token=xyz123&license_key=XXXX-YYYY-ZZZZ-WWWW&nonce=xyz789
```
### Step 4: Client Exchanges Token for Activation
```bash
curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \
-H "Content-Type: application/json" \
-d '{
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
"domain": "https://customer-site.com",
"activation_token": "xyz123"
}'
```
**Response (Success):**
```json
{
"success": true,
"activation_id": 456,
"license_key": "XXXX-YYYY-ZZZZ-WWWW",
"status": "active"
}
```
---
## Configuration
### Site-Level Settings
In Admin SPA: **Settings > Licensing**
| Setting | Description |
|---------|-------------|
| Default Activation Method | `api` or `oauth` - Default for all products |
| License Key Format | Format pattern for generated keys |
| Default Validity Period | Days until license expires |
| Default Activation Limit | Max activations per license |
### Per-Product Settings
In Admin SPA: **Products > Edit Product > General Tab**
| Setting | Description |
|---------|-------------|
| Enable Licensing | Toggle to enable license generation |
| Activation Method | `Use Site Default`, `Simple API`, or `Secure OAuth` |
---
## Database Schema
### Licenses Table (`wp_woonoow_licenses`)
| Column | Type | Description |
|--------|------|-------------|
| id | BIGINT | Primary key |
| license_key | VARCHAR(255) | Unique license key |
| product_id | BIGINT | WooCommerce product ID |
| order_id | BIGINT | WooCommerce order ID |
| user_id | BIGINT | Customer user ID |
| status | VARCHAR(50) | active, inactive, expired, revoked |
| activation_limit | INT | Max allowed activations |
| activation_count | INT | Current activation count |
| expires_at | DATETIME | Expiration date |
| created_at | DATETIME | Created timestamp |
| updated_at | DATETIME | Updated timestamp |
### Activations Table (`wp_woonoow_license_activations`)
| Column | Type | Description |
|--------|------|-------------|
| id | BIGINT | Primary key |
| license_id | BIGINT | Foreign key to licenses |
| domain | VARCHAR(255) | Activated domain |
| machine_id | VARCHAR(255) | Optional machine identifier |
| status | VARCHAR(50) | active, deactivated, pending |
| user_agent | TEXT | Client user agent |
| activated_at | DATETIME | Activation timestamp |
---
## Customer SPA: License Connect Page
The OAuth confirmation page is available at:
```
/my-account/license-connect/
```
### Query Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| license_key | Yes | License key to activate |
| site_url | Yes | Requesting site URL |
| return_url | Yes | Callback URL after authorization |
| state | Yes | CSRF protection token |
| nonce | No | Additional security nonce |
### UI Features
- **Focused Layout** - No header/sidebar/footer, just the authorization card
- **Brand Display** - Shows vendor site name
- **License Details** - Displays license key, site URL, product name
- **Security Warning** - Warns user to only authorize trusted sites
- **Authorize/Deny Buttons** - Clear actions for user
---
## Security Considerations
1. **State Token** - Prevents CSRF attacks, expires after 5 minutes
2. **Activation Token** - Single-use, expires after 5 minutes
3. **User Verification** - OAuth ensures license owner authorizes activation
4. **Domain Validation** - Tracks activated domains for audit
5. **Rate Limiting** - Consider implementing on activation endpoints
---
## Files Reference
| File | Purpose |
|------|---------|
| `includes/Modules/Licensing/LicensingModule.php` | Module registration, endpoint handlers |
| `includes/Modules/Licensing/LicenseManager.php` | Core license operations |
| `includes/Api/LicensesController.php` | REST API endpoints |
| `customer-spa/src/pages/Account/LicenseConnect.tsx` | OAuth confirmation UI |
| `customer-spa/src/pages/Account/index.tsx` | Routing for license pages |
| `customer-spa/src/App.tsx` | Top-level routing (license-connect outside BaseLayout) |

View File

@@ -57,6 +57,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
@@ -2898,6 +2899,33 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz",

View File

@@ -59,6 +59,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",

View File

@@ -23,6 +23,8 @@ import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes';
import Licenses from '@/routes/Products/Licenses';
import LicenseDetail from '@/routes/Products/Licenses/Detail';
import SubscriptionsIndex from '@/routes/Subscriptions';
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
import CouponsIndex from '@/routes/Marketing/Coupons';
import CouponNew from '@/routes/Marketing/Coupons/New';
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
@@ -31,7 +33,7 @@ import CustomerNew from '@/routes/Customers/New';
import CustomerEdit from '@/routes/Customers/Edit';
import CustomerDetail from '@/routes/Customers/Detail';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2 } from 'lucide-react';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react';
import { Toaster } from 'sonner';
import { useShortcuts } from "@/hooks/useShortcuts";
import { CommandPalette } from "@/components/CommandPalette";
@@ -51,6 +53,8 @@ import { __ } from '@/lib/i18n';
import { ThemeToggle } from '@/components/ThemeToggle';
import { initializeWindowAPI } from '@/lib/windowAPI';
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
function useFullscreen() {
const [on, setOn] = useState<boolean>(() => {
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
@@ -134,8 +138,14 @@ function ActiveNavLink({ to, startsWith, end, className, children, childPaths }:
);
}
function Sidebar() {
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
}
function Sidebar({ collapsed, onToggle }: SidebarProps) {
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
const linkCollapsed = "flex items-center justify-center rounded-md p-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
const active = "bg-secondary";
const { main } = useActiveSection();
@@ -149,14 +159,27 @@ function Sidebar() {
'mail': Mail,
'palette': Palette,
'settings': SettingsIcon,
'help-circle': HelpCircle,
'repeat': Repeat,
};
// Get navigation tree from backend
const navTree = (window as any).WNW_NAV_TREE || [];
return (
<aside className="w-56 flex-shrink-0 p-3 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background">
<nav className="flex flex-col gap-1">
<aside className={`flex-shrink-0 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background flex flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
{/* Toggle button */}
<div className={`p-2 border-b border-border ${collapsed ? 'flex justify-center' : 'flex justify-end'}`}>
<button
onClick={onToggle}
className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
title={collapsed ? __('Expand sidebar') : __('Collapse sidebar')}
>
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className={`flex flex-col gap-1 flex-1 ${collapsed ? 'p-1' : 'p-3'}`}>
{navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package;
const isActive = main.key === item.key;
@@ -164,10 +187,11 @@ function Sidebar() {
<Link
key={item.key}
to={item.path}
className={`${link} ${isActive ? active : ''}`}
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
title={collapsed ? item.label : undefined}
>
<IconComponent className="w-4 h-4" />
<span>{item.label}</span>
<IconComponent className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
@@ -192,6 +216,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
'mail': Mail,
'palette': Palette,
'settings': SettingsIcon,
'repeat': Repeat,
};
// Get navigation tree from backend
@@ -238,6 +263,7 @@ import SettingsPayments from '@/routes/Settings/Payments';
import SettingsShipping from '@/routes/Settings/Shipping';
import SettingsTax from '@/routes/Settings/Tax';
import SettingsCustomers from '@/routes/Settings/Customers';
import SettingsSecurity from '@/routes/Settings/Security';
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
import SettingsNotifications from '@/routes/Settings/Notifications';
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
@@ -261,8 +287,12 @@ import AppearanceCart from '@/routes/Appearance/Cart';
import AppearanceCheckout from '@/routes/Appearance/Checkout';
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account';
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
import AppearancePages from '@/routes/Appearance/Pages';
import MarketingIndex from '@/routes/Marketing';
import Newsletter from '@/routes/Marketing/Newsletter';
import NewsletterLayout from '@/routes/Marketing/Newsletter';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import MorePage from '@/routes/More';
import Help from '@/routes/Help';
@@ -455,6 +485,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
) : (
<div className="font-semibold">{siteTitle}</div>
)}
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
title={__('Visit Store')}
>
<ExternalLink className="w-4 h-4" />
<span className="hidden sm:inline">{__('Store')}</span>
</a>
</div>
<div className="flex items-center gap-3">
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
@@ -556,6 +597,10 @@ function AppRoutes() {
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
<Route path="/orders/:id/label" element={<OrderLabel />} />
{/* Subscriptions */}
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
{/* Coupons (under Marketing) */}
<Route path="/coupons" element={<CouponsIndex />} />
<Route path="/coupons/new" element={<CouponNew />} />
@@ -580,6 +625,7 @@ function AppRoutes() {
<Route path="/settings/shipping" element={<SettingsShipping />} />
<Route path="/settings/tax" element={<SettingsTax />} />
<Route path="/settings/customers" element={<SettingsCustomers />} />
<Route path="/settings/security" element={<SettingsSecurity />} />
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
<Route path="/settings/checkout" element={<SettingsIndex />} />
@@ -608,11 +654,22 @@ function AppRoutes() {
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/menus" element={<AppearanceMenus />} />
<Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<Newsletter />} />
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
<Route index element={<Navigate to="subscribers" replace />} />
<Route path="subscribers" element={<NewsletterSubscribers />} />
<Route path="campaigns" element={<NewsletterCampaignsList />} />
<Route path="campaigns/:id" element={<CampaignEdit />} />
</Route>
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
{/* Help - Main menu route with no submenu */}
<Route path="/help" element={<Help />} />
@@ -638,6 +695,42 @@ function Shell() {
const location = useLocation();
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('wnwSidebarCollapsed') === '1'; } catch { return false; }
});
const [wasAutoCollapsed, setWasAutoCollapsed] = useState(false);
// Save sidebar state to localStorage
useEffect(() => {
try { localStorage.setItem('wnwSidebarCollapsed', sidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
}, [sidebarCollapsed]);
// Check if current route is Page Editor (auto-collapse route)
const isPageEditorRoute = location.pathname === '/appearance/pages';
// Auto-collapse/expand sidebar based on route
useEffect(() => {
if (isPageEditorRoute) {
// Auto-collapse when entering Page Editor (if not already collapsed)
if (!sidebarCollapsed) {
setSidebarCollapsed(true);
setWasAutoCollapsed(true);
}
} else {
// Auto-expand when leaving Page Editor (only if we auto-collapsed it)
if (wasAutoCollapsed && sidebarCollapsed) {
setSidebarCollapsed(false);
setWasAutoCollapsed(false);
}
}
}, [isPageEditorRoute]);
const toggleSidebar = () => {
setSidebarCollapsed(v => !v);
setWasAutoCollapsed(false); // Manual toggle clears auto state
};
// Check if standalone mode - force fullscreen and hide toggle
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
const fullscreen = isStandalone ? true : on;
@@ -660,7 +753,7 @@ function Shell() {
{fullscreen ? (
isDesktop ? (
<div className="flex flex-1 min-h-0">
<Sidebar />
<Sidebar collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
<main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
<div className="flex flex-col-reverse">

View File

@@ -14,24 +14,24 @@ interface BlockRendererProps {
isLast: boolean;
}
export function BlockRenderer({
block,
isEditing,
onEdit,
onDelete,
onMoveUp,
export function BlockRenderer({
block,
isEditing,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast
isLast
}: BlockRendererProps) {
// Prevent navigation in builder
const handleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.closest('a') ||
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
target.closest('a') ||
target.closest('button') ||
target.classList.contains('button') ||
target.classList.contains('button-outline') ||
@@ -42,7 +42,7 @@ export function BlockRenderer({
e.stopPropagation();
}
};
const renderBlockContent = () => {
switch (block.type) {
case 'card':
@@ -75,62 +75,77 @@ export function BlockRenderer({
marginBottom: '24px'
},
hero: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
background: 'linear-gradient(135deg, var(--wn-gradient-start, #667eea) 0%, var(--wn-gradient-end, #764ba2) 100%)',
color: '#fff',
borderRadius: '8px',
padding: '32px 40px',
marginBottom: '24px'
}
};
// Convert markdown to HTML for visual rendering
const htmlContent = parseMarkdownBasics(block.content);
return (
<div style={cardStyles[block.cardType]}>
<div
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
<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 [&_.text-link]:text-purple-600 [&_.text-link]:underline"
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
);
case 'button': {
const buttonStyle: React.CSSProperties = block.style === 'solid'
? {
display: 'inline-block',
background: '#7f54b3',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
// Different styles based on button type
let buttonStyle: React.CSSProperties;
if (block.style === 'link') {
// Plain link style - just underlined text
buttonStyle = {
color: 'var(--wn-primary, #7f54b3)',
textDecoration: 'underline',
};
} else if (block.style === 'outline') {
buttonStyle = {
display: 'inline-block',
background: 'transparent',
color: 'var(--wn-secondary, #7f54b3)',
padding: '12px 26px',
border: '2px solid var(--wn-secondary, #7f54b3)',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
} else {
// Solid style (default)
buttonStyle = {
display: 'inline-block',
background: 'var(--wn-primary, #7f54b3)',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
}
const containerStyle: React.CSSProperties = {
textAlign: block.align || 'center',
};
if (block.widthMode === 'full') {
buttonStyle.display = 'block';
buttonStyle.width = '100%';
buttonStyle.textAlign = 'center';
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
// Width modes don't apply to plain links
if (block.style !== 'link') {
if (block.widthMode === 'full') {
buttonStyle.display = 'block';
buttonStyle.width = '100%';
buttonStyle.textAlign = 'center';
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
}
}
return (
<div style={containerStyle}>
<a href={block.link} style={buttonStyle}>
@@ -166,13 +181,13 @@ export function BlockRenderer({
</div>
);
}
case 'divider':
return <hr className="border-t border-gray-300 my-4" />;
case 'spacer':
return <div style={{ height: `${block.height}px` }} />;
default:
return null;
}
@@ -184,7 +199,7 @@ export function BlockRenderer({
<div className={`transition-all ${isEditing ? 'ring-2 ring-purple-500 ring-offset-2' : ''}`}>
{renderBlockContent()}
</div>
{/* Hover Controls */}
<div className="absolute -right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-1 bg-white rounded-md shadow-lg border p-1">
{!isFirst && (

View File

@@ -101,13 +101,11 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
};
const openEditDialog = (block: EmailBlock) => {
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
setEditingBlockId(block.id);
if (block.type === 'card') {
// Convert markdown to HTML for rich text editor
const htmlContent = parseMarkdownBasics(block.content);
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
setEditingContent(htmlContent);
setEditingCardType(block.cardType);
} else if (block.type === 'button') {
@@ -124,7 +122,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
setEditingAlign(block.align);
}
console.log('[EmailBuilder] Setting editDialogOpen to true');
setEditDialogOpen(true);
};

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
import React, { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Image, Upload } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface MediaUploaderProps {
onSelect: (url: string, id?: number) => void;
type?: 'image' | 'video' | 'audio' | 'file';
title?: string;
buttonText?: string;
className?: string;
children?: React.ReactNode;
}
export function MediaUploader({
onSelect,
type = 'image',
title = __('Select Image'),
buttonText = __('Use Image'),
className,
children
}: MediaUploaderProps) {
const frameRef = useRef<any>(null);
const openMediaModal = (e: React.MouseEvent) => {
e.preventDefault();
// Check if wp.media is available
const wp = (window as any).wp;
if (!wp || !wp.media) {
console.warn('WordPress media library not available');
return;
}
// Reuse existing frame
if (frameRef.current) {
frameRef.current.open();
return;
}
// Create new frame
frameRef.current = wp.media({
title,
button: {
text: buttonText,
},
library: {
type,
},
multiple: false,
});
// Handle selection
frameRef.current.on('select', () => {
const state = frameRef.current.state();
const selection = state.get('selection');
if (selection.length > 0) {
const attachment = selection.first().toJSON();
onSelect(attachment.url, attachment.id);
}
});
frameRef.current.open();
};
return (
<div onClick={openMediaModal} className={className}>
{children || (
<Button variant="outline" size="sm" type="button">
<Upload className="w-4 h-4 mr-2" />
{__('Select Image')}
</Button>
)}
</div>
);
}

View File

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

View File

@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -28,19 +28,45 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
>(({ className, ...props }, ref) => {
// Get or create portal container inside the app for proper CSS scoping
const getPortalContainer = () => {
const appContainer = document.getElementById('woonoow-admin-app');
if (!appContainer) return document.body;
let portalRoot = document.getElementById('woonoow-dialog-portal');
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-dialog-portal';
// Copy theme class from documentElement for proper CSS variable inheritance
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
portalRoot.className = themeClass;
appContainer.appendChild(portalRoot);
} else {
// Update theme class in case it changed
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (!portalRoot.classList.contains(themeClass)) {
portalRoot.classList.remove('light', 'dark');
portalRoot.classList.add(themeClass);
}
}
return portalRoot;
};
return (
<AlertDialogPortal container={getPortalContainer()}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
})
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({

View File

@@ -40,7 +40,17 @@ const DialogContent = React.forwardRef<
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-dialog-portal';
// Copy theme class from documentElement for proper CSS variable inheritance
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
portalRoot.className = themeClass;
appContainer.appendChild(portalRoot);
} else {
// Update theme class in case it changed
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (!portalRoot.classList.contains(themeClass)) {
portalRoot.classList.remove('light', 'dark');
portalRoot.classList.add(themeClass);
}
}
return portalRoot;
};

View File

@@ -57,20 +57,46 @@ DropdownMenuSubContent.displayName =
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
>(({ className, sideOffset = 4, ...props }, ref) => {
// Get or create portal container inside the app for proper CSS scoping
const getPortalContainer = () => {
const appContainer = document.getElementById('woonoow-admin-app');
if (!appContainer) return document.body;
let portalRoot = document.getElementById('woonoow-dropdown-portal');
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-dropdown-portal';
// Copy theme class from documentElement for proper CSS variable inheritance
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
portalRoot.className = themeClass;
appContainer.appendChild(portalRoot);
} else {
// Update theme class in case it changed
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (!portalRoot.classList.contains(themeClass)) {
portalRoot.classList.remove('light', 'dark');
portalRoot.classList.add(themeClass);
}
}
return portalRoot;
};
return (
<DropdownMenuPrimitive.Portal container={getPortalContainer()}>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
})
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<

View File

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

View File

@@ -21,7 +21,7 @@ export interface Option {
/** What to render in the button/list. Can be a string or React node. */
label: React.ReactNode;
/** Optional text used for filtering. Falls back to string label or value. */
searchText?: string;
triggerLabel?: React.ReactNode;
}
interface Props {
@@ -55,7 +55,7 @@ export function SearchableSelect({
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
return (
<Popover open={disabled ? false : open} onOpenChange={(o)=> !disabled && setOpen(o)}>
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -65,7 +65,7 @@ export function SearchableSelect({
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
{selected ? selected.label : placeholder}
{selected ? (selected.triggerLabel ?? selected.label) : placeholder}
<ChevronsUpDown className="opacity-50 h-4 w-4 shrink-0" />
</Button>
</PopoverTrigger>

View File

@@ -89,7 +89,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className

View File

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

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, ReactNode } from 'react';
import React, { createContext, useContext, ReactNode, useEffect } from 'react';
interface AppContextType {
isStandalone: boolean;
@@ -7,15 +7,44 @@ interface AppContextType {
const AppContext = createContext<AppContextType | undefined>(undefined);
export function AppProvider({
children,
isStandalone,
exitFullscreen
}: {
children: ReactNode;
isStandalone: boolean;
export function AppProvider({
children,
isStandalone,
exitFullscreen
}: {
children: ReactNode;
isStandalone: boolean;
exitFullscreen?: () => void;
}) {
useEffect(() => {
// Fetch and apply appearance settings (colors)
const loadAppearance = async () => {
try {
const restUrl = (window as any).WNW_CONFIG?.restUrl || '';
const response = await fetch(`${restUrl}/appearance/settings`);
if (response.ok) {
const result = await response.json();
// API returns { success: true, data: { general: { colors: {...} } } }
const colors = result.data?.general?.colors;
if (colors) {
const root = document.documentElement;
// Inject all color settings as CSS variables
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
if (colors.text) root.style.setProperty('--wn-text', colors.text);
if (colors.background) root.style.setProperty('--wn-background', colors.background);
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
}
}
} catch (e) {
console.error('Failed to load appearance settings', e);
}
};
loadAppearance();
}, []);
return (
<AppContext.Provider value={{ isStandalone, exitFullscreen }}>
{children}

View File

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

View File

@@ -8,11 +8,27 @@ export function htmlToMarkdown(html: string): string {
let markdown = html;
// Headings
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
// Store aligned headings for preservation
const alignedHeadings: { [key: string]: string } = {};
let headingIndex = 0;
// Process headings with potential style attributes
for (let level = 1; level <= 4; level++) {
const hashes = '#'.repeat(level);
markdown = markdown.replace(new RegExp(`<h${level}([^>]*)>(.*?)</h${level}>`, 'gis'), (match, attrs, content) => {
// Check for text-align in style attribute
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
if (alignMatch) {
const align = alignMatch[1].toLowerCase();
const placeholder = `[[HEADING${headingIndex}]]`;
alignedHeadings[placeholder] = `<h${level} style="text-align: ${align};">${content}</h${level}>`;
headingIndex++;
return placeholder + '\n\n';
}
// No alignment, convert to markdown
return `${hashes} ${content}\n\n`;
});
}
// Bold
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
@@ -68,8 +84,23 @@ export function htmlToMarkdown(html: string): string {
}).join('\n') + '\n\n';
});
// Paragraphs - convert to double newlines
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, '$1\n\n');
// Paragraphs - preserve text-align by using placeholders
const alignedParagraphs: { [key: string]: string } = {};
let alignIndex = 0;
markdown = markdown.replace(/<p([^>]*)>(.*?)<\/p>/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();
// Use double-bracket placeholder that won't be matched by HTML regex
const placeholder = `[[ALIGN${alignIndex}]]`;
alignedParagraphs[placeholder] = `<p style="text-align: ${align};">${content}</p>`;
alignIndex++;
return placeholder + '\n\n';
}
// No alignment, convert to plain text
return `${content}\n\n`;
});
// Line breaks
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
@@ -80,6 +111,16 @@ export function htmlToMarkdown(html: string): string {
// Remove remaining HTML tags
markdown = markdown.replace(/<[^>]+>/g, '');
// Restore aligned paragraphs
Object.entries(alignedParagraphs).forEach(([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
markdown = markdown.replace(/\n{3,}/g, '\n\n');

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ export default function AppearanceGeneral() {
const [customHeading, setCustomHeading] = useState('');
const [customBody, setCustomBody] = useState('');
const [fontScale, setFontScale] = useState([1.0]);
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
const fontPairs = {
modern: { name: 'Modern & Clean', fonts: 'Inter' },
@@ -36,13 +37,15 @@ export default function AppearanceGeneral() {
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
};
const [colors, setColors] = useState({
primary: '#1a1a1a',
secondary: '#6b7280',
accent: '#3b82f6',
text: '#111827',
background: '#ffffff',
gradientStart: '#9333ea', // purple-600 defaults
gradientEnd: '#3b82f6', // blue-500 defaults
});
useEffect(() => {
@@ -51,7 +54,7 @@ export default function AppearanceGeneral() {
// Load appearance settings
const response = await api.get('/appearance/settings');
const general = response.data?.general;
if (general) {
if (general.spa_mode) setSpaMode(general.spa_mode);
if (general.spa_page) setSpaPage(general.spa_page || 0);
@@ -63,6 +66,9 @@ export default function AppearanceGeneral() {
setCustomBody(general.typography.custom?.body || '');
setFontScale([general.typography.scale || 1.0]);
}
if (general.container_width) {
setContainerWidth(general.container_width);
}
if (general.colors) {
setColors({
primary: general.colors.primary || '#1a1a1a',
@@ -70,10 +76,12 @@ export default function AppearanceGeneral() {
accent: general.colors.accent || '#3b82f6',
text: general.colors.text || '#111827',
background: general.colors.background || '#ffffff',
gradientStart: general.colors.gradientStart || '#9333ea',
gradientEnd: general.colors.gradientEnd || '#3b82f6',
});
}
}
// Load available pages
const pagesResponse = await api.get('/pages/list');
console.log('Pages API response:', pagesResponse);
@@ -90,7 +98,7 @@ export default function AppearanceGeneral() {
setLoading(false);
}
};
loadSettings();
}, []);
@@ -106,9 +114,10 @@ export default function AppearanceGeneral() {
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
scale: fontScale[0],
},
containerWidth,
colors,
});
toast.success('General settings saved successfully');
} catch (error) {
console.error('Save error:', error);
@@ -139,7 +148,7 @@ export default function AppearanceGeneral() {
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="checkout_only" id="spa-checkout" />
<div className="space-y-1">
@@ -151,7 +160,7 @@ export default function AppearanceGeneral() {
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="full" id="spa-full" />
<div className="space-y-1">
@@ -175,14 +184,14 @@ export default function AppearanceGeneral() {
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This page will render the full SPA to the body element with no theme interference.
This page will render the full SPA to the body element with no theme interference.
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
</AlertDescription>
</Alert>
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
<Select
value={spaPage.toString()}
<Select
value={spaPage.toString()}
onValueChange={(value) => setSpaPage(parseInt(value))}
>
<SelectTrigger id="spa-page">
@@ -203,6 +212,36 @@ export default function AppearanceGeneral() {
<strong>Tip:</strong> You can set this page as your homepage in Settings Reading
</p>
</SettingsSection>
<SettingsSection label="Container Width" htmlFor="container-width">
<RadioGroup value={containerWidth} onValueChange={(value: any) => setContainerWidth(value)}>
<div className="flex items-start space-x-3">
<RadioGroupItem value="boxed" id="width-boxed" />
<div className="space-y-1">
<Label htmlFor="width-boxed" className="font-medium cursor-pointer">
Boxed
</Label>
<p className="text-sm text-muted-foreground">
Content centered with max-width (recommended)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="fullwidth" id="width-full" />
<div className="space-y-1">
<Label htmlFor="width-full" className="font-medium cursor-pointer">
Full Width
</Label>
<p className="text-sm text-muted-foreground">
Content fills entire screen width
</p>
</div>
</div>
</RadioGroup>
<p className="text-sm text-muted-foreground mt-2">
Default width for all pages (can be overridden per page)
</p>
</SettingsSection>
</div>
</SettingsCard>
@@ -246,7 +285,7 @@ export default function AppearanceGeneral() {
<p className="text-sm text-muted-foreground mb-3">
Self-hosted fonts, no external requests
</p>
{typographyMode === 'predefined' && (
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
@@ -284,7 +323,7 @@ export default function AppearanceGeneral() {
)}
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="custom_google" id="typo-custom" />
<div className="space-y-1 flex-1">
@@ -297,7 +336,7 @@ export default function AppearanceGeneral() {
Using Google Fonts may not be GDPR compliant
</AlertDescription>
</Alert>
{typographyMode === 'custom_google' && (
<div className="space-y-3 mt-3">
<SettingsSection label="Heading Font" htmlFor="heading-font">
@@ -321,7 +360,7 @@ export default function AppearanceGeneral() {
</div>
</div>
</RadioGroup>
<div className="space-y-3 pt-4 border-t">
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
<Slider
@@ -345,18 +384,18 @@ export default function AppearanceGeneral() {
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(colors).map(([key, value]) => (
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}>
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
<div className="flex gap-2">
<Input
id={`color-${key}`}
type="color"
value={value}
value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
type="text"
value={value}
value={value as string}
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
className="flex-1 font-mono"
/>

View File

@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus, GripVertical, Trash2, Link as LinkIcon, FileText, Check, AlertCircle, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { SearchableSelect } from '@/components/ui/searchable-select';
// Types
interface Page {
id: number;
title: string;
slug: string;
is_woonoow_page?: boolean;
is_store_page?: boolean;
}
interface MenuItem {
id: string;
label: string;
type: 'page' | 'custom';
value: string;
target: '_self' | '_blank';
}
interface MenuSettings {
primary: MenuItem[];
mobile: MenuItem[];
}
// Sortable Item Component
function SortableMenuItem({
item,
onRemove,
onUpdate,
pages
}: {
item: MenuItem;
onRemove: (id: string) => void;
onUpdate: (id: string, updates: Partial<MenuItem>) => void;
pages: Page[];
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const [isEditing, setIsEditing] = useState(false);
return (
<div
ref={setNodeRef}
style={style}
className={`bg-white border rounded-lg mb-2 shadow-sm ${isEditing ? 'ring-2 ring-primary ring-offset-1' : ''}`}
>
<div className="flex items-center p-3 gap-3">
<div {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
<GripVertical className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0" onClick={() => setIsEditing(!isEditing)}>
<div className="flex items-center gap-2 font-medium truncate">
{item.type === 'page' ? <FileText className="w-4 h-4 text-blue-500" /> : <LinkIcon className="w-4 h-4 text-green-500" />}
{item.type === 'page' ? (
(() => {
const page = pages.find(p => p.slug === item.value);
if (page?.is_store_page) {
return <span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-[10px] font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
}
if (item.value === '/' || page?.is_woonoow_page) {
return <span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-[10px] font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
}
return <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
})()
) : (
<span className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">Custom</span>
)}
</div>
<div className="text-xs text-gray-500 truncate">
{item.type === 'page' ? `Page: /${item.value}` : `URL: ${item.value}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-red-500" onClick={() => onRemove(item.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{isEditing && (
<div className="p-4 border-t bg-gray-50 rounded-b-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Label</Label>
<Input
value={item.label}
onChange={(e) => onUpdate(item.id, { label: e.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Target</Label>
<Select
value={item.target}
onValueChange={(val: any) => onUpdate(item.id, { target: val })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="_self">Same Tab</SelectItem>
<SelectItem value="_blank">New Tab</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{item.type === 'custom' && (
<div className="space-y-2">
<Label className="text-xs">URL</Label>
<Input
value={item.value}
onChange={(e) => onUpdate(item.id, { value: e.target.value })}
className="h-8 text-sm font-mono"
/>
</div>
)}
</div>
)}
</div>
);
}
export default function MenuEditor() {
const [menus, setMenus] = useState<MenuSettings>({ primary: [], mobile: [] });
const [activeTab, setActiveTab] = useState<'primary' | 'mobile'>('primary');
const [loading, setLoading] = useState(true);
const [pages, setPages] = useState<Page[]>([]);
const [spaPageId, setSpaPageId] = useState<number>(0);
// New Item State
const [newItemType, setNewItemType] = useState<'page' | 'custom'>('page');
const [newItemLabel, setNewItemLabel] = useState('');
const [newItemValue, setNewItemValue] = useState('');
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [settingsRes, pagesRes] = await Promise.all([
api.get('/appearance/settings'),
api.get('/pages/list')
]);
const settings = settingsRes.data;
if (settings.menus) {
setMenus(settings.menus);
} else {
// Default seeding if empty
setMenus({
primary: [
{ id: 'home', label: 'Home', type: 'page', value: '/', target: '_self' },
{ id: 'shop', label: 'Shop', type: 'page', value: 'shop', target: '_self' }
],
mobile: []
});
}
if (settings.general?.spa_page) {
setSpaPageId(parseInt(settings.general.spa_page));
}
if (pagesRes.success) {
setPages(pagesRes.data);
}
setLoading(false);
} catch (error) {
console.error('Failed to load menu data', error);
toast.error('Failed to load menu data');
setLoading(false);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
setMenus((prev) => {
const list = prev[activeTab];
const oldIndex = list.findIndex((item) => item.id === active.id);
const newIndex = list.findIndex((item) => item.id === over?.id);
return {
...prev,
[activeTab]: arrayMove(list, oldIndex, newIndex),
};
});
}
};
const addItem = () => {
if (!newItemLabel) {
toast.error('Label is required');
return;
}
if (!newItemValue) {
toast.error('Destination is required');
return;
}
const newItem: MenuItem = {
id: Math.random().toString(36).substr(2, 9),
label: newItemLabel,
type: newItemType,
value: newItemValue,
target: '_self'
};
setMenus(prev => ({
...prev,
[activeTab]: [...prev[activeTab], newItem]
}));
// Reset form
setNewItemLabel('');
if (newItemType === 'custom') setNewItemValue('');
toast.success('Item added');
};
const removeItem = (id: string) => {
setMenus(prev => ({
...prev,
[activeTab]: prev[activeTab].filter(item => item.id !== id)
}));
};
const updateItem = (id: string, updates: Partial<MenuItem>) => {
setMenus(prev => ({
...prev,
[activeTab]: prev[activeTab].map(item => item.id === id ? { ...item, ...updates } : item)
}));
};
const handleSave = async () => {
try {
await api.post('/appearance/menus', { menus });
toast.success('Menus saved successfully');
} catch (error) {
toast.error('Failed to save menus');
}
};
if (loading) {
return <div className="p-8 flex justify-center"><div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div></div>;
}
return (
<SettingsLayout
title="Menu Editor"
description="Manage your store's navigation menus"
onSave={handleSave}
isLoading={loading}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Col: Add Items */}
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-lg">Add Items</CardTitle>
<CardDescription>Add pages or custom links</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
<Tabs value={newItemType} onValueChange={(v: any) => setNewItemType(v)} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="page">Page</TabsTrigger>
<TabsTrigger value="custom">Custom URL</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<Label>Label</Label>
<Input
placeholder="e.g. Shop"
value={newItemLabel}
onChange={(e) => setNewItemLabel(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Destination</Label>
{newItemType === 'page' ? (
<SearchableSelect
value={newItemValue}
onChange={setNewItemValue}
options={[
{
value: '/',
label: (
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
</div>
),
triggerLabel: (
<div className="flex items-center justify-between w-full">
<span className="flex items-center gap-2"><Home className="w-4 h-4" /> Home</span>
<span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>
</div>
),
searchText: 'Home'
},
...pages.filter(p => p.id !== spaPageId).map(page => {
const Badge = () => {
if (page.is_store_page) {
return <span className="ml-2 inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Store</span>;
}
if (page.is_woonoow_page) {
return <span className="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">WooNooW</span>;
}
return <span className="ml-2 inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">WP</span>;
};
return {
value: page.slug,
label: (
<div className="flex items-center justify-between w-full">
<div className="flex flex-col overflow-hidden">
<span className="truncate">{page.title}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">/{page.slug}</span>
</div>
<Badge />
</div>
),
triggerLabel: (
<div className="flex items-center justify-between w-full">
<span className="truncate">{page.title}</span>
<Badge />
</div>
),
searchText: `${page.title} ${page.slug}`
};
})
]}
placeholder="Select a page"
className="w-full"
/>
) : (
<Input
placeholder="https://"
value={newItemValue}
onChange={(e) => setNewItemValue(e.target.value)}
/>
)}
</div>
<Button className="w-full" variant="outline" onClick={addItem}>
<Plus className="w-4 h-4 mr-2" /> Add to Menu
</Button>
</CardContent>
</Card>
{/* Right Col: Menu Structure */}
<div className="md:col-span-2 space-y-6">
<Tabs value={activeTab} onValueChange={(v: any) => setActiveTab(v)}>
<TabsList className="w-full justify-start">
<TabsTrigger value="primary">Primary Menu</TabsTrigger>
<TabsTrigger value="mobile">Mobile Menu (Optional)</TabsTrigger>
</TabsList>
<TabsContent value="primary" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Menu Structure</CardTitle>
<CardDescription>Drag and drop to reorder items</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={menus.primary.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
{menus.primary.length === 0 ? (
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No items in menu</p>
</div>
) : (
menus.primary.map((item) => (
<SortableMenuItem
key={item.id}
item={item}
onRemove={removeItem}
onUpdate={updateItem}
pages={pages}
/>
))
)}
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mobile" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Mobile Menu Structure</CardTitle>
<CardDescription>
Leave empty to use Primary Menu automatically.
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={menus.mobile.map(item => item.id)}
strategy={verticalListSortingStrategy}
>
{menus.mobile.length === 0 ? (
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
<p>Using Primary Menu</p>
</div>
) : (
menus.mobile.map((item) => (
<SortableMenuItem
key={item.id}
item={item}
onRemove={removeItem}
onUpdate={updateItem}
pages={pages}
/>
))
)}
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</SettingsLayout>
);
}

View File

@@ -0,0 +1,284 @@
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { cn } from '@/lib/utils';
import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CanvasSection } from './CanvasSection';
import {
HeroRenderer,
ContentRenderer,
ImageTextRenderer,
FeatureGridRenderer,
CTABannerRenderer,
ContactFormRenderer,
} from './section-renderers';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface CanvasRendererProps {
sections: Section[];
selectedSectionId: string | null;
deviceMode: 'desktop' | 'mobile';
onSelectSection: (id: string | null) => void;
onAddSection: (type: string, index?: number) => void;
onDeleteSection: (id: string) => void;
onDuplicateSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
containerWidth?: 'boxed' | 'fullwidth' | 'default';
}
const SECTION_TYPES = [
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
{ type: 'content', label: 'Content', icon: LayoutTemplate },
{ type: 'image-text', label: 'Image + Text', icon: LayoutTemplate },
{ type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate },
{ type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate },
{ type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate },
];
// Map section type to renderer component
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
'hero': HeroRenderer,
'content': ContentRenderer,
'image-text': ImageTextRenderer,
'feature-grid': FeatureGridRenderer,
'cta-banner': CTABannerRenderer,
'contact-form': ContactFormRenderer,
};
export function CanvasRenderer({
sections,
selectedSectionId,
deviceMode,
onSelectSection,
onAddSection,
onDeleteSection,
onDuplicateSection,
onMoveSection,
onReorderSections,
onDeviceModeChange,
containerWidth = 'default',
}: CanvasRendererProps) {
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const newSections = arrayMove(sections, oldIndex, newIndex);
onReorderSections(newSections);
}
};
const handleCanvasClick = (e: React.MouseEvent) => {
// Only deselect if clicking directly on canvas background
if (e.target === e.currentTarget) {
onSelectSection(null);
}
};
return (
<div className="flex-1 flex flex-col bg-gray-100 overflow-hidden">
{/* Device mode toggle */}
<div className="flex items-center justify-center gap-2 py-3 bg-white border-b">
<Button
variant={deviceMode === 'desktop' ? 'default' : 'ghost'}
size="sm"
onClick={() => onDeviceModeChange('desktop')}
className="gap-2"
>
<Monitor className="w-4 h-4" />
Desktop
</Button>
<Button
variant={deviceMode === 'mobile' ? 'default' : 'ghost'}
size="sm"
onClick={() => onDeviceModeChange('mobile')}
className="gap-2"
>
<Smartphone className="w-4 h-4" />
Mobile
</Button>
</div>
{/* Canvas viewport */}
<div
className="flex-1 overflow-y-auto p-6"
onClick={handleCanvasClick}
>
<div
className={cn(
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
deviceMode === 'mobile' ? 'max-w-sm' : (
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
)
)}
>
{sections.length === 0 ? (
<div className="py-24 text-center text-gray-400">
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">No sections yet</p>
<p className="text-sm mb-6">Add your first section to start building</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Add Section
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{SECTION_TYPES.map((type) => (
<DropdownMenuItem
key={type.type}
onClick={() => onAddSection(type.type)}
>
<type.icon className="w-4 h-4 mr-2" />
{type.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="flex flex-col">
{/* Top Insertion Zone */}
<InsertionZone
index={0}
onAdd={(type) => onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index.
// Actually onAddSection in Props is (type) => void. I need to update Props too.
// Let's check props interface above.
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sections.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col">
{sections.map((section, index) => {
const Renderer = SECTION_RENDERERS[section.type];
return (
<React.Fragment key={section.id}>
<CanvasSection
section={section}
isSelected={selectedSectionId === section.id}
isHovered={hoveredSectionId === section.id}
onSelect={() => onSelectSection(section.id)}
onHover={() => setHoveredSectionId(section.id)}
onLeave={() => setHoveredSectionId(null)}
onDelete={() => onDeleteSection(section.id)}
onDuplicate={() => onDuplicateSection(section.id)}
onMoveUp={() => onMoveSection(section.id, 'up')}
onMoveDown={() => onMoveSection(section.id, 'down')}
canMoveUp={index > 0}
canMoveDown={index < sections.length - 1}
>
{Renderer ? (
<Renderer section={section} />
) : (
<div className="p-8 text-center text-gray-400">
Unknown section type: {section.type}
</div>
)}
</CanvasSection>
{/* Insertion Zone After Section */}
<InsertionZone
index={index + 1}
onAdd={(type) => onAddSection(type, index + 1)}
/>
</React.Fragment>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
</div>
</div>
);
}
// Helper: Insertion Zone Component
function InsertionZone({ index, onAdd }: { index: number; onAdd: (type: string) => void }) {
return (
<div className="group relative h-4 -my-2 z-10 flex items-center justify-center transition-all hover:h-8 hover:my-0">
{/* Line */}
<div className="absolute left-4 right-4 h-0.5 bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity" />
{/* Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="relative z-10 w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all hover:scale-110 shadow-sm"
title="Add Section Here"
>
<Plus className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{SECTION_TYPES.map((type) => (
<DropdownMenuItem
key={type.type}
onClick={() => onAdd(type.type)}
>
<type.icon className="w-4 h-4 mr-2" />
{type.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import React, { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { GripVertical, Trash2, Copy, ChevronUp, ChevronDown } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { __ } from '@/lib/i18n';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Section } from '../store/usePageEditorStore';
interface CanvasSectionProps {
section: Section;
children: ReactNode;
isSelected: boolean;
isHovered: boolean;
onSelect: () => void;
onHover: () => void;
onLeave: () => void;
onDelete: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}
export function CanvasSection({
section,
children,
isSelected,
isHovered,
onSelect,
onHover,
onLeave,
onDelete,
onDuplicate,
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
}: CanvasSectionProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'relative group transition-all duration-200',
isDragging && 'opacity-50 z-50',
isSelected && 'ring-2 ring-blue-500 ring-offset-2',
isHovered && !isSelected && 'ring-1 ring-blue-300'
)}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onMouseEnter={onHover}
onMouseLeave={onLeave}
>
{/* Section content with Styles */}
<div
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")}
style={{
backgroundColor: section.styles?.backgroundColor,
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
/>
<div
className="absolute inset-0 z-0 bg-black"
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
/>
</>
)}
{/* Content Wrapper */}
<div className={cn(
"relative z-10",
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
)}>
{children}
</div>
</div>
{/* Floating Toolbar (Standard Interaction) */}
{isSelected && (
<div className="absolute -top-10 right-0 z-50 flex items-center gap-1 bg-white shadow-lg border rounded-lg px-2 py-1 animate-in fade-in slide-in-from-bottom-2">
{/* Label */}
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide mr-2 px-1">
{section.type.replace('-', ' ')}
</span>
{/* Divider */}
<div className="w-px h-4 bg-gray-200 mx-1" />
{/* Actions */}
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
disabled={!canMoveUp}
className={cn(
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
!canMoveUp && 'opacity-30 cursor-not-allowed'
)}
title="Move up"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
disabled={!canMoveDown}
className={cn(
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
!canMoveDown && 'opacity-30 cursor-not-allowed'
)}
title="Move down"
>
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition"
title="Duplicate"
>
<Copy className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-gray-200 mx-1" />
<div className="w-px h-4 bg-gray-200 mx-1" />
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Active Border Label */}
{isSelected && (
<div className="absolute -top-px left-0 bg-blue-500 text-white text-[10px] uppercase font-bold px-2 py-0.5 rounded-b-sm z-10">
{section.type}
</div>
)}
{/* Drag Handle (Always visible on hover or select) */}
{(isSelected || isHovered) && (
<button
{...attributes}
{...listeners}
className="absolute top-1/2 -left-8 -translate-y-1/2 p-1.5 rounded text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing hover:bg-gray-100"
title="Drag to reorder"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-5 h-5" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { FileText, Layout, Loader2 } from 'lucide-react';
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
}
interface CreatePageModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (page: PageItem) => void;
}
export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) {
const [pageType, setPageType] = useState<'page' | 'template'>('page');
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
// Prevent double submission
const isSubmittingRef = useRef(false);
// Get site URL from WordPress config
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
// Fetch templates
const { data: templates = [] } = useQuery({
queryKey: ['templates-presets'],
queryFn: async () => {
const res = await api.get('/templates/presets');
return res as { id: string; label: string; description: string; icon: string }[];
}
});
// Create page mutation
const createMutation = useMutation({
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
// Guard against double submission
if (isSubmittingRef.current) {
throw new Error('Request already in progress');
}
isSubmittingRef.current = true;
try {
// api.post returns JSON directly (not wrapped in { data: ... })
const response = await api.post('/pages', {
title: data.title,
slug: data.slug,
templateId: data.templateId
});
return response; // Return response directly, not response.data
} finally {
// Reset after a delay to prevent race conditions
setTimeout(() => {
isSubmittingRef.current = false;
}, 500);
}
},
onSuccess: (data) => {
if (data?.page) {
toast.success(__('Page created successfully'));
onCreated({
id: data.page.id,
type: 'page',
slug: data.page.slug,
title: data.page.title,
});
onOpenChange(false);
setTitle('');
setSlug('');
setSelectedTemplateId('blank');
}
},
onError: (error: any) => {
// Don't show error for duplicate prevention
if (error?.message === 'Request already in progress') {
return;
}
// Extract error message from the response
const message = error?.response?.data?.message ||
error?.message ||
__('Failed to create page');
toast.error(message);
},
});
// Auto-generate slug from title
const handleTitleChange = (value: string) => {
setTitle(value);
// Auto-generate slug only if slug matches the previously auto-generated value
const autoSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
if (!slug || slug === autoSlug) {
setSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''));
}
};
// Handle form submission
const handleSubmit = () => {
if (createMutation.isPending || isSubmittingRef.current) {
return;
}
if (pageType === 'page' && title && slug) {
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
}
};
// Reset form when modal closes
useEffect(() => {
if (!open) {
setTitle('');
setSlug('');
setPageType('page');
setSelectedTemplateId('blank');
isSubmittingRef.current = false;
}
}, [open]);
const isDisabled = pageType === 'page' && (!title || !slug) || createMutation.isPending || isSubmittingRef.current;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Create New Page')}</DialogTitle>
<DialogDescription>
{__('Choose what type of page you want to create.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4 px-1">
{/* Page Type Selection */}
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
<div
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
onClick={() => setPageType('page')}
>
<RadioGroupItem value="page" id="page" className="mt-1" />
<div className="flex-1">
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
<FileText className="w-4 h-4" />
{__('Structural Page')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Static content like About, Contact, Terms')}
</p>
</div>
</div>
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
<div className="flex-1">
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
<Layout className="w-4 h-4" />
{__('CPT Template')}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{__('Templates are auto-created for each post type')}
</p>
</div>
</div>
</RadioGroup>
{/* Page Details */}
{pageType === 'page' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Page Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={__('e.g., About Us')}
disabled={createMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">{__('URL Slug')}</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder={__('e.g., about-us')}
disabled={createMutation.isPending}
/>
<p className="text-xs text-muted-foreground truncate">
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
</p>
</div>
</div>
<div className="space-y-3">
<Label>{__('Choose a Template')}</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{templates.map((tpl) => (
<div
key={tpl.id}
className={`
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
`}
onClick={() => setSelectedTemplateId(tpl.id)}
>
<div className="mb-2 font-medium text-sm flex items-center gap-2">
{tpl.label}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
</div>
))}
{templates.length === 0 && (
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
{__('Loading templates...')}
</div>
)}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={createMutation.isPending}>
{__('Cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isDisabled}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{__('Creating...')}
</>
) : (
__('Create Page')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { __ } from '@/lib/i18n';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MediaUploader } from '@/components/MediaUploader';
import { Image as ImageIcon } from 'lucide-react';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
export interface SectionProp {
type: 'static' | 'dynamic';
value?: any;
source?: string;
}
interface InspectorFieldProps {
fieldName: string;
fieldLabel: string;
fieldType: 'text' | 'textarea' | 'url' | 'image' | 'rte';
value: SectionProp;
onChange: (value: SectionProp) => void;
supportsDynamic?: boolean;
availableSources?: { value: string; label: string }[];
}
export function InspectorField({
fieldName,
fieldLabel,
fieldType,
value,
onChange,
supportsDynamic = false,
availableSources = [],
}: InspectorFieldProps) {
const isDynamic = value.type === 'dynamic';
const currentValue = isDynamic ? (value.source || '') : (value.value || '');
const handleValueChange = (newValue: string) => {
if (isDynamic) {
onChange({ type: 'dynamic', source: newValue });
} else {
onChange({ type: 'static', value: newValue });
}
};
const handleTypeToggle = (dynamic: boolean) => {
if (dynamic) {
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
} else {
onChange({ type: 'static', value: '' });
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={fieldName} className="text-sm font-medium">
{fieldLabel}
</Label>
{supportsDynamic && availableSources.length > 0 && (
<div className="flex items-center gap-2">
<span className={cn(
'text-xs',
isDynamic ? 'text-orange-500 font-medium' : 'text-gray-400'
)}>
{isDynamic ? '◆ Dynamic' : 'Static'}
</span>
<Switch
checked={isDynamic}
onCheckedChange={handleTypeToggle}
/>
</div>
)}
</div>
{isDynamic && supportsDynamic ? (
<Select value={currentValue} onValueChange={handleValueChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select data source" />
</SelectTrigger>
<SelectContent>
{availableSources.map((source) => (
<SelectItem key={source.value} value={source.value}>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : fieldType === 'rte' ? (
<RichTextEditor
content={currentValue}
onChange={handleValueChange}
placeholder={`Enter ${fieldLabel.toLowerCase()}...`}
/>
) : fieldType === 'textarea' ? (
<Textarea
id={fieldName}
value={currentValue}
onChange={(e) => handleValueChange(e.target.value)}
rows={4}
className="resize-none"
/>
) : (
<div className="flex gap-2">
<Input
id={fieldName}
type={fieldType === 'url' ? 'url' : 'text'}
value={currentValue}
onChange={(e) => handleValueChange(e.target.value)}
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
className="flex-1"
/>
{(fieldType === 'url' || fieldType === 'image') && (
<MediaUploader
onSelect={(url) => handleValueChange(url)}
type="image"
className="shrink-0"
>
<Button variant="outline" size="icon" title={__('Select Image')}>
<ImageIcon className="w-4 h-4" />
</Button>
</MediaUploader>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,808 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Slider } from '@/components/ui/slider';
import {
Settings,
PanelRightClose,
PanelRight,
Trash2,
ExternalLink,
Palette,
Type,
Home
} from 'lucide-react';
import { InspectorField, SectionProp } from './InspectorField';
import { InspectorRepeater } from './InspectorRepeater';
import { MediaUploader } from '@/components/MediaUploader';
import { SectionStyles, ElementStyle } from '../store/usePageEditorStore';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp>;
}
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth';
}
interface InspectorPanelProps {
page: PageItem | null;
selectedSection: Section | null;
isCollapsed: boolean;
isTemplate: boolean;
availableSources: { value: string; label: string }[];
onToggleCollapse: () => void;
onSectionPropChange: (propName: string, value: SectionProp) => void;
onLayoutChange: (layout: string) => void;
onColorSchemeChange: (scheme: string) => void;
onSectionStylesChange: (styles: Partial<SectionStyles>) => void;
onElementStylesChange: (fieldName: string, styles: Partial<ElementStyle>) => void;
onDeleteSection: () => void;
onSetAsSpaLanding?: () => void;
onUnsetSpaLanding?: () => void;
onDeletePage?: () => void;
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
}
// Section field configurations
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
hero: [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
content: [
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
'image-text': [
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
{ name: 'cta_text', label: 'Button Text', type: 'text' },
{ name: 'cta_url', label: 'Button URL', type: 'url' },
],
'feature-grid': [
{ name: 'heading', label: 'Heading', type: 'text' },
],
'cta-banner': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Description', type: 'text' },
{ name: 'button_text', label: 'Button Text', type: 'text' },
{ name: 'button_url', label: 'Button URL', type: 'url' },
],
'contact-form': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
],
};
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
hero: [
{ value: 'default', label: 'Centered' },
{ value: 'hero-left-image', label: 'Image Left' },
{ value: 'hero-right-image', label: 'Image Right' },
],
'image-text': [
{ value: 'image-left', label: 'Image Left' },
{ value: 'image-right', label: 'Image Right' },
],
'feature-grid': [
{ value: 'grid-2', label: '2 Columns' },
{ value: 'grid-3', label: '3 Columns' },
{ value: 'grid-4', label: '4 Columns' },
],
content: [
{ value: 'default', label: 'Full Width' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'medium', label: 'Medium' },
],
};
const COLOR_SCHEMES = [
{ value: 'default', label: 'Default' },
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'muted', label: 'Muted' },
{ value: 'gradient', label: 'Gradient' },
];
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
hero: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'cta_text', label: 'Button', type: 'text' },
],
content: [
{ name: 'heading', label: 'Headings', type: 'text' },
{ name: 'text', label: 'Body Text', type: 'text' },
{ name: 'link', label: 'Links', type: 'text' },
{ name: 'image', label: 'Images', type: 'image' },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
],
'image-text': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Text', type: 'text' },
{ name: 'image', label: 'Image', type: 'image' },
{ name: 'button', label: 'Button', type: 'text' },
],
'feature-grid': [
{ name: 'heading', label: 'Heading', type: 'text' },
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
],
'cta-banner': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'text', label: 'Description', type: 'text' },
{ name: 'button_text', label: 'Button', type: 'text' },
],
'contact-form': [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'button', label: 'Button', type: 'text' },
{ name: 'fields', label: 'Input Fields', type: 'text' },
],
};
export function InspectorPanel({
page,
selectedSection,
isCollapsed,
isTemplate,
availableSources,
onToggleCollapse,
onSectionPropChange,
onLayoutChange,
onColorSchemeChange,
onSectionStylesChange,
onElementStylesChange,
onDeleteSection,
onSetAsSpaLanding,
onUnsetSpaLanding,
onDeletePage,
onContainerWidthChange,
}: InspectorPanelProps) {
if (isCollapsed) {
return (
<div className="w-10 border-l bg-white flex flex-col items-center py-4">
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="mb-4">
<PanelRight className="w-4 h-4" />
</Button>
</div>
);
}
if (!selectedSection) {
return (
<div className="w-80 border-l bg-white flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b shrink-0">
<h3 className="font-semibold text-sm">{__('Page Settings')}</h3>
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8">
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="p-4 space-y-4 overflow-y-auto">
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
<Settings className="w-4 h-4" />
{isTemplate ? __('Template Info') : __('Page Info')}
</div>
<div className="space-y-4 bg-gray-50 p-3 rounded-lg border">
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Type')}</Label>
<p className="text-sm font-medium">
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
</p>
</div>
{page?.title && (
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Title')}</Label>
<p className="text-sm font-medium">{page.title}</p>
</div>
)}
{page?.url && (
<div>
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('URL')}</Label>
<a href={page.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1">
{__('View Page')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
{/* SPA Landing Settings - Only for Pages */}
{!isTemplate && page && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('SPA Landing Page')}</Label>
{page.isSpaLanding ? (
<div className="space-y-2">
<div className="bg-green-50 text-green-700 px-3 py-2 rounded-md text-sm flex items-center gap-2 border border-green-100">
<Home className="w-4 h-4" />
<span className="font-medium">{__('This is your SPA Landing')}</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
onClick={onUnsetSpaLanding}
>
<Trash2 className="w-4 h-4 mr-2" />
{__('Unset Landing Page')}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={onSetAsSpaLanding}
>
<Home className="w-4 h-4 mr-2" />
{__('Set as SPA Landing')}
</Button>
)}
</div>
)}
{/* Container Width */}
{!isTemplate && page && onContainerWidthChange && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('Container Width')}</Label>
<RadioGroup
value={page.containerWidth || 'boxed'}
onValueChange={(val: any) => onContainerWidthChange(val)}
className="gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="boxed" id="cw-boxed" />
<Label htmlFor="cw-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed')}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="fullwidth" id="cw-full" />
<Label htmlFor="cw-full" className="text-sm font-normal cursor-pointer">{__('Full Width')}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="default" id="cw-default" />
<Label htmlFor="cw-default" className="text-sm font-normal cursor-pointer text-gray-500">{__('Default (SPA Settings)')}</Label>
</div>
</RadioGroup>
</div>
)}
{/* Danger Zone */}
{!isTemplate && page && onDeletePage && (
<div className="pt-2 border-t mt-2">
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
<Button
variant="outline"
size="sm"
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
onClick={onDeletePage}
>
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete This Page')}
</Button>
</div>
)}
</div>
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
{__('Select any section on the canvas to edit its content and design.')}
</div>
</div>
</div >
);
}
return (
<div
className={cn(
"w-80 border-l bg-white flex flex-col transition-all duration-300 shadow-xl z-30",
isCollapsed && "w-0 overflow-hidden border-none"
)}
>
{/* Header */}
<div className="h-14 border-b flex items-center justify-between px-4 shrink-0 bg-white">
<span className="font-semibold text-sm truncate">
{SECTION_FIELDS[selectedSection.type]
? (selectedSection.type.charAt(0).toUpperCase() + selectedSection.type.slice(1)).replace('-', ' ')
: 'Settings'}
</span>
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8 hover:bg-gray-100">
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
{/* Content Tabs (Content vs Design) */}
<Tabs defaultValue="content" className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 pt-4 shrink-0 bg-white">
<TabsList className="w-full grid grid-cols-2">
<TabsTrigger value="content">{__('Content')}</TabsTrigger>
<TabsTrigger value="design">{__('Design')}</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-y-auto">
{/* Content Tab */}
<TabsContent value="content" className="p-4 space-y-6 m-0">
{/* Structure & Presets */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Structure')}</h4>
{LAYOUT_OPTIONS[selectedSection.type] && (
<div className="space-y-2">
<Label className="text-xs">{__('Layout Variant')}</Label>
<Select
value={selectedSection.layoutVariant || 'default'}
onValueChange={onLayoutChange}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LAYOUT_OPTIONS[selectedSection.type].map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label className="text-xs">{__('Preset Scheme')}</Label>
<Select
value={selectedSection.colorScheme || 'default'}
onValueChange={onColorSchemeChange}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{COLOR_SCHEMES.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-full h-px bg-gray-100" />
{/* Fields */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Fields')}</h4>
{SECTION_FIELDS[selectedSection.type]?.map((field) => (
<React.Fragment key={field.name}>
<InspectorField
fieldName={field.name}
fieldLabel={field.label}
fieldType={field.type}
value={selectedSection.props[field.name] || { type: 'static', value: '' }}
onChange={(val) => onSectionPropChange(field.name, val)}
supportsDynamic={field.dynamic && isTemplate}
availableSources={availableSources}
/>
{selectedSection.type === 'contact-form' && field.name === 'redirect_url' && (
<p className="text-[10px] text-gray-500 mt-1 pl-1">
Available shortcodes: {'{name}'}, {'{email}'}, {'{date}'}
</p>
)}
{selectedSection.type === 'contact-form' && field.name === 'webhook_url' && (
<Accordion type="single" collapsible className="w-full mt-1 border rounded-md">
<AccordionItem value="payload" className="border-0">
<AccordionTrigger className="text-[10px] py-1 px-2 hover:no-underline text-gray-500">
View Payload Example
</AccordionTrigger>
<AccordionContent className="px-2 pb-2">
<pre className="text-[10px] bg-gray-50 p-2 rounded overflow-x-auto">
{JSON.stringify({
"form_id": "contact_form_123",
"fields": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello world"
},
"meta": {
"url": "https://site.com/contact",
"timestamp": 1710000000
}
}, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</React.Fragment>
))}
</div>
{/* Feature Grid Repeater */}
{selectedSection.type === 'feature-grid' && (
<div className="pt-4 border-t">
<InspectorRepeater
label={__('Features')}
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
fields={[
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'description', label: 'Description', type: 'textarea' },
{ name: 'icon', label: 'Icon', type: 'icon' },
]}
itemLabelKey="title"
/>
</div>
)}
</TabsContent>
{/* Design Tab */}
<TabsContent value="design" className="p-4 space-y-6 m-0">
{/* Background */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
<div className="space-y-2">
<Label className="text-xs">{__('Background Color')}</Label>
<div className="flex gap-2">
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={selectedSection.styles?.backgroundColor || '#ffffff'}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="#FFFFFF"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
value={selectedSection.styles?.backgroundColor || ''}
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">{__('Background Image')}</Label>
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
{selectedSection.styles?.backgroundImage ? (
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white text-xs font-medium">{__('Change')}</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
) : (
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
<Palette className="w-6 h-6" />
{__('Select Image')}
</Button>
)}
</MediaUploader>
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{__('Overlay Opacity')}</Label>
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
</div>
<Slider
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
max={100}
step={5}
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
/>
</div>
<div className="space-y-2 pt-2">
<Label className="text-xs">{__('Section Height')}</Label>
<Select
value={selectedSection.styles?.heightPreset || 'default'}
onValueChange={(val) => {
// Map presets to padding values
const paddingMap: Record<string, string> = {
'default': '0',
'small': '0',
'medium': '0',
'large': '0',
'screen': '0',
};
const padding = paddingMap[val] || '4rem';
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
onSectionStylesChange({
paddingTop: padding,
paddingBottom: padding,
heightPreset: val // We'll add this to interface
} as any);
}}
>
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="small">Small (Compact)</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="large">Large (Spacious)</SelectItem>
<SelectItem value="screen">Full Screen</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="w-full h-px bg-gray-100" />
{/* Element Styles */}
<div className="space-y-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{__('Element Styles')}</h4>
<Accordion type="single" collapsible className="w-full">
{(STYLABLE_ELEMENTS[selectedSection.type] || []).map((field) => {
const styles = selectedSection.elementStyles?.[field.name] || {};
const isImage = field.type === 'image';
return (
<AccordionItem key={field.name} value={field.name}>
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-2">
{/* Common: Background Wrapper */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.backgroundColor || '#ffffff'}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#fff)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.backgroundColor || ''}
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
/>
</div>
</div>
{!isImage ? (
<>
{/* Text Color */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Text Color')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.color || '#000000' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.color || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Color (#000)"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.color || ''}
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
/>
</div>
</div>
{/* Typography Group */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Typography')}</Label>
<Select value={styles.fontFamily || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontFamily: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Font Family" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="primary">Primary (Headings)</SelectItem>
<SelectItem value="secondary">Secondary (Body)</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-2">
<Select value={styles.fontSize || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontSize: val === 'default' ? undefined : val })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Size" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Size</SelectItem>
<SelectItem value="text-sm">Small</SelectItem>
<SelectItem value="text-base">Base</SelectItem>
<SelectItem value="text-lg">Large</SelectItem>
<SelectItem value="text-xl">XL</SelectItem>
<SelectItem value="text-2xl">2XL</SelectItem>
<SelectItem value="text-3xl">3XL</SelectItem>
<SelectItem value="text-4xl">4XL</SelectItem>
<SelectItem value="text-5xl">5XL</SelectItem>
<SelectItem value="text-6xl">6XL</SelectItem>
</SelectContent>
</Select>
<Select value={styles.fontWeight || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontWeight: val === 'default' ? undefined : val })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Weight" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Weight</SelectItem>
<SelectItem value="font-light">Light</SelectItem>
<SelectItem value="font-normal">Normal</SelectItem>
<SelectItem value="font-medium">Medium</SelectItem>
<SelectItem value="font-semibold">Semibold</SelectItem>
<SelectItem value="font-bold">Bold</SelectItem>
<SelectItem value="font-extrabold">Extra Bold</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default Align</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
{/* Link Specific Styles */}
{field.name === 'link' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Link Styles')}</Label>
<Select value={styles.textDecoration || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textDecoration: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Decoration" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="none">None</SelectItem>
<SelectItem value="underline">Underline</SelectItem>
</SelectContent>
</Select>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Hover Color')}</Label>
<div className="flex items-center gap-2">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.hoverColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.hoverColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
<input
type="text"
placeholder="Hover Color"
className="flex-1 h-7 text-xs rounded border px-2"
value={styles.hoverColor || ''}
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
/>
</div>
</div>
</div>
)}
{/* Button/Box Specific Styles */}
{field.name === 'button' && (
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
<div className="flex items-center gap-2 h-7">
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
<input
type="color"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
value={styles.borderColor || '#000000'}
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
/>
</div>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
</div>
</div>
</div>
)}
</>
) : (
<>
{/* Image Settings */}
<div className="space-y-2">
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="cover">Cover</SelectItem>
<SelectItem value="contain">Contain</SelectItem>
<SelectItem value="fill">Fill</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Width')}</Label>
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">{__('Height')}</Label>
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
</div>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</TabsContent>
</div>
{/* Footer - Delete Button */}
{
selectedSection && (
<div className="p-4 border-t mt-auto shrink-0 bg-gray-50/50">
<Button variant="destructive" className="w-full" onClick={onDeleteSection}>
<Trash2 className="w-4 h-4 mr-2" />
{__('Delete Section')}
</Button>
</div>
)
}
</Tabs >
</div >
);
}

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
interface RepeaterFieldDef {
name: string;
label: string;
type: 'text' | 'textarea' | 'url' | 'image' | 'icon';
placeholder?: string;
}
interface InspectorRepeaterProps {
label: string;
items: any[];
fields: RepeaterFieldDef[];
onChange: (items: any[]) => void;
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
}
// Sortable Item Component
function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// List of available icons for selection
const ICON_OPTIONS = [
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
'Wifi', 'Wrench'
].sort();
return (
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
<AccordionItem value={`item-${index}`} className="border-0">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
<GripVertical className="w-4 h-4" />
</button>
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
{item[itemLabelKey] || `Item ${index + 1}`}
</AccordionTrigger>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation();
onDelete(index);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<AccordionContent className="p-3 space-y-3">
{fields.map((field: RepeaterFieldDef) => (
<div key={field.name} className="space-y-1.5">
<Label className="text-xs text-gray-500">{field.label}</Label>
{field.type === 'textarea' ? (
<Textarea
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="text-xs min-h-[60px]"
/>
) : field.type === 'icon' ? (
<Select
value={item[field.name] || ''}
onValueChange={(val) => onChange(index, field.name, val)}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent className="max-h-[200px]">
{ICON_OPTIONS.map(iconName => (
<SelectItem key={iconName} value={iconName}>
<div className="flex items-center gap-2">
<span>{iconName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type="text"
value={item[field.name] || ''}
onChange={(e) => onChange(index, field.name, e.target.value)}
placeholder={field.placeholder}
className="h-8 text-xs"
/>
)}
</div>
))}
</AccordionContent>
</AccordionItem>
</div>
);
}
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) {
// Generate simple stable IDs for sorting if items don't have them
const itemIds = items.map((_, i) => `item-${i}`);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = itemIds.indexOf(active.id as string);
const newIndex = itemIds.indexOf(over.id as string);
onChange(arrayMove(items, oldIndex, newIndex));
}
};
const handleItemChange = (index: number, fieldName: string, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [fieldName]: value };
onChange(newItems);
};
const handleAddItem = () => {
const newItem: any = {};
fields.forEach(f => newItem[f.name] = '');
onChange([...items, newItem]);
};
const handleDeleteItem = (index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
onChange(newItems);
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
<Plus className="w-3 h-3 mr-1" />
Add Item
</Button>
</div>
<Accordion type="single" collapsible className="w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={itemIds}
strategy={verticalListSortingStrategy}
>
{items.map((item, index) => (
<SortableItem
key={`item-${index}`} // Note: In a real app with IDs, use item.id
id={`item-${index}`}
index={index}
item={item}
fields={fields}
itemLabelKey={itemLabelKey}
onChange={handleItemChange}
onDelete={handleDeleteItem}
/>
))}
</SortableContext>
</DndContext>
</Accordion>
{items.length === 0 && (
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
No items yet. Click "Add Item" to start.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,486 @@
import React, { useState, useEffect, useRef } from 'react';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Settings, Eye, Smartphone, Monitor, ExternalLink, RefreshCw, Loader2 } from 'lucide-react';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
}
interface AvailableSource {
value: string;
label: string;
}
interface PageSettingsProps {
page: PageItem | null;
section: Section | null;
sections: Section[]; // All sections for preview
onSectionUpdate: (section: Section) => void;
isTemplate?: boolean;
availableSources?: AvailableSource[];
}
// Section field configs
const SECTION_FIELDS: Record<string, { name: string; type: 'text' | 'textarea' | 'url' | 'image'; dynamic?: boolean }[]> = {
hero: [
{ name: 'title', type: 'text', dynamic: true },
{ name: 'subtitle', type: 'text', dynamic: true },
{ name: 'image', type: 'image', dynamic: true },
{ name: 'cta_text', type: 'text' },
{ name: 'cta_url', type: 'url' },
],
content: [
{ name: 'content', type: 'textarea', dynamic: true },
],
'image-text': [
{ name: 'title', type: 'text', dynamic: true },
{ name: 'text', type: 'textarea', dynamic: true },
{ name: 'image', type: 'image', dynamic: true },
],
'feature-grid': [
{ name: 'heading', type: 'text' },
],
'cta-banner': [
{ name: 'title', type: 'text' },
{ name: 'text', type: 'text' },
{ name: 'button_text', type: 'text' },
{ name: 'button_url', type: 'url' },
],
'contact-form': [
{ name: 'title', type: 'text' },
{ name: 'webhook_url', type: 'url' },
{ name: 'redirect_url', type: 'url' },
],
};
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
hero: [
{ value: 'default', label: 'Centered' },
{ value: 'hero-left-image', label: 'Image Left' },
{ value: 'hero-right-image', label: 'Image Right' },
],
'image-text': [
{ value: 'image-left', label: 'Image Left' },
{ value: 'image-right', label: 'Image Right' },
],
'feature-grid': [
{ value: 'grid-2', label: '2 Columns' },
{ value: 'grid-3', label: '3 Columns' },
{ value: 'grid-4', label: '4 Columns' },
],
content: [
{ value: 'default', label: 'Full Width' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'medium', label: 'Medium' },
],
};
const COLOR_SCHEMES = [
{ value: 'default', label: 'Default' },
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'muted', label: 'Muted' },
{ value: 'gradient', label: 'Gradient' },
];
export function PageSettings({
page,
section,
sections,
onSectionUpdate,
isTemplate = false,
availableSources = [],
}: PageSettingsProps) {
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Debounced preview fetch
useEffect(() => {
if (!page || !showPreview) return;
// Clear existing timeout
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
// Debounce preview updates
previewTimeoutRef.current = setTimeout(async () => {
setPreviewLoading(true);
try {
const endpoint = page.type === 'page'
? `/preview/page/${page.slug}`
: `/preview/template/${page.cpt}`;
const response = await api.post(endpoint, { sections });
if (response?.html) {
setPreviewHtml(response.html);
}
} catch (error) {
console.error('Preview error:', error);
} finally {
setPreviewLoading(false);
}
}, 500);
return () => {
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
};
}, [page, sections, showPreview]);
// Update iframe when HTML changes
useEffect(() => {
if (iframeRef.current && previewHtml) {
const doc = iframeRef.current.contentDocument;
if (doc) {
doc.open();
doc.write(previewHtml);
doc.close();
}
}
}, [previewHtml]);
// Manual refresh
const handleRefreshPreview = async () => {
if (!page) return;
setPreviewLoading(true);
try {
const endpoint = page.type === 'page'
? `/preview/page/${page.slug}`
: `/preview/template/${page.cpt}`;
const response = await api.post(endpoint, { sections });
if (response?.html) {
setPreviewHtml(response.html);
}
} catch (error) {
console.error('Preview error:', error);
} finally {
setPreviewLoading(false);
}
};
// Update section prop
const updateProp = (name: string, value: any, isDynamic?: boolean) => {
if (!section) return;
const newProps = { ...section.props };
if (isDynamic) {
newProps[name] = { type: 'dynamic', source: value };
} else {
newProps[name] = { type: 'static', value };
}
onSectionUpdate({ ...section, props: newProps });
};
// Get prop value
const getPropValue = (name: string): string => {
const prop = section?.props[name];
if (!prop) return '';
if (typeof prop === 'object') {
return prop.type === 'dynamic' ? prop.source : prop.value || '';
}
return String(prop);
};
// Check if prop is dynamic
const isPropDynamic = (name: string): boolean => {
const prop = section?.props[name];
return typeof prop === 'object' && prop?.type === 'dynamic';
};
// Render field based on type
const renderField = (field: { name: string; type: string; dynamic?: boolean }) => {
const value = getPropValue(field.name);
const isDynamic = isPropDynamic(field.name);
const fieldLabel = field.name.charAt(0).toUpperCase() + field.name.slice(1).replace('_', ' ');
return (
<div key={field.name} className="space-y-2">
<div className="flex items-center justify-between">
<Label>{fieldLabel}</Label>
{field.dynamic && isTemplate && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{isDynamic ? '◆ Dynamic' : 'Static'}
</span>
<Switch
checked={isDynamic}
onCheckedChange={(checked) => {
if (checked) {
updateProp(field.name, 'post_title', true);
} else {
updateProp(field.name, '', false);
}
}}
/>
</div>
)}
</div>
{isDynamic && isTemplate ? (
<Select
value={value}
onValueChange={(v) => updateProp(field.name, v, true)}
>
<SelectTrigger>
<SelectValue placeholder={__('Select source')} />
</SelectTrigger>
<SelectContent>
{availableSources.map(source => (
<SelectItem key={source.value} value={source.value}>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === 'textarea' ? (
<Textarea
value={value}
onChange={(e) => updateProp(field.name, e.target.value)}
rows={4}
/>
) : (
<Input
type={field.type === 'url' ? 'url' : 'text'}
value={value}
onChange={(e) => updateProp(field.name, e.target.value)}
/>
)}
</div>
);
};
return (
<div className="w-80 border-l bg-white flex flex-col">
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-6">
{/* Page Info */}
{page && !section && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Settings className="w-4 h-4" />
{isTemplate ? __('Template Settings') : __('Page Settings')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{__('Type')}</Label>
<p className="text-sm text-gray-600">
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
</p>
</div>
{page.url && (
<div>
<Label>{__('URL')}</Label>
<a
href={page.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline flex items-center gap-1"
>
{page.url}
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</CardContent>
</Card>
)}
{/* Section Settings */}
{section && (
<>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{__('Section Settings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Layout Variant */}
{LAYOUT_OPTIONS[section.type] && (
<div className="space-y-2">
<Label>{__('Layout')}</Label>
<Select
value={section.layoutVariant || 'default'}
onValueChange={(v) => onSectionUpdate({ ...section, layoutVariant: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LAYOUT_OPTIONS[section.type].map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Color Scheme */}
<div className="space-y-2">
<Label>{__('Color Scheme')}</Label>
<Select
value={section.colorScheme || 'default'}
onValueChange={(v) => onSectionUpdate({ ...section, colorScheme: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_SCHEMES.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{__('Content')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{SECTION_FIELDS[section.type]?.map(renderField)}
</CardContent>
</Card>
</>
)}
{/* Preview Panel */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<Eye className="w-4 h-4" />
{__('Preview')}
</span>
{showPreview && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleRefreshPreview}
disabled={previewLoading}
>
<RefreshCw className={`w-3 h-3 ${previewLoading ? 'animate-spin' : ''}`} />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
{/* Preview Mode Toggle */}
<div className="flex gap-2 mb-3">
<Button
variant={previewMode === 'desktop' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('desktop')}
>
<Monitor className="w-4 h-4 mr-1" />
{__('Desktop')}
</Button>
<Button
variant={previewMode === 'mobile' ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewMode('mobile')}
>
<Smartphone className="w-4 h-4 mr-1" />
{__('Mobile')}
</Button>
</div>
{/* Preview Toggle */}
{!showPreview ? (
<Button
variant="outline"
className="w-full"
onClick={() => setShowPreview(true)}
disabled={!page}
>
<Eye className="w-4 h-4 mr-2" />
{__('Show Preview')}
</Button>
) : (
<div className="space-y-2">
{/* Preview Iframe Container */}
<div
className="relative bg-gray-100 rounded-lg overflow-hidden border"
style={{
height: '300px',
width: previewMode === 'mobile' ? '200px' : '100%',
margin: previewMode === 'mobile' ? '0 auto' : undefined,
}}
>
{previewLoading && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}
<iframe
ref={iframeRef}
className="w-full h-full border-0"
title="Page Preview"
sandbox="allow-same-origin"
style={{
transform: previewMode === 'mobile' ? 'scale(0.5)' : 'scale(0.4)',
transformOrigin: 'top left',
width: previewMode === 'mobile' ? '400px' : '250%',
height: previewMode === 'mobile' ? '600px' : '750px',
}}
/>
</div>
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => setShowPreview(false)}
>
{__('Hide Preview')}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { FileText, Layout, Loader2, Home } from 'lucide-react';
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
has_template?: boolean;
permalink_base?: string;
}
interface PageSidebarProps {
pages: PageItem[];
selectedPage: PageItem | null;
onSelectPage: (page: PageItem) => void;
isLoading: boolean;
}
export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) {
const structuralPages = pages.filter(p => p.type === 'page');
const templates = pages.filter(p => p.type === 'template');
if (isLoading) {
return (
<div className="w-60 border-r bg-white flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="w-60 border-r bg-white flex flex-col">
<div className="flex-1 overflow-y-auto">
<div className="p-4">
{/* Structural Pages */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
<FileText className="w-3.5 h-3.5" />
{__('Structural Pages')}
</h3>
<div className="space-y-1">
{structuralPages.length === 0 ? (
<p className="text-sm text-gray-400 italic">{__('No pages yet')}</p>
) : (
structuralPages.map((page) => (
<button
key={`page-${page.id}`}
onClick={() => onSelectPage(page)}
className={cn(
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
'hover:bg-gray-100',
selectedPage?.id === page.id && selectedPage?.type === 'page'
? 'bg-primary/10 text-primary font-medium'
: 'text-gray-700'
)}
>
<span className="truncate">{page.title}</span>
{(page as any).isSpaLanding && (
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
<Home className="w-3.5 h-3.5 text-green-600" />
</span>
)}
</button>
))
)}
</div>
</div>
{/* Templates */}
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
<Layout className="w-3.5 h-3.5" />
{__('Templates')}
</h3>
<div className="space-y-1">
{templates.map((template) => (
<button
key={`template-${template.cpt}`}
onClick={() => onSelectPage(template)}
className={cn(
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
'hover:bg-gray-100',
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
? 'bg-primary/10 text-primary font-medium'
: 'text-gray-700'
)}
>
<span className="block">{template.title}</span>
{template.permalink_base && (
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
)}
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,321 @@
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { __ } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
Loader2
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
}
interface SectionEditorProps {
sections: Section[];
selectedSection: Section | null;
onSelectSection: (section: Section | null) => void;
onAddSection: (type: string) => void;
onDeleteSection: (id: string) => void;
onMoveSection: (id: string, direction: 'up' | 'down') => void;
onReorderSections: (sections: Section[]) => void;
isTemplate: boolean;
cpt?: string;
isLoading: boolean;
}
const SECTION_TYPES = [
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
{ type: 'content', label: 'Content', icon: Type },
{ type: 'image-text', label: 'Image + Text', icon: Image },
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
];
// Sortable Section Card Component
function SortableSectionCard({
section,
index,
totalCount,
isSelected,
onSelect,
onDelete,
onMove,
}: {
section: Section;
index: number;
totalCount: number;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
onMove: (direction: 'up' | 'down') => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
const Icon = sectionType?.icon || LayoutTemplate;
const hasDynamic = Object.values(section.props).some(
p => typeof p === 'object' && p?.type === 'dynamic'
);
return (
<Card
ref={setNodeRef}
style={style}
className={cn(
'p-4 cursor-pointer transition-all',
'hover:shadow-md',
isSelected ? 'ring-2 ring-primary shadow-md' : '',
isDragging ? 'opacity-50 shadow-lg ring-2 ring-primary/50' : ''
)}
onClick={onSelect}
>
<div className="flex items-center gap-3">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-4 h-4 text-gray-400 hover:text-gray-600" />
</div>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<Icon className="w-5 h-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-sm">
{sectionType?.label || section.type}
</div>
<div className="text-xs text-gray-500">
{section.layoutVariant || 'default'}
{hasDynamic && (
<span className="ml-2 text-primary"> {__('Dynamic')}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMove('up')}
disabled={index === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onMove('down')}
disabled={index === totalCount - 1}
>
<ChevronDown className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent className="z-[60]">
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</Card>
);
}
export function SectionEditor({
sections,
selectedSection,
onSelectSection,
onAddSection,
onDeleteSection,
onMoveSection,
onReorderSections,
isTemplate,
cpt,
isLoading,
}: SectionEditorProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sections.findIndex(s => s.id === active.id);
const newIndex = sections.findIndex(s => s.id === over.id);
const newSections = arrayMove(sections, oldIndex, newIndex);
onReorderSections(newSections);
}
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-4">
{/* Section Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
{isTemplate && (
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
{__('Template: ')} {cpt}
</span>
)}
</div>
{/* Sections List with Drag-and-Drop */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sections.map(s => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{sections.map((section, index) => (
<SortableSectionCard
key={section.id}
section={section}
index={index}
totalCount={sections.length}
isSelected={selectedSection?.id === section.id}
onSelect={() => onSelectSection(section)}
onDelete={() => onDeleteSection(section.id)}
onMove={(direction) => onMoveSection(section.id, direction)}
/>
))}
</div>
</SortableContext>
</DndContext>
{sections.length === 0 && (
<div className="text-center py-12 text-gray-400">
<LayoutTemplate className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{__('No sections yet. Add your first section.')}</p>
</div>
)}
{/* Add Section Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full">
<Plus className="w-4 h-4 mr-2" />
{__('Add Section')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
{SECTION_TYPES.map((sectionType) => {
const Icon = sectionType.icon;
return (
<DropdownMenuItem
key={sectionType.type}
onClick={() => onAddSection(sectionType.type)}
>
<Icon className="w-4 h-4 mr-2" />
{sectionType.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { ArrowRight } from 'lucide-react';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface CTABannerRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; btnText: string }> = {
default: { bg: '', text: 'text-gray-900', btnBg: 'bg-blue-600', btnText: 'text-white' },
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
};
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
const title = section.props?.title?.value || 'Ready to get started?';
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
const buttonText = section.props?.button_text?.value || 'Get Started';
const buttonUrl = section.props?.button_url?.value || '#';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text');
return (
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
<div className="max-w-4xl mx-auto text-center space-y-6">
<h2
className={cn(
!titleStyle.classNames && "text-3xl md:text-4xl font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
<p
className={cn(
"max-w-2xl mx-auto",
!textStyle.classNames && "text-lg opacity-90",
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
<button className={cn(
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
!btnStyle.style?.backgroundColor && scheme.btnBg,
!btnStyle.style?.color && scheme.btnText,
btnStyle.classNames
)}
style={btnStyle.style}
>
{buttonText}
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Send, Mail, User, MessageSquare } from 'lucide-react';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface ContactFormRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string; btnBg: string }> = {
default: { bg: '', text: 'text-gray-900', inputBg: 'bg-gray-50', btnBg: 'bg-blue-600' },
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
};
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const title = section.props?.title?.value || 'Contact Us';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign
}
};
};
const titleStyle = getTextStyles('title');
const buttonStyleObj = getTextStyles('button');
const fieldsStyleObj = getTextStyles('fields');
const buttonStyle = section.elementStyles?.button || {};
const fieldsStyle = section.elementStyles?.fields || {};
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-xl mx-auto">
<h2
className={cn(
"text-3xl font-bold text-center mb-8",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{/* Name field */}
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Your Name"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Email field */}
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
placeholder="Your Email"
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Message field */}
<div className="relative">
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
<textarea
placeholder="Your Message"
rows={4}
className={cn(
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
!fieldsStyle.backgroundColor && scheme.inputBg
)}
style={{
backgroundColor: fieldsStyle.backgroundColor,
color: fieldsStyle.color
}}
disabled
/>
</div>
{/* Submit button */}
<button
type="submit"
className={cn(
'w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition opacity-80 cursor-not-allowed',
!buttonStyle.backgroundColor && scheme.btnBg,
!buttonStyle.color && (section.colorScheme === 'primary' || section.colorScheme === 'gradient' ? 'text-blue-600' : 'text-white'),
buttonStyleObj.classNames
)}
style={{
backgroundColor: buttonStyle.backgroundColor,
color: buttonStyle.color
}}
disabled
>
<Send className="w-5 h-5" />
Send Message
</button>
<p className="text-center text-sm opacity-60">
(Form preview only - functional on frontend)
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Section } from '../../store/usePageEditorStore';
interface ContentRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: 'bg-white', text: 'text-gray-900' },
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
dark: { bg: 'bg-gray-900', text: 'text-white' },
blue: { bg: 'wn-primary-bg', text: 'text-white' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
const WIDTH_CLASSES: Record<string, string> = {
default: 'max-w-6xl',
narrow: 'max-w-2xl',
medium: 'max-w-4xl',
};
const fontSizeToCSS = (className?: string) => {
switch (className) {
case 'text-sm': return '0.875rem';
case 'text-base': return '1rem';
case 'text-lg': return '1.125rem';
case 'text-xl': return '1.25rem';
case 'text-2xl': return '1.5rem';
case 'text-3xl': return '1.875rem';
case 'text-4xl': return '2.25rem';
case 'text-5xl': return '3rem';
case 'text-6xl': return '3.75rem';
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-light': return '300';
case 'font-normal': return '400';
case 'font-medium': return '500';
case 'font-semibold': return '600';
case 'font-bold': return '700';
case 'font-extrabold': return '800';
default: return undefined;
}
};
// Helper to generate scoped CSS for prose elements
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
const styles: string[] = [];
const scope = `#section-${sectionId}`;
// Headings (h1-h4)
const hs = elementStyles?.heading;
if (hs) {
const headingRules = [
hs.color && `color: ${hs.color} !important;`,
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
// Add padding if background color is set to make it look decent
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
}
}
// Body text (p, li)
const ts = elementStyles?.text;
if (ts) {
const textRules = [
ts.color && `color: ${ts.color} !important;`,
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
}
}
// Explicit Spacing & List Formatting Restorations
// These ensure vertical rhythm and list styles exist even if prose defaults are overridden or missing
styles.push(`
${scope} p { margin-bottom: 1em; }
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
${scope} li { margin-bottom: 0.25em; }
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
`);
// Links (a:not(.button))
const ls = elementStyles?.link;
if (ls) {
const linkRules = [
ls.color && `color: ${ls.color} !important;`,
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
].filter(Boolean).join(' ');
if (linkRules) {
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
}
if (ls.hoverColor) {
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
}
}
// Buttons (a[data-button], .button)
const bs = elementStyles?.button;
if (bs) {
const btnRules = [
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
bs.color && `color: ${bs.color} !important;`,
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
bs.padding && `padding: ${bs.padding} !important;`,
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
].filter(Boolean).join(' ');
// Always force text-decoration: none for buttons
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
// Add hover effect opacity or something to make it feel alive, or just keep it simple
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
}
// Images
const is = elementStyles?.image;
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
].filter(Boolean).join(' ');
if (imgRules) {
styles.push(`${scope} img { ${imgRules} }`);
}
}
return styles.join('\n');
};
export function ContentRenderer({ section, className }: ContentRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
const isDynamic = section.props?.content?.type === 'dynamic';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const contentStyle = getTextStyles('content');
const headingStyle = getTextStyles('heading');
const buttonStyle = getTextStyles('button');
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
id={`section-${section.id}`}
className={cn(
'px-4 md:px-8',
heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text,
className
)}
style={getBackgroundStyle()}
>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<div
className={cn(
'mx-auto prose prose-lg max-w-none',
// Default prose overrides
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-a:no-underline hover:prose-a:underline', // Establish baseline for links
widthClass,
scheme.text === 'text-white' && 'prose-invert',
contentStyle.classNames // Apply font family, size, weight to container just in case
)}
style={{
color: contentStyle.style.color,
textAlign: contentStyle.style.textAlign as React.CSSProperties['textAlign'],
'--tw-prose-headings': headingStyle.style?.color,
} as React.CSSProperties}
>
{isDynamic && (
<div className="flex items-center gap-2 text-orange-400 text-sm font-medium mb-4">
<span></span>
<span>{section.props?.content?.source || 'Dynamic Content'}</span>
</div>
)}
<div dangerouslySetInnerHTML={{ __html: content }} />
{cta_text && cta_url && (
<div className="mt-8">
<a
href={cta_url}
className={cn(
"button inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
!buttonStyle.style?.backgroundColor && "bg-blue-600",
!buttonStyle.style?.color && "text-white",
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{cta_text}
</a>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, any>;
elementStyles?: Record<string, any>;
}
interface FeatureGridRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }> = {
default: { bg: '', text: 'text-gray-900', cardBg: 'bg-gray-50' },
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
};
const GRID_CLASSES: Record<string, string> = {
'grid-2': 'grid-cols-1 md:grid-cols-2',
'grid-3': 'grid-cols-1 md:grid-cols-3',
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
// Default features for demo
const DEFAULT_FEATURES = [
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
];
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'grid-3';
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
const heading = section.props?.heading?.value || 'Our Features';
const features = section.props?.features?.value || DEFAULT_FEATURES;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-6xl mx-auto">
{heading && (
<h2
className={cn(
"text-3xl md:text-4xl font-bold text-center mb-12",
headingStyle.classNames
)}
style={headingStyle.style}
>
{heading}
</h2>
)}
<div className={cn('grid gap-8', gridClass)}>
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
// Resolve icon from name, fallback to Star
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
return (
<div
key={index}
className={cn(
'p-6 rounded-xl text-center',
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
<IconComponent className="w-7 h-7 text-blue-600" />
</div>
<h3
className={cn(
"mb-2",
!featureItemStyle.style?.color && "text-lg font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.title || `Feature ${index + 1}`}
</h3>
<p
className={cn(
"text-sm",
!featureItemStyle.style?.color && "opacity-80"
)}
style={{ color: featureItemStyle.style?.color }}
>
{feature.description || 'Feature description goes here'}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
elementStyles?: Record<string, any>;
}
interface HeroRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: '', text: 'text-gray-900' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
export function HeroRenderer({ section, className }: HeroRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'default';
const title = section.props?.title?.value || 'Hero Title';
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
const image = section.props?.image?.value;
const ctaText = section.props?.cta_text?.value || 'Get Started';
const ctaUrl = section.props?.cta_url?.value || '#';
// Check for dynamic placeholders
const isDynamicTitle = section.props?.title?.type === 'dynamic';
const isDynamicSubtitle = section.props?.subtitle?.type === 'dynamic';
const isDynamicImage = section.props?.image?.type === 'dynamic';
// Element Styles
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary', // Mapping secondary to sans for now if primary is assumed default
'font-serif': styles.fontFamily === 'primary', // Mapping primary to serif (headings) - ADJUST AS NEEDED
}
),
style: {
color: styles.color,
backgroundColor: styles.backgroundColor,
textAlign: styles.textAlign
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const ctaStyle = getTextStyles('cta_text'); // For button
// Helper for image styles
const imageStyle = section.elementStyles?.['image'] || {};
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
layout === 'hero-right-image' ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
'flex-wrap md:flex-nowrap'
)}>
{/* Image */}
<div className="w-full md:w-1/2">
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
{image ? (
<img
src={image}
alt={title}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
) : (
<div className="w-full h-64 md:h-80 bg-gray-300 flex items-center justify-center">
<span className="text-gray-500">
{isDynamicImage ? `${section.props?.image?.source}` : 'No Image'}
</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="w-full md:w-1/2 space-y-4">
<h1
className={cn("font-bold", titleStyle.classNames || "text-3xl md:text-5xl")}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h1>
<p
className={cn("opacity-90", subtitleStyle.classNames || "text-lg md:text-xl")}
style={subtitleStyle.style}
>
{isDynamicSubtitle && <span className="text-orange-400 mr-2"></span>}
{subtitle}
</p>
{ctaText && (
<button
className={cn(
"px-6 py-3 rounded-lg transition hover:opacity-90",
!ctaStyle.style?.backgroundColor && "bg-white",
!ctaStyle.style?.color && "text-gray-900",
ctaStyle.classNames
)}
style={ctaStyle.style}
>
{ctaText}
</button>
)}
</div>
</div>
</div>
);
}
// Default centered layout
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8 text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className="max-w-4xl mx-auto space-y-6">
<h1
className={cn("font-bold", titleStyle.classNames || "text-4xl md:text-6xl")}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h1>
<p
className={cn("opacity-90 max-w-2xl mx-auto", subtitleStyle.classNames || "text-lg md:text-2xl")}
style={subtitleStyle.style}
>
{isDynamicSubtitle && <span className="text-orange-400 mr-2"></span>}
{subtitle}
</p>
{/* Image with Wrapper for Background */}
<div
className={cn("mx-auto", imageStyle.width ? "" : "max-w-3xl w-full")}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
>
{image ? (
<img
src={image}
alt={title}
className={cn(
"w-full rounded-xl shadow-lg mt-8",
!imageStyle.height && "h-auto", // Default height if not specified
!imageStyle.objectFit && "object-cover" // Default fit if not specified
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
maxWidth: '100%',
}}
/>
) : isDynamicImage ? (
<div className="w-full h-64 bg-gray-300 rounded-xl flex items-center justify-center mt-8">
<span className="text-gray-500"> {section.props?.image?.source}</span>
</div>
) : null}
</div>
{ctaText && (
<button
className={cn(
"px-8 py-4 rounded-lg transition hover:opacity-90 mt-4",
!ctaStyle.style?.backgroundColor && "bg-white",
!ctaStyle.style?.color && "text-gray-900",
ctaStyle.classNames || "font-semibold"
)}
style={ctaStyle.style}
>
{ctaText}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Section } from '../../store/usePageEditorStore';
interface ImageTextRendererProps {
section: Section;
className?: string;
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: '', text: 'text-gray-900' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
const layout = section.layoutVariant || 'image-left';
const isImageRight = layout === 'image-right';
const title = section.props?.title?.value || 'Section Title';
const text = section.props?.text?.value || 'Your descriptive text goes here. Edit this section to add your own content.';
const image = section.props?.image?.value;
const isDynamicTitle = section.props?.title?.type === 'dynamic';
const isDynamicText = section.props?.text?.type === 'dynamic';
const isDynamicImage = section.props?.image?.type === 'dynamic';
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const imageStyle = section.elementStyles?.['image'] || {};
const buttonStyle = getTextStyles('button');
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<div
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
style={getBackgroundStyle()}
>
<div className={cn(
'max-w-6xl mx-auto flex items-center gap-12',
isImageRight ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
'flex-wrap md:flex-nowrap'
)}>
{/* Image */}
<div className="w-full md:w-1/2" style={{ backgroundColor: imageStyle.backgroundColor }}>
{image ? (
<img
src={image}
alt={title}
className="w-full h-auto rounded-xl shadow-lg"
style={{
objectFit: imageStyle.objectFit,
width: imageStyle.width,
maxWidth: '100%',
height: imageStyle.height,
}}
/>
) : (
<div className="w-full h-64 md:h-80 bg-gray-200 rounded-xl flex items-center justify-center">
<span className="text-gray-400">
{isDynamicImage ? (
<span className="flex items-center gap-2">
<span className="text-orange-400"></span>
{section.props?.image?.source}
</span>
) : (
'Add Image'
)}
</span>
</div>
)}
</div>
{/* Content */}
<div className="w-full md:w-1/2 space-y-4">
<h2
className={cn(
"text-2xl md:text-3xl font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{isDynamicTitle && <span className="text-orange-400 mr-2"></span>}
{title}
</h2>
<p
className={cn(
"text-lg opacity-90 leading-relaxed",
textStyle.classNames
)}
style={textStyle.style}
>
{isDynamicText && <span className="text-orange-400 mr-2"></span>}
{text}
</p>
{cta_text && cta_url && (
<div className="pt-4">
<span
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
!buttonStyle.style?.backgroundColor && "bg-blue-600",
!buttonStyle.style?.color && "text-white",
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{cta_text}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { HeroRenderer } from './HeroRenderer';
export { ContentRenderer } from './ContentRenderer';
export { ImageTextRenderer } from './ImageTextRenderer';
export { FeatureGridRenderer } from './FeatureGridRenderer';
export { CTABannerRenderer } from './CTABannerRenderer';
export { ContactFormRenderer } from './ContactFormRenderer';

View File

@@ -0,0 +1,398 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { PageSidebar } from './components/PageSidebar';
import { CanvasRenderer } from './components/CanvasRenderer';
import { InspectorPanel } from './components/InspectorPanel';
import { CreatePageModal } from './components/CreatePageModal';
import { usePageEditorStore, Section } from './store/usePageEditorStore';
// Types
interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
icon?: string;
has_template?: boolean;
permalink_base?: string;
isFrontPage?: boolean;
}
export default function AppearancePages() {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Zustand store
const {
currentPage,
sections,
selectedSectionId,
deviceMode,
inspectorCollapsed,
hasUnsavedChanges,
isLoading,
availableSources,
setCurrentPage,
setSections,
setSelectedSection,
setDeviceMode,
setInspectorCollapsed,
setAvailableSources,
setIsLoading,
addSection,
deleteSection,
duplicateSection,
moveSection,
reorderSections,
updateSectionProp,
updateSectionLayout,
updateSectionColorScheme,
updateSectionStyles,
updateElementStyles,
markAsSaved,
setAsSpaLanding,
unsetSpaLanding,
} = usePageEditorStore();
// Get selected section object
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
// Fetch all pages and templates
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
queryKey: ['pages'],
queryFn: async () => {
const response = await api.get('/pages');
// Map API snake_case to frontend camelCase
return response.map((p: any) => ({
...p,
isSpaLanding: !!p.is_spa_frontpage
}));
},
});
// Fetch selected page/template structure
const { data: pageData, isLoading: pageLoading } = useQuery({
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
queryFn: async () => {
if (!currentPage) return null;
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
const response = await api.get(endpoint);
return response;
},
enabled: !!currentPage,
});
// Fetch global settings for defaults
const { data: globalSettings } = useQuery({
queryKey: ['appearance-settings'],
queryFn: async () => api.get('/appearance/settings'),
});
// Update store when page data loads
useEffect(() => {
if (pageData?.structure?.sections) {
setSections(pageData.structure.sections);
markAsSaved();
}
if (pageData?.available_sources) {
setAvailableSources(pageData.available_sources);
}
// Sync isFrontPage if returned from single page API (optional, but good practice)
if (pageData?.is_front_page !== undefined && currentPage) {
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
}
// Sync containerWidth
if (pageData?.container_width && currentPage) {
setCurrentPage({ ...currentPage, containerWidth: pageData.container_width });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
// Update loading state
useEffect(() => {
setIsLoading(pageLoading);
}, [pageLoading, setIsLoading]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
if (!currentPage) return;
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
return api.post(endpoint, { sections });
},
onSuccess: () => {
toast.success(__('Page saved successfully'));
markAsSaved();
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
},
onError: () => {
toast.error(__('Failed to save page'));
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
return api.del(`/pages/${id}`);
},
onSuccess: () => {
toast.success(__('Page deleted successfully'));
markAsSaved(); // Clear unsaved flag
setCurrentPage(null);
queryClient.invalidateQueries({ queryKey: ['pages'] });
},
onError: () => {
toast.error(__('Failed to delete page'));
},
});
// Set as SPA Landing mutation
const setSpaLandingMutation = useMutation({
mutationFn: async (id: number) => {
return api.post(`/pages/${id}/set-as-spa-landing`);
},
onSuccess: () => {
toast.success(__('Set as SPA Landing Page'));
queryClient.invalidateQueries({ queryKey: ['pages'] });
// Update local state is handled by re-fetching pages or we can optimistic update
if (currentPage) {
setCurrentPage({ ...currentPage, isSpaLanding: true });
}
},
onError: () => {
toast.error(__('Failed to set SPA Landing Page'));
},
});
// Unset SPA Landing mutation
const unsetSpaLandingMutation = useMutation({
mutationFn: async () => {
return api.post(`/pages/unset-spa-landing`);
},
onSuccess: () => {
toast.success(__('Unset SPA Landing Page'));
queryClient.invalidateQueries({ queryKey: ['pages'] });
if (currentPage) {
setCurrentPage({ ...currentPage, isSpaLanding: false });
}
},
onError: () => {
toast.error(__('Failed to unset SPA Landing Page'));
},
});
// Handle page selection
const handleSelectPage = (page: PageItem) => {
if (hasUnsavedChanges) {
if (!confirm(__('You have unsaved changes. Continue?'))) return;
}
if (page.type === 'page') {
setCurrentPage({
...page,
isSpaLanding: !!(page as any).isSpaLanding
});
} else {
setCurrentPage(page);
};
setSelectedSection(null);
};
const handleDiscard = () => {
if (pageData?.structure?.sections) {
setSections(pageData.structure.sections);
markAsSaved();
toast.success(__('Changes discarded'));
}
};
const handleDeletePage = () => {
if (!currentPage || !currentPage.id) return;
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
deleteMutation.mutate(currentPage.id);
}
};
return (
<div className={
cn(
"flex flex-col bg-white transition-all duration-300",
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
)
} >
{/* Header */}
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
<div>
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
<p className="text-sm text-muted-foreground">
{currentPage ? currentPage.title : __('Select a page to edit')}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
className="mr-2"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
{hasUnsavedChanges && (
<>
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
<Button
variant="ghost"
size="sm"
onClick={handleDiscard}
>
<Undo2 className="w-4 h-4 mr-2" />
{__('Discard')}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateModal(true)}
>
<Plus className="w-4 h-4 mr-2" />
{__('Create Page')}
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={!hasUnsavedChanges || saveMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
{saveMutation.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
</div >
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
< div className="flex-1 flex overflow-hidden" >
{/* Left Column: Pages List */}
< PageSidebar
pages={pages}
selectedPage={currentPage}
onSelectPage={handleSelectPage}
isLoading={pagesLoading}
/>
{/* Center Column: Canvas Renderer */}
{
currentPage ? (
<CanvasRenderer
sections={sections}
selectedSectionId={selectedSectionId}
deviceMode={deviceMode}
onSelectSection={setSelectedSection}
onAddSection={addSection}
onDeleteSection={deleteSection}
onDuplicateSection={duplicateSection}
onMoveSection={moveSection}
onReorderSections={reorderSections}
onDeviceModeChange={setDeviceMode}
containerWidth={
currentPage?.containerWidth && currentPage.containerWidth !== 'default'
? currentPage.containerWidth
: ((globalSettings as any)?.data?.general?.container_width || 'boxed')
}
/>
) : (
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
<div className="text-center">
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">{__('Select a page from the sidebar')}</p>
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
</div>
</div>
)
}
{/* Right Column: Inspector Panel */}
{
currentPage && (
<InspectorPanel
page={currentPage}
selectedSection={selectedSection}
isCollapsed={inspectorCollapsed}
isTemplate={currentPage.type === 'template'}
availableSources={availableSources}
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
onSectionPropChange={(propName, value) => {
if (selectedSectionId) {
updateSectionProp(selectedSectionId, propName, value);
}
}}
onLayoutChange={(layout) => {
if (selectedSectionId) {
updateSectionLayout(selectedSectionId, layout);
}
}}
onColorSchemeChange={(scheme) => {
if (selectedSectionId) {
updateSectionColorScheme(selectedSectionId, scheme);
}
}}
onSectionStylesChange={(styles) => {
if (selectedSectionId) {
updateSectionStyles(selectedSectionId, styles);
}
}}
onElementStylesChange={(fieldName, styles) => {
if (selectedSectionId) {
updateElementStyles(selectedSectionId, fieldName, styles);
}
}}
onDeleteSection={() => {
if (selectedSectionId) {
deleteSection(selectedSectionId);
}
}}
onSetAsSpaLanding={() => {
if (currentPage?.id) {
setSpaLandingMutation.mutate(currentPage.id);
}
}}
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
onDeletePage={handleDeletePage}
onContainerWidthChange={(width) => {
if (currentPage) {
setCurrentPage({ ...currentPage, containerWidth: width });
markAsSaved(); // Mark as changed so save button enables
}
}}
/>
)
}
</div >
{/* Create Page Modal */}
< CreatePageModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onCreated={(newPage) => {
queryClient.invalidateQueries({ queryKey: ['pages'] });
setCurrentPage(newPage);
}
}
/>
</div >
);
}

View File

@@ -0,0 +1,450 @@
import { create } from 'zustand';
// Simple ID generator (replaces uuid)
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
export interface SectionProp {
type: 'static' | 'dynamic';
value?: any;
source?: string;
}
export interface SectionStyles {
backgroundColor?: string;
backgroundImage?: string;
backgroundOverlay?: number; // 0-100 opacity
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained';
heightPreset?: string;
}
export interface ElementStyle {
color?: string;
fontSize?: string;
fontWeight?: string;
fontFamily?: 'primary' | 'secondary';
textAlign?: 'left' | 'center' | 'right';
// Image specific
objectFit?: 'cover' | 'contain' | 'fill';
backgroundColor?: string; // Wrapper BG
width?: string;
height?: string;
// Link specific
textDecoration?: 'none' | 'underline';
hoverColor?: string;
// Button/Box specific
borderColor?: string;
borderWidth?: string;
borderRadius?: string;
padding?: string;
}
export interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp>;
}
export interface PageItem {
id?: number;
type: 'page' | 'template';
cpt?: string;
slug?: string;
title: string;
url?: string;
isFrontPage?: boolean;
isSpaLanding?: boolean;
containerWidth?: 'boxed' | 'fullwidth';
}
interface PageEditorState {
// Current page/template being edited
currentPage: PageItem | null;
// Sections for the current page
sections: Section[];
// Selection & interaction
selectedSectionId: string | null;
hoveredSectionId: string | null;
// UI state
deviceMode: 'desktop' | 'mobile';
inspectorCollapsed: boolean;
hasUnsavedChanges: boolean;
isLoading: boolean;
// Available sources for dynamic fields (CPT templates)
availableSources: { value: string; label: string }[];
// Actions
setCurrentPage: (page: PageItem | null) => void;
setSections: (sections: Section[]) => void;
setSelectedSection: (id: string | null) => void;
setHoveredSection: (id: string | null) => void;
setDeviceMode: (mode: 'desktop' | 'mobile') => void;
setInspectorCollapsed: (collapsed: boolean) => void;
setAvailableSources: (sources: { value: string; label: string }[]) => void;
setIsLoading: (loading: boolean) => void;
// Section actions
addSection: (type: string, index?: number) => void;
deleteSection: (id: string) => void;
duplicateSection: (id: string) => void;
moveSection: (id: string, direction: 'up' | 'down') => void;
reorderSections: (sections: Section[]) => void;
updateSectionProp: (sectionId: string, propName: string, value: SectionProp) => void;
updateSectionLayout: (sectionId: string, layoutVariant: string) => void;
updateSectionColorScheme: (sectionId: string, colorScheme: string) => void;
updateSectionStyles: (sectionId: string, styles: Partial<SectionStyles>) => void;
updateElementStyles: (sectionId: string, fieldName: string, styles: Partial<ElementStyle>) => void;
// Page actions
setAsSpaLanding: () => Promise<void>;
unsetSpaLanding: () => Promise<void>;
// Persistence
markAsChanged: () => void;
markAsSaved: () => void;
reset: () => void;
savePage: () => Promise<void>;
}
// Default props for each section type
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
hero: {
title: { type: 'static', value: 'Welcome to Our Site' },
subtitle: { type: 'static', value: 'Discover amazing products and services' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: 'Get Started' },
cta_url: { type: 'static', value: '#' },
},
content: {
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'image-text': {
title: { type: 'static', value: 'Section Title' },
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
image: { type: 'static', value: '' },
cta_text: { type: 'static', value: '' },
cta_url: { type: 'static', value: '' },
},
'feature-grid': {
heading: { type: 'static', value: 'Our Features' },
features: { type: 'static', value: '' },
},
'cta-banner': {
title: { type: 'static', value: 'Ready to get started?' },
text: { type: 'static', value: 'Join thousands of happy customers today.' },
button_text: { type: 'static', value: 'Get Started' },
button_url: { type: 'static', value: '#' },
},
'contact-form': {
title: { type: 'static', value: 'Contact Us' },
webhook_url: { type: 'static', value: '' },
redirect_url: { type: 'static', value: '' },
},
};
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
};
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
// Initial state
currentPage: null,
sections: [],
selectedSectionId: null,
hoveredSectionId: null,
deviceMode: 'desktop',
inspectorCollapsed: false,
hasUnsavedChanges: false,
isLoading: false,
availableSources: [],
// Setters
setCurrentPage: (currentPage) => set({ currentPage }),
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
setDeviceMode: (deviceMode) => set({ deviceMode }),
setInspectorCollapsed: (inspectorCollapsed) => set({ inspectorCollapsed }),
setAvailableSources: (availableSources) => set({ availableSources }),
setIsLoading: (isLoading) => set({ isLoading }),
// Section actions
addSection: (type, index) => {
const { sections } = get();
const sectionConfig = SECTION_CONFIGS[type];
if (!sectionConfig) return;
const newSection: Section = {
id: generateId(),
type,
props: { ...sectionConfig.defaultProps },
styles: { ...sectionConfig.defaultStyles }
};
const newSections = [...sections];
if (typeof index === 'number') {
newSections.splice(index, 0, newSection);
} else {
newSections.push(newSection);
}
set({ sections: newSections, hasUnsavedChanges: true });
// Select the new section
set({ selectedSectionId: newSection.id });
},
deleteSection: (id) => {
const { sections, selectedSectionId } = get();
const newSections = sections.filter(s => s.id !== id);
set({
sections: newSections,
hasUnsavedChanges: true,
selectedSectionId: selectedSectionId === id ? null : selectedSectionId
});
},
duplicateSection: (id) => {
const { sections } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
const section = sections[index];
const newSection: Section = {
...JSON.parse(JSON.stringify(section)), // Deep clone
id: generateId()
};
const newSections = [...sections];
newSections.splice(index + 1, 0, newSection);
set({ sections: newSections, hasUnsavedChanges: true });
},
moveSection: (id, direction) => {
const { sections } = get();
const index = sections.findIndex(s => s.id === id);
if (index === -1) return;
if (direction === 'up' && index > 0) {
const newSections = [...sections];
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
} else if (direction === 'down' && index < sections.length - 1) {
const newSections = [...sections];
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
set({ sections: newSections, hasUnsavedChanges: true });
}
},
reorderSections: (sections) => {
set({ sections, hasUnsavedChanges: true });
},
updateSectionProp: (sectionId, propName, value) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
props: {
...section.props,
[propName]: value
}
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionLayout: (sectionId, layoutVariant) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
layoutVariant
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionColorScheme: (sectionId, colorScheme) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
colorScheme
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateSectionStyles: (sectionId, styles) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
return {
...section,
styles: {
...section.styles,
...styles
}
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
updateElementStyles: (sectionId, fieldName, styles) => {
const { sections } = get();
const newSections = sections.map(section => {
if (section.id !== sectionId) return section;
const newElementStyles = {
...section.elementStyles,
[fieldName]: {
...(section.elementStyles?.[fieldName] || {}),
...styles
}
};
return {
...section,
elementStyles: newElementStyles
};
});
set({ sections: newSections, hasUnsavedChanges: true });
},
// Page Actions
setAsSpaLanding: async () => {
const { currentPage } = get();
if (!currentPage || !currentPage.id) return;
try {
set({ isLoading: true });
// Call API to set page as SPA Landing
await fetch(`${(window as any).WNW_API.root}/pages/${currentPage.id}/set-as-spa-landing`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
}
});
// Update local state - only update isSpaLanding, no other properties
set({
currentPage: {
...currentPage,
isSpaLanding: true
},
isLoading: false
});
} catch (error) {
console.error('Failed to set SPA landing page:', error);
set({ isLoading: false });
}
},
unsetSpaLanding: async () => {
const { currentPage } = get();
if (!currentPage) return;
try {
set({ isLoading: true });
// Call API to unset SPA Landing
await fetch(`${(window as any).WNW_API.root}/pages/unset-spa-landing`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
}
});
// Update local state
set({
currentPage: {
...currentPage,
isSpaLanding: false
},
isLoading: false
});
} catch (error) {
console.error('Failed to unset SPA landing page:', error);
set({ isLoading: false });
}
},
// Persistence
markAsChanged: () => set({ hasUnsavedChanges: true }),
markAsSaved: () => set({ hasUnsavedChanges: false }),
savePage: async () => {
const { currentPage, sections } = get();
if (!currentPage) return;
try {
set({ isLoading: true });
const endpoint = currentPage.type === 'page'
? `/pages/${currentPage.slug}`
: `/templates/${currentPage.cpt}`;
await fetch(`${(window as any).WNW_API.root}${endpoint}`, {
method: 'POST',
headers: {
'X-WP-Nonce': (window as any).WNW_API.nonce,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sections,
container_width: currentPage.containerWidth
})
});
set({
hasUnsavedChanges: false,
isLoading: false
});
} catch (error) {
console.error('Failed to save page:', error);
set({ isLoading: false });
}
},
reset: () =>
set({
currentPage: null,
sections: [],
selectedSectionId: null,
hoveredSectionId: null,
hasUnsavedChanges: false,
}),
}));

View File

@@ -11,27 +11,34 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ErrorCard } from '@/components/ErrorCard';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit } from 'lucide-react';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { formatMoney } from '@/lib/currency';
export default function CustomersIndex() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// State
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// FAB config - 'none' because submenu has 'New' tab (per SOP)
useFABConfig('none');
// Fetch customers
const customersQuery = useQuery({
queryKey: ['customers', page, search],
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
@@ -46,14 +53,14 @@ export default function CustomersIndex() {
showErrorToast(error);
},
});
// Handlers
const toggleSelection = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleAll = () => {
if (selectedIds.length === customers.length) {
setSelectedIds([]);
@@ -61,21 +68,21 @@ export default function CustomersIndex() {
setSelectedIds(customers.map(c => c.id));
}
};
const handleDelete = () => {
if (selectedIds.length === 0) return;
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
deleteMutation.mutate(selectedIds);
};
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
};
// Data
const customers = customersQuery.data?.data || [];
const pagination = customersQuery.data?.pagination;
// Loading state
if (customersQuery.isLoading) {
return (
@@ -85,7 +92,7 @@ export default function CustomersIndex() {
</div>
);
}
// Error state
if (customersQuery.isError) {
return (
@@ -96,7 +103,7 @@ export default function CustomersIndex() {
/>
);
}
return (
<div className="space-y-4">
{/* Mobile: Search */}
@@ -130,7 +137,7 @@ export default function CustomersIndex() {
{__('Delete')} ({selectedIds.length})
</button>
)}
<button
onClick={handleRefresh}
disabled={customersQuery.isFetching}
@@ -140,7 +147,7 @@ export default function CustomersIndex() {
{__('Refresh')}
</button>
</div>
{/* Right: Search */}
<div className="flex gap-3 items-center">
<div className="relative">
@@ -158,7 +165,7 @@ export default function CustomersIndex() {
</div>
</div>
</div>
{/* Desktop: Table */}
<div className="hidden md:block rounded-lg border overflow-hidden">
<table className="w-full">
@@ -212,9 +219,8 @@ export default function CustomersIndex() {
</td>
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
<td className="p-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
{customer.role === 'customer' ? __('Member') : __('Guest')}
</span>
</td>
@@ -225,14 +231,37 @@ export default function CustomersIndex() {
<td className="p-3 text-sm text-muted-foreground">
{new Date(customer.registered).toLocaleDateString()}
</td>
<td className="p-3">
<button
onClick={() => navigate(`/customers/${customer.id}/edit`)}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Edit className="w-4 h-4" />
{__('Edit')}
</button>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}`)}>
<Eye className="mr-2 h-4 w-4" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this customer?'))) {
deleteMutation.mutate([customer.id]);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))
@@ -240,7 +269,7 @@ export default function CustomersIndex() {
</tbody>
</table>
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-3">
{customers.length === 0 ? (
@@ -257,7 +286,7 @@ export default function CustomersIndex() {
>
<div className="flex items-center gap-3">
{/* Checkbox */}
<div
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -302,7 +331,7 @@ export default function CustomersIndex() {
))
)}
</div>
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<div className="flex justify-center gap-2">

View File

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

View File

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

View File

@@ -8,10 +8,18 @@ import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFABConfig } from '@/hooks/useFABConfig';
import { CouponFilterSheet } from './components/CouponFilterSheet';
import { CouponCard } from './components/CouponCard';
@@ -34,11 +42,11 @@ export default function CouponsIndex() {
// Fetch coupons
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['coupons', page, search, discountType],
queryFn: () => CouponsApi.list({
page,
per_page: 20,
search,
discount_type: discountType && discountType !== 'all' ? discountType : undefined
queryFn: () => CouponsApi.list({
page,
per_page: 20,
search,
discount_type: discountType && discountType !== 'all' ? discountType : undefined
}),
});
@@ -58,7 +66,7 @@ export default function CouponsIndex() {
// Bulk delete
const handleBulkDelete = async () => {
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
for (const id of selectedIds) {
await deleteMutation.mutateAsync(id);
}
@@ -149,7 +157,7 @@ export default function CouponsIndex() {
{/* Desktop Toolbar */}
<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">
{/* Left: Bulk Actions */}
<div className="flex gap-3">
{/* Delete - Show only when items selected */}
@@ -173,7 +181,7 @@ export default function CouponsIndex() {
<RefreshCw className="w-4 h-4" />
{__('Refresh')}
</button>
{/* New Coupon - Desktop only */}
<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"
@@ -264,7 +272,7 @@ export default function CouponsIndex() {
</td>
<td className="p-3">
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
{coupon.code}
{coupon.code}
</Link>
{coupon.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
@@ -289,13 +297,32 @@ export default function CouponsIndex() {
)}
</td>
<td className="p-3 text-center">
<button
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
>
<Edit className="w-4 h-4" />
{__('Edit')}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/coupons/${coupon.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this coupon?'))) {
deleteMutation.mutate(coupon.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -131,138 +130,131 @@ export default function Campaigns() {
return (
<div className="space-y-6">
<SettingsCard
title={__('All Campaigns')}
description={`${campaigns.length} ${__('campaigns total')}`}
>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search campaigns...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('New Campaign')}
</Button>
</div>
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search campaigns...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('New Campaign')}
</Button>
</div>
{/* Campaigns Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading campaigns...')}
</div>
) : filteredCampaigns.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{searchQuery ? __('No campaigns found matching your search') : (
<div className="space-y-4">
<Send className="h-12 w-12 mx-auto opacity-50" />
<p>{__('No campaigns yet')}</p>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('Create your first campaign')}
</Button>
</div>
)}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Title')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCampaigns.map((campaign) => {
const status = statusConfig[campaign.status] || statusConfig.draft;
const StatusIcon = status.icon;
return (
<TableRow key={campaign.id}>
<TableCell>
<div>
<div className="font-medium">{campaign.title}</div>
{campaign.subject && (
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
{campaign.subject}
</div>
)}
</div>
</TableCell>
<TableCell>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
<StatusIcon className="h-3 w-3" />
{__(status.label)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell">
{campaign.status === 'sent' ? (
<span>
{campaign.sent_count}/{campaign.recipient_count}
{campaign.failed_count > 0 && (
<span className="text-red-500 ml-1">
({campaign.failed_count} {__('failed')})
</span>
)}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{campaign.sent_at
? formatDate(campaign.sent_at)
: campaign.scheduled_at
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
: formatDate(campaign.created_at)
}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
<Copy className="mr-2 h-4 w-4" />
{__('Duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteId(campaign.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{/* Campaigns Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading campaigns...')}
</div>
) : filteredCampaigns.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{searchQuery ? __('No campaigns found matching your search') : (
<div className="space-y-4">
<Send className="h-12 w-12 mx-auto opacity-50" />
<p>{__('No campaigns yet')}</p>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('Create your first campaign')}
</Button>
</div>
)}
</div>
</SettingsCard>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Title')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCampaigns.map((campaign) => {
const status = statusConfig[campaign.status] || statusConfig.draft;
const StatusIcon = status.icon;
return (
<TableRow key={campaign.id}>
<TableCell>
<div>
<div className="font-medium">{campaign.title}</div>
{campaign.subject && (
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
{campaign.subject}
</div>
)}
</div>
</TableCell>
<TableCell>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
<StatusIcon className="h-3 w-3" />
{__(status.label)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell">
{campaign.status === 'sent' ? (
<span>
{campaign.sent_count}/{campaign.recipient_count}
{campaign.failed_count > 0 && (
<span className="text-red-500 ml-1">
({campaign.failed_count} {__('failed')})
</span>
)}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{campaign.sent_at
? formatDate(campaign.sent_at)
: campaign.scheduled_at
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
: formatDate(campaign.created_at)
}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
<Copy className="mr-2 h-4 w-4" />
{__('Duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteId(campaign.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>

View File

@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, Trash2, Search } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
@@ -16,6 +17,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export default function Subscribers() {
const [searchQuery, setSearchQuery] = useState('');
@@ -66,91 +73,147 @@ export default function Subscribers() {
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
);
// Checkbox logic
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
const toggleAll = () => {
if (selectedIds.length === filteredSubscribers.length) {
setSelectedIds([]);
} else {
setSelectedIds(filteredSubscribers.map((s: any) => s.email));
}
};
const toggleRow = (email: string) => {
setSelectedIds(prev =>
prev.includes(email) ? prev.filter(e => e !== email) : [...prev, email]
);
};
const handleBulkDelete = async () => {
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
for (const email of selectedIds) {
await deleteSubscriber.mutateAsync(email);
}
setSelectedIds([]);
};
return (
<div className="space-y-6">
<SettingsCard
title={__('Subscribers List')}
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Filter subscribers...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<Button onClick={exportSubscribers} variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
{__('Export CSV')}
</Button>
</div>
{/* Subscribers Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Email')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Subscribed Date')}</TableHead>
<TableHead>{__('WP User')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{subscriber.status || __('Active')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">{__('No')}</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => deleteSubscriber.mutate(subscriber.email)}
disabled={deleteSubscriber.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Filter subscribers...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
</SettingsCard>
<div className="flex gap-2">
{selectedIds.length > 0 && (
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')} ({selectedIds.length})
</Button>
)}
<Button onClick={exportSubscribers} variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
{__('Export CSV')}
</Button>
</div>
</div >
{/* Subscribers Table */}
{
isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
<Checkbox
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<TableHead>{__('Email')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Subscribed Date')}</TableHead>
<TableHead>{__('WP User')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="p-3">
<Checkbox
checked={selectedIds.includes(subscriber.email)}
onCheckedChange={() => toggleRow(subscriber.email)}
aria-label={__('Select subscriber')}
/>
</TableCell>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{subscriber.status || __('Active')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">{__('No')}</span>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
deleteSubscriber.mutate(subscriber.email);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Remove')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
{/* Email Template Settings */}
<SettingsCard
@@ -187,6 +250,6 @@ export default function Subscribers() {
</div>
</div>
</SettingsCard>
</div>
</div >
);
}

View File

@@ -1,41 +1,24 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import React, { useState } from 'react';
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Mail } from 'lucide-react';
import { Mail, Users, Send } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { useModules } from '@/hooks/useModules';
import Subscribers from './Subscribers';
import Campaigns from './Campaigns';
import { cn } from '@/lib/utils'; // Assuming cn exists, widely used in ShadCN
export default function Newsletter() {
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('subscribers');
export default function NewsletterLayout() {
const navigate = useNavigate();
const location = useLocation();
const { isEnabled } = useModules();
// Check for tab query param
useEffect(() => {
const tabParam = searchParams.get('tab');
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
setActiveTab(tabParam);
}
}, [searchParams]);
// Update URL when tab changes
const handleTabChange = (value: string) => {
setActiveTab(value);
setSearchParams({ tab: value });
};
// Show disabled state if newsletter module is off
if (!isEnabled('newsletter')) {
return (
<SettingsLayout
title={__('Newsletter')}
description={__('Newsletter module is disabled')}
>
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
<p className="text-muted-foreground mt-2">{__('Newsletter module is disabled')}</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
@@ -46,29 +29,78 @@ export default function Newsletter() {
{__('Go to Module Settings')}
</Button>
</div>
</SettingsLayout>
</div>
);
}
const navItems = [
{
id: 'subscribers',
label: __('Subscribers'),
icon: Users,
path: '/marketing/newsletter/subscribers',
isActive: (path: string) => path.includes('/subscribers')
},
{
id: 'campaigns',
label: __('Campaigns'),
icon: Send,
path: '/marketing/newsletter/campaigns',
isActive: (path: string) => path.includes('/campaigns')
}
];
return (
<SettingsLayout
title={__('Newsletter')}
description={__('Manage subscribers and send email campaigns')}
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
</TabsList>
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
<p className="text-muted-foreground mt-2">{__('Manage subscribers and send email campaigns')}</p>
</div>
<TabsContent value="subscribers" className="space-y-4 mt-6">
<Subscribers />
</TabsContent>
<div className="flex flex-col lg:flex-row gap-6">
{/* Sidebar Navigation */}
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.isActive(location.pathname);
return (
<Link
key={item.id}
to={item.path}
className={cn(
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
// Focus styles matching ShadCN buttons (ring only on keyboard focus)
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
)}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
<TabsContent value="campaigns" className="space-y-4 mt-6">
<Campaigns />
</TabsContent>
</Tabs>
</SettingsLayout>
<div className="pt-4 border-t">
<Button
className="w-full justify-start"
variant="outline"
onClick={() => navigate('/marketing/newsletter/campaigns/new')}
>
<span className="mr-2">+</span>
{__('New Campaign')}
</Button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

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

View File

@@ -122,6 +122,15 @@ export default function MorePage() {
{/* Exit Fullscreen / Logout */}
<div className=" py-6 space-y-3">
<Button
onClick={() => window.open(window.WNW_CONFIG?.storeUrl || '/store/', '_blank')}
variant="outline"
className="w-full justify-start gap-3"
>
<ExternalLink className="w-5 h-5" />
{__('Visit Store')}
</Button>
{isStandalone && (
<Button
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}

View File

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

View File

@@ -1,24 +1,31 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react';
import { Filter, PackageOpen, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
import { useFABConfig } from '@/hooks/useFABConfig';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import {
Select,
@@ -94,8 +101,8 @@ export default function Orders() {
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -136,7 +143,7 @@ export default function Orders() {
const rows = data?.rows;
if (!rows) return [];
if (!searchQuery.trim()) return rows;
const query = searchQuery.toLowerCase();
return rows.filter((order: any) =>
order.number?.toString().includes(query) ||
@@ -255,8 +262,8 @@ export default function Orders() {
{__('Delete')} ({selectedIds.length})
</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"
onClick={handleRefresh}
disabled={q.isLoading || isRefreshing}
@@ -305,7 +312,7 @@ export default function Orders() {
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
}}
/>
{activeFiltersCount > 0 && (
<button
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
@@ -432,7 +439,7 @@ export default function Orders() {
/>
</td>
<td className="p-3">
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
<Link className="font-medium hover:underline" to={`/orders/${row.id}`}>#{row.number}</Link>
</td>
<td className="p-3 min-w-32">
<span title={row.date ?? ""}>
@@ -454,9 +461,36 @@ export default function Orders() {
decimals: store.decimals,
})}
</td>
<td className="p-3 text-center space-x-2">
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}`)}>
<Eye className="mr-2 h-4 w-4" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit Order')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setSelectedIds([row.id]);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}

View File

@@ -1,20 +1,20 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
import { useFABConfig } from '@/hooks/useFABConfig';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -27,6 +27,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Link, useNavigate } from 'react-router-dom';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import { Skeleton } from '@/components/ui/skeleton';
@@ -45,7 +52,7 @@ function StockBadge({ value, quantity }: { value?: string; quantity?: number })
const v = (value || '').toLowerCase();
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;
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
{label}
@@ -62,8 +69,8 @@ export default function Products() {
const [type, setType] = useState<string | undefined>(initial.type || undefined);
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'title' | 'id' | 'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -113,7 +120,7 @@ export default function Products() {
const rows = data?.rows;
if (!rows) return [];
if (!searchQuery.trim()) return rows;
const query = searchQuery.toLowerCase();
return rows.filter((product: any) =>
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 gap-3">
{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"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
@@ -236,8 +243,8 @@ export default function Products() {
{__('Delete')} ({selectedIds.length})
</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"
onClick={handleRefresh}
disabled={q.isLoading || isRefreshing}
@@ -412,9 +419,37 @@ export default function Products() {
</span>
</td>
<td className="p-3 text-right">
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
{__('Edit')}
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => nav(`/products/${product.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
{product.permalink && (
<DropdownMenuItem onClick={() => window.open(product.permalink, '_blank')}>
<Eye className="mr-2 h-4 w-4" />
{__('View')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setSelectedIds([product.id]);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

@@ -40,6 +40,13 @@ export type ProductFormData = {
licensing_enabled?: boolean;
license_activation_limit?: string;
license_duration_days?: string;
license_activation_method?: '' | 'api' | 'oauth';
// Subscription
subscription_enabled?: boolean;
subscription_period?: 'day' | 'week' | 'month' | 'year';
subscription_interval?: string;
subscription_trial_days?: string;
subscription_signup_fee?: string;
};
type Props = {
@@ -89,6 +96,13 @@ export function ProductFormTabbed({
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
const [licenseActivationMethod, setLicenseActivationMethod] = useState<'' | 'api' | 'oauth'>(initial?.license_activation_method || '');
// Subscription state
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -119,6 +133,13 @@ export function ProductFormTabbed({
setLicensingEnabled(initial.licensing_enabled || false);
setLicenseActivationLimit(initial.license_activation_limit || '');
setLicenseDurationDays(initial.license_duration_days || '');
setLicenseActivationMethod(initial.license_activation_method || '');
// Subscription
setSubscriptionEnabled(initial.subscription_enabled || false);
setSubscriptionPeriod(initial.subscription_period || 'month');
setSubscriptionInterval(initial.subscription_interval || '1');
setSubscriptionTrialDays(initial.subscription_trial_days || '');
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
}
}, [initial, mode]);
@@ -181,6 +202,13 @@ export function ProductFormTabbed({
licensing_enabled: licensingEnabled,
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
license_activation_method: licensingEnabled ? licenseActivationMethod : undefined,
// Subscription
subscription_enabled: subscriptionEnabled,
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
};
await onSubmit(payload);
@@ -237,6 +265,18 @@ export function ProductFormTabbed({
setLicenseActivationLimit={setLicenseActivationLimit}
licenseDurationDays={licenseDurationDays}
setLicenseDurationDays={setLicenseDurationDays}
licenseActivationMethod={licenseActivationMethod}
setLicenseActivationMethod={setLicenseActivationMethod}
subscriptionEnabled={subscriptionEnabled}
setSubscriptionEnabled={setSubscriptionEnabled}
subscriptionPeriod={subscriptionPeriod}
setSubscriptionPeriod={setSubscriptionPeriod}
subscriptionInterval={subscriptionInterval}
setSubscriptionInterval={setSubscriptionInterval}
subscriptionTrialDays={subscriptionTrialDays}
setSubscriptionTrialDays={setSubscriptionTrialDays}
subscriptionSignupFee={subscriptionSignupFee}
setSubscriptionSignupFee={setSubscriptionSignupFee}
/>
</FormSection>

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor';
@@ -50,6 +50,19 @@ type GeneralTabProps = {
setLicenseActivationLimit?: (value: string) => void;
licenseDurationDays?: string;
setLicenseDurationDays?: (value: string) => void;
licenseActivationMethod?: '' | 'api' | 'oauth';
setLicenseActivationMethod?: (value: '' | 'api' | 'oauth') => void;
// Subscription
subscriptionEnabled?: boolean;
setSubscriptionEnabled?: (value: boolean) => void;
subscriptionPeriod?: 'day' | 'week' | 'month' | 'year';
setSubscriptionPeriod?: (value: 'day' | 'week' | 'month' | 'year') => void;
subscriptionInterval?: string;
setSubscriptionInterval?: (value: string) => void;
subscriptionTrialDays?: string;
setSubscriptionTrialDays?: (value: string) => void;
subscriptionSignupFee?: string;
setSubscriptionSignupFee?: (value: string) => void;
};
export function GeneralTab({
@@ -84,6 +97,18 @@ export function GeneralTab({
setLicenseActivationLimit,
licenseDurationDays,
setLicenseDurationDays,
licenseActivationMethod,
setLicenseActivationMethod,
subscriptionEnabled,
setSubscriptionEnabled,
subscriptionPeriod,
setSubscriptionPeriod,
subscriptionInterval,
setSubscriptionInterval,
subscriptionTrialDays,
setSubscriptionTrialDays,
subscriptionSignupFee,
setSubscriptionSignupFee,
}: GeneralTabProps) {
const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
@@ -477,6 +502,113 @@ export function GeneralTab({
</p>
</div>
</div>
{setLicenseActivationMethod && (
<div>
<Label className="text-xs">{__('Activation Method')}</Label>
<Select
value={licenseActivationMethod || 'default'}
onValueChange={(v) => setLicenseActivationMethod(v === 'default' ? '' : v as '' | 'api' | 'oauth')}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder={__('Use Site Default')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{__('Use Site Default')}</SelectItem>
<SelectItem value="api">{__('Simple API (license key only)')}</SelectItem>
<SelectItem value="oauth">{__('Secure OAuth (requires login)')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
{__('Override site-level activation method for this product')}
</p>
</div>
)}
</div>
)}
</>
)}
{/* Subscription option */}
{setSubscriptionEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="subscription-enabled"
checked={subscriptionEnabled || false}
onCheckedChange={(checked) => setSubscriptionEnabled(checked as boolean)}
/>
<Label htmlFor="subscription-enabled" className="cursor-pointer font-normal flex items-center gap-1">
<Repeat className="h-3 w-3" />
{__('Enable subscription for this product')}
</Label>
</div>
{/* Subscription settings panel */}
{subscriptionEnabled && (
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Billing Period')}</Label>
<Select
value={subscriptionPeriod || 'month'}
onValueChange={(v: any) => setSubscriptionPeriod?.(v)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="day">{__('Day')}</SelectItem>
<SelectItem value="week">{__('Week')}</SelectItem>
<SelectItem value="month">{__('Month')}</SelectItem>
<SelectItem value="year">{__('Year')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">{__('Billing Interval')}</Label>
<Input
type="number"
min="1"
placeholder="1"
value={subscriptionInterval || '1'}
onChange={(e) => setSubscriptionInterval?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('e.g., 1 = every month, 3 = every 3 months')}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Free Trial Days')}</Label>
<Input
type="number"
min="0"
placeholder={__('0 = no trial')}
value={subscriptionTrialDays || ''}
onChange={(e) => setSubscriptionTrialDays?.(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">{__('Sign-up Fee')}</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={subscriptionSignupFee || ''}
onChange={(e) => setSubscriptionSignupFee?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('One-time fee charged on first order')}
</p>
</div>
</div>
</div>
)}
</>
@@ -527,6 +659,6 @@ export function GeneralTab({
</>
)}
</CardContent>
</Card>
</Card >
);
}

View File

@@ -10,7 +10,8 @@ import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/
import { CodeEditor } from '@/components/ui/code-editor';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, RotateCcw, FileText } from 'lucide-react';
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { markdownToHtml } from '@/lib/markdown-utils';
@@ -38,12 +39,22 @@ export default function EditTemplate() {
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
const [activeTab, setActiveTab] = useState('preview');
// Fetch email customization settings
// Send Test Email state
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
const [testEmail, setTestEmail] = useState('');
// Fetch email customization settings (for non-color settings like logo, footer, social links)
const { data: emailSettings } = useQuery({
queryKey: ['email-settings'],
queryFn: () => api.get('/notifications/email-settings'),
});
// Fetch appearance settings for unified colors
const { data: appearanceSettings } = useQuery({
queryKey: ['appearance-settings'],
queryFn: () => api.get('/appearance/settings'),
});
// Fetch template
const { data: template, isLoading, error } = useQuery({
queryKey: ['notification-template', eventId, channelId, recipientType],
@@ -114,6 +125,32 @@ export default function EditTemplate() {
}
};
// Send test email mutation
const sendTestMutation = useMutation({
mutationFn: async (email: string) => {
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
email,
recipient: recipientType,
});
},
onSuccess: (data: any) => {
toast.success(data.message || __('Test email sent successfully'));
setTestEmailDialogOpen(false);
setTestEmail('');
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to send test email'));
},
});
const handleSendTest = () => {
if (!testEmail || !testEmail.includes('@')) {
toast.error(__('Please enter a valid email address'));
return;
}
sendTestMutation.mutate(testEmail);
};
// Visual mode: Update blocks → Markdown (source of truth)
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
setBlocks(newBlocks);
@@ -181,7 +218,7 @@ export default function EditTemplate() {
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
store_url: window.location.origin,
site_url: window.location.origin,
store_email: 'store@example.com',
};
@@ -261,7 +298,7 @@ export default function EditTemplate() {
current_year: new Date().getFullYear().toString(),
site_name: 'My WordPress Store',
store_name: 'My WordPress Store',
store_url: '#',
site_url: '#',
store_email: 'store@example.com',
support_email: 'support@example.com',
// Account-related URLs and variables
@@ -273,6 +310,9 @@ export default function EditTemplate() {
user_temp_password: '••••••••',
customer_first_name: 'John',
customer_last_name: 'Doe',
// Campaign/Newsletter variables
content: '<p>This is sample content that would be replaced with your actual campaign content.</p>',
campaign_title: 'Newsletter Campaign',
};
Object.keys(sampleData).forEach((key) => {
@@ -288,14 +328,15 @@ export default function EditTemplate() {
}
});
// Get email settings for preview
// Get email settings for preview - use UNIFIED appearance settings for colors
const settings = emailSettings || {};
const primaryColor = settings.primary_color || '#7f54b3';
const secondaryColor = settings.secondary_color || '#7f54b3';
const heroGradientStart = settings.hero_gradient_start || '#667eea';
const heroGradientEnd = settings.hero_gradient_end || '#764ba2';
const heroTextColor = settings.hero_text_color || '#ffffff';
const buttonTextColor = settings.button_text_color || '#ffffff';
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
const primaryColor = appearColors.primary || '#7f54b3';
const secondaryColor = appearColors.secondary || '#7f54b3';
const heroGradientStart = appearColors.gradientStart || '#667eea';
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
const heroTextColor = '#ffffff'; // Always white on gradient
const buttonTextColor = '#ffffff'; // Always white on primary
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
const socialIconColor = settings.social_icon_color || 'white';
const logoUrl = settings.logo_url || '';
@@ -307,10 +348,11 @@ export default function EditTemplate() {
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
// Generate social icons HTML with PNG images
// Get plugin URL from config, with fallback
const pluginUrl =
(window as any).woonoowData?.pluginUrl ||
(window as any).WNW_CONFIG?.pluginUrl ||
'';
'/wp-content/plugins/woonoow/';
const socialIconsHtml = socialLinks.length > 0 ? `
<div style="margin-top: 16px;">
${socialLinks.map((link: any) => `
@@ -354,6 +396,7 @@ export default function EditTemplate() {
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
.text-link { color: ${primaryColor}; text-decoration: underline; }
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
@@ -414,128 +457,175 @@ export default function EditTemplate() {
}
return (
<SettingsLayout
title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
onSave={handleSave}
saveLabel={__('Save Template')}
isLoading={false}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Determine if staff or customer based on event category
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
const page = isStaffEvent ? 'staff' : 'customer';
navigate(`/settings/notifications/${page}?tab=events`);
}}
className="gap-2"
title={__('Back')}
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-2"
title={__('Reset to Default')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span>
</Button>
</div>
}
>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
<>
<SettingsLayout
title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
onSave={handleSave}
saveLabel={__('Save Template')}
isLoading={false}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Determine if staff or customer based on event category
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
const page = isStaffEvent ? 'staff' : 'customer';
navigate(`/settings/notifications/${page}?tab=events`);
}}
className="gap-2"
title={__('Back')}
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-2"
title={__('Reset to Default')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setTestEmailDialogOpen(true)}
className="gap-2"
title={__('Send Test')}
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">{__('Send Test')}</span>
</Button>
</div>
{/* Body */}
<div className="space-y-4">
{/* Three-tab system: Preview | Visual | Markdown */}
<div className="flex items-center justify-between">
<Label>{__('Message Body')}</Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList className="grid grid-cols-3">
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
<Eye className="h-3 w-3" />
{__('Preview')}
</TabsTrigger>
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
<Edit className="h-3 w-3" />
{__('Visual')}
</TabsTrigger>
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
<FileText className="h-3 w-3" />
{__('Markdown')}
</TabsTrigger>
</TabsList>
</Tabs>
}
>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
</div>
{/* Preview Tab */}
{activeTab === 'preview' && (
<div className="border rounded-md overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full min-h-[600px] overflow-hidden bg-white"
title={__('Email Preview')}
/>
{/* Body */}
<div className="space-y-4">
{/* Three-tab system: Preview | Visual | Markdown */}
<div className="flex items-center justify-between">
<Label>{__('Message Body')}</Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList className="grid grid-cols-3">
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
<Eye className="h-3 w-3" />
{__('Preview')}
</TabsTrigger>
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
<Edit className="h-3 w-3" />
{__('Visual')}
</TabsTrigger>
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
<FileText className="h-3 w-3" />
{__('Markdown')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{/* Visual Tab */}
{activeTab === 'visual' && (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
</p>
</div>
)}
{/* Preview Tab */}
{activeTab === 'preview' && (
<div className="border rounded-md overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full min-h-[600px] overflow-hidden bg-white"
title={__('Email Preview')}
/>
</div>
)}
{/* Markdown Tab */}
{activeTab === 'markdown' && (
<div className="space-y-2">
<CodeEditor
value={markdownContent}
onChange={handleMarkdownChange}
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
supportMarkdown={true}
/>
<p className="text-xs text-muted-foreground">
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
</p>
<p className="text-xs text-muted-foreground">
{__('All changes are automatically synced between Visual and Markdown modes.')}
</p>
</div>
)}
{/* Visual Tab */}
{activeTab === 'visual' && (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
</p>
</div>
)}
{/* Markdown Tab */}
{activeTab === 'markdown' && (
<div className="space-y-2">
<CodeEditor
value={markdownContent}
onChange={handleMarkdownChange}
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
supportMarkdown={true}
/>
<p className="text-xs text-muted-foreground">
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
</p>
<p className="text-xs text-muted-foreground">
{__('All changes are automatically synced between Visual and Markdown modes.')}
</p>
</div>
)}
</div>
</CardContent>
</Card>
</SettingsLayout>
{/* Send Test Email Dialog */}
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Send Test Email')}</DialogTitle>
<DialogDescription>
{__('Send a test email with sample data to verify the template looks correct.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input
id="test-email"
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="you@example.com"
/>
<p className="text-xs text-muted-foreground">
{__('The subject will be prefixed with [TEST]')}
</p>
</div>
</div>
</CardContent>
</Card>
</SettingsLayout>
<DialogFooter>
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -219,190 +219,22 @@ export default function EmailCustomization() {
}
>
<div className="space-y-6">
{/* Brand Colors */}
{/* Unified Colors Notice */}
<SettingsCard
title={__('Brand Colors')}
description={__('Set your primary and secondary brand colors for buttons and accents')}
description={__('Colors for buttons, gradients, and accents in emails')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
<div className="flex gap-2">
<Input
id="primary_color"
type="color"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for primary buttons and main accents')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
<div className="flex gap-2">
<Input
id="secondary_color"
type="color"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for outline buttons and borders')}
</p>
</div>
</div>
</SettingsCard>
{/* Hero Card Gradient */}
<SettingsCard
title={__('Hero Card Gradient')}
description={__('Customize the gradient colors for hero/success card backgrounds')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_start"
type="color"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
placeholder="#667eea"
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_end"
type="color"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
placeholder="#764ba2"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
<div className="flex gap-2">
<Input
id="hero_text_color"
type="color"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text and heading color for hero cards (usually white)')}
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-900 dark:text-blue-100">
<strong>{__('Colors are now unified!')}</strong>{' '}
{__('Email colors (buttons, gradients) now use the same colors as your storefront for consistent branding.')}
</p>
<p className="text-sm text-blue-900 dark:text-blue-100 mt-2">
{__('To change colors, go to')}{' '}
<a href="#/appearance/general" className="font-medium underline hover:no-underline">
{__('Appearance → General → Colors')}
</a>
</p>
</div>
{/* Preview */}
<div className="mt-4 p-6 rounded-lg text-center" style={{
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
}}>
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
</div>
</SettingsCard>
{/* Button Styling */}
<SettingsCard
title={__('Button Styling')}
description={__('Customize button text color and appearance')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
<div className="flex gap-2">
<Input
id="button_text_color"
type="color"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text color for buttons (usually white for dark buttons)')}
</p>
</div>
{/* Button Preview */}
<div className="flex gap-3 flex-wrap">
<button
className="px-6 py-3 rounded-lg font-medium"
style={{
backgroundColor: formData.primary_color,
color: formData.button_text_color,
}}
>
{__('Primary Button')}
</button>
<button
className="px-6 py-3 rounded-lg font-medium border-2"
style={{
borderColor: formData.secondary_color,
color: formData.secondary_color,
backgroundColor: 'transparent',
}}
>
{__('Secondary Button')}
</button>
</div>
</div>
</SettingsCard>
@@ -540,7 +372,7 @@ export default function EmailCustomization() {
{__('Add Social Link')}
</Button>
</div>
{formData.social_links.length === 0 ? (
<p className="text-sm text-muted-foreground">
{__('No social links added. Click "Add Social Link" to get started.')}

View File

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

View File

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

View File

@@ -0,0 +1,423 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Play, Pause, XCircle, RefreshCw, Calendar, User, Package, CreditCard, Clock, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
interface SubscriptionOrder {
id: number;
subscription_id: number;
order_id: number;
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
order_status: string;
created_at: string;
}
interface Subscription {
id: number;
user_id: number;
order_id: number;
product_id: number;
variation_id: number | null;
product_name: string;
product_image: string;
user_name: string;
user_email: string;
status: string;
billing_period: string;
billing_interval: number;
billing_schedule: string;
recurring_amount: string;
start_date: string;
trial_end_date: string | null;
next_payment_date: string | null;
end_date: string | null;
last_payment_date: string | null;
payment_method: string;
payment_method_title?: string;
pause_count: number;
failed_payment_count: number;
cancel_reason: string | null;
created_at: string;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
orders: SubscriptionOrder[];
}
const statusColors: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
'draft': 'bg-gray-100 text-gray-600',
};
const statusLabels: Record<string, string> = {
'pending': __('Pending'),
'active': __('Active'),
'on-hold': __('On Hold'),
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
'draft': __('Draft'),
};
const orderTypeLabels: Record<string, string> = {
'parent': __('Initial Order'),
'renewal': __('Renewal'),
'switch': __('Plan Switch'),
'resubscribe': __('Resubscribe'),
};
const formatPrice = (amount: string | number) => {
const val = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(val)) return amount;
// Simple formatting using browser's locale but keeping currency from store
try {
return new Intl.NumberFormat(window.WNW_STORE?.locale || 'en-US', {
style: 'currency',
currency: window.WNW_STORE?.currency || 'USD',
minimumFractionDigits: window.WNW_STORE?.decimals || 2,
}).format(val);
} catch (e) {
return (window.WNW_STORE?.currency_symbol || '$') + val;
}
};
async function fetchSubscription(id: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscription');
return res.json();
}
async function subscriptionAction(id: number, action: string, reason?: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.WNW_API.nonce,
},
body: JSON.stringify({ reason }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `Failed to ${action} subscription`);
}
return res.json();
}
export default function SubscriptionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const { data: subscription, isLoading, error } = useQuery<Subscription>({
queryKey: ['subscription', id],
queryFn: () => fetchSubscription(id!),
enabled: !!id,
});
useEffect(() => {
if (subscription) {
setPageHeader(__('Subscription') + ' #' + subscription.id);
}
return () => clearPageHeader();
}, [subscription, setPageHeader, clearPageHeader]);
const actionMutation = useMutation({
mutationFn: ({ action, reason }: { action: string; reason?: string }) =>
subscriptionAction(parseInt(id!), action, reason),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
},
onError: (error: Error) => {
toast.error(error.message);
},
});
const handleAction = (action: string) => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
return;
}
actionMutation.mutate({ action });
};
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
<Skeleton className="h-64" />
</div>
);
}
if (error || !subscription) {
return (
<div className="text-center py-12">
<p className="text-red-500">{__('Failed to load subscription')}</p>
<Button variant="outline" className="mt-4" onClick={() => navigate('/subscriptions')}>
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back to Subscriptions')}
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Back button and actions */}
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => navigate('/subscriptions')}>
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back')}
</Button>
<div className="flex items-center gap-2">
{subscription.can_pause && (
<Button
variant="outline"
onClick={() => handleAction('pause')}
disabled={actionMutation.isPending}
>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</Button>
)}
{subscription.can_resume && (
<Button
variant="outline"
onClick={() => handleAction('resume')}
disabled={actionMutation.isPending}
>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</Button>
)}
{subscription.status === 'active' && (
<Button
variant="outline"
onClick={() => handleAction('renew')}
disabled={actionMutation.isPending}
>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</Button>
)}
{subscription.can_cancel && (
<Button
variant="destructive"
onClick={() => handleAction('cancel')}
disabled={actionMutation.isPending}
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</Button>
)}
</div>
</div>
{/* Status and product info */}
<div className="grid gap-6 md:grid-cols-2">
{/* Subscription Info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{__('Subscription Details')}</CardTitle>
<Badge className={statusColors[subscription.status] || 'bg-gray-100'}>
{statusLabels[subscription.status] || subscription.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
{subscription.product_image ? (
<img
src={subscription.product_image}
alt={subscription.product_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-muted rounded flex items-center justify-center">
<Package className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div>
<h3 className="font-medium">{subscription.product_name}</h3>
<p className="text-sm text-muted-foreground">
{subscription.billing_schedule}
</p>
<p className="text-lg font-semibold mt-1">
{formatPrice(subscription.recurring_amount)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div>
<div className="text-sm text-muted-foreground">{__('Start Date')}</div>
<div>{new Date(subscription.start_date).toLocaleDateString()}</div>
</div>
{subscription.next_payment_date && (
<div>
<div className="text-sm text-muted-foreground">{__('Next Payment')}</div>
<div>{new Date(subscription.next_payment_date).toLocaleDateString()}</div>
</div>
)}
{subscription.trial_end_date && (
<div>
<div className="text-sm text-muted-foreground">{__('Trial End')}</div>
<div>{new Date(subscription.trial_end_date).toLocaleDateString()}</div>
</div>
)}
{subscription.end_date && (
<div>
<div className="text-sm text-muted-foreground">{__('End Date')}</div>
<div>{new Date(subscription.end_date).toLocaleDateString()}</div>
</div>
)}
</div>
{subscription.cancel_reason && (
<div className="pt-4 border-t">
<div className="text-sm text-muted-foreground">{__('Cancel Reason')}</div>
<div className="text-red-600">{subscription.cancel_reason}</div>
</div>
)}
</CardContent>
</Card>
{/* Customer Info */}
<Card>
<CardHeader>
<CardTitle>{__('Customer')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<div className="font-medium">{subscription.user_name}</div>
<div className="text-sm text-muted-foreground">{subscription.user_email}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div>
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{subscription.payment_method_title || subscription.payment_method || __('Not set')}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Pause Count')}</div>
<div>{subscription.pause_count}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Failed Payments')}</div>
<div className={subscription.failed_payment_count > 0 ? 'text-red-600' : ''}>
{subscription.failed_payment_count}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Parent Order')}</div>
<Link
to={`/orders/${subscription.order_id}`}
className="text-primary hover:underline"
>
#{subscription.order_id}
</Link>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Related Orders */}
<Card>
<CardHeader>
<CardTitle>{__('Related Orders')}</CardTitle>
<CardDescription>{__('All orders associated with this subscription')}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Order')}</TableHead>
<TableHead>{__('Type')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Date')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscription.orders?.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
{__('No orders found')}
</TableCell>
</TableRow>
) : (
subscription.orders?.map((order) => {
const rawStatus = order.order_status?.replace('wc-', '') || 'pending';
return (
<TableRow key={order.id}>
<TableCell>
<Link
to={`/orders/${order.order_id}`}
className="text-primary hover:underline font-medium"
>
#{order.order_id}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">
{orderTypeLabels[order.order_type] || order.order_type}
</Badge>
</TableCell>
<TableCell>
<span className="capitalize">{statusLabels[rawStatus] || rawStatus}</span>
</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,368 @@
import React, { useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
interface Subscription {
id: number;
user_id: number;
order_id: number;
product_id: number;
product_name: string;
user_name: string;
user_email: string;
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
billing_schedule: string;
recurring_amount: string;
next_payment_date: string | null;
created_at: string;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
}
const statusColors: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
};
const statusLabels: Record<string, string> = {
'pending': __('Pending'),
'active': __('Active'),
'on-hold': __('On Hold'),
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
};
async function fetchSubscriptions(params: Record<string, string>) {
const url = new URL(window.WNW_API.root + '/subscriptions');
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscriptions');
return res.json();
}
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', reason?: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.WNW_API.nonce,
},
body: JSON.stringify({ reason }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `Failed to ${action} subscription`);
}
return res.json();
}
export default function SubscriptionsIndex() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const status = searchParams.get('status') || '';
const page = parseInt(searchParams.get('page') || '1');
useEffect(() => {
setPageHeader(__('Subscriptions'));
return () => clearPageHeader();
}, [setPageHeader, clearPageHeader]);
const { data, isLoading, error } = useQuery({
queryKey: ['subscriptions', { status, page }],
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
});
const actionMutation = useMutation({
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
subscriptionAction(id, action, reason),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
},
onError: (error: Error) => {
toast.error(error.message);
},
});
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
return;
}
actionMutation.mutate({ id, action });
};
const handleStatusFilter = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === 'all') {
params.delete('status');
} else {
params.set('status', value);
}
params.delete('page');
setSearchParams(params);
};
const subscriptions: Subscription[] = data?.subscriptions || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
// Checkbox logic
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const toggleAll = () => {
if (selectedIds.length === subscriptions.length) {
setSelectedIds([]);
} else {
setSelectedIds(subscriptions.map(s => s.id));
}
};
const toggleRow = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={__('Filter by status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="expired">{__('Expired')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
{__('Total')}: {total} {__('subscriptions')}
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 p-3">
<Checkbox
checked={subscriptions.length > 0 && selectedIds.length === subscriptions.length}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</TableHead>
<TableHead className="w-[80px]">{__('ID')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Product')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Billing')}</TableHead>
<TableHead>{__('Next Payment')}</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
</TableRow>
))
) : subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Repeat className="w-8 h-8 opacity-50" />
<p>{__('No subscriptions found')}</p>
</div>
</TableCell>
</TableRow>
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell className="p-3">
<Checkbox
checked={selectedIds.includes(sub.id)}
onCheckedChange={() => toggleRow(sub.id)}
aria-label={__('Select subscription')}
/>
</TableCell>
<TableCell className="font-medium">
<Link to={`/subscriptions/${sub.id}`} className="hover:underline">
#{sub.id}
</Link>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{sub.user_name}</div>
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
</div>
</TableCell>
<TableCell>{sub.product_name}</TableCell>
<TableCell>
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
{statusLabels[sub.status] || sub.status}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">
{sub.billing_schedule}
</div>
</TableCell>
<TableCell>
{sub.next_payment_date ? (
<div className="text-sm">
{new Date(sub.next_payment_date).toLocaleDateString()}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
<Eye className="w-4 h-4 mr-2" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{sub.can_pause && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</DropdownMenuItem>
)}
{sub.can_resume && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</DropdownMenuItem>
)}
{sub.status === 'active' && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</DropdownMenuItem>
)}
{sub.can_cancel && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction(sub.id, 'cancel')}
className="text-red-600"
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page - 1));
setSearchParams(params);
}}
>
{__('Previous')}
</Button>
<span className="text-sm text-muted-foreground">
{__('Page')} {page} {__('of')} {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page + 1));
setSearchParams(params);
}}
>
{__('Next')}
</Button>
</div>
)}
</div>
);
}

View File

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

View File

@@ -22,5 +22,5 @@ module.exports = {
borderRadius: { lg: "12px", md: "10px", sm: "8px" }
}
},
plugins: [require("tailwindcss-animate")]
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")]
};

View File

@@ -1,12 +0,0 @@
{
"name": "woonoow/woonoow",
"type": "wordpress-plugin",
"autoload": {
"psr-4": {
"WooNooW\\": "plugin/includes/"
}
},
"require": {
"php": "^8.1"
}
}

20
composer.lock generated
View File

@@ -1,20 +0,0 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.1"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -19,6 +19,8 @@ import Wishlist from './pages/Wishlist';
import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import OrderPay from './pages/OrderPay';
import { DynamicPageRenderer } from './pages/DynamicPage';
// Create QueryClient instance
const queryClient = new QueryClient({
@@ -55,47 +57,83 @@ const getAppearanceSettings = () => {
return (window as any).woonoowCustomer?.appearanceSettings || {};
};
// Get initial route from data attribute (set by PHP based on SPA mode)
// Get initial route from data attribute or derive from SPA mode
const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route');
return initialRoute || '/shop'; // Default to shop if not specified
if (initialRoute) return initialRoute;
// Derive from SPA mode if no explicit route
const spaMode = (window as any).woonoowCustomer?.spaMode || 'full';
if (spaMode === 'checkout_only') return '/checkout';
return '/shop'; // Default for full mode
};
// Get front page slug from config
const getFrontPageSlug = () => {
return (window as any).woonoowCustomer?.frontPageSlug || null;
};
// Router wrapper component that uses hooks requiring Router context
function AppRoutes() {
const initialRoute = getInitialRoute();
const frontPageSlug = getFrontPageSlug();
return (
<BaseLayout>
<Routes>
{/* Root route redirects to initial route based on SPA mode */}
<Route path="/" element={<Navigate to={initialRoute} replace />} />
<Routes>
{/* License Connect - Standalone focused page without layout */}
<Route path="/my-account/license-connect" element={<Account />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* All other routes wrapped in BaseLayout */}
<Route
path="/*"
element={
<BaseLayout>
<Routes>
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Fallback to initial route */}
<Route path="*" element={<Navigate to={initialRoute} replace />} />
</Routes>
</BaseLayout>
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
<Route path="/:slug" element={<DynamicPageRenderer />} />
</Routes>
</BaseLayout>
}
/>
</Routes>
);
}
@@ -104,7 +142,7 @@ const getRouterConfig = () => {
const config = (window as any).woonoowCustomer;
return {
useBrowserRouter: config?.useBrowserRouter ?? true,
basePath: config?.basePath || '/store',
basePath: config?.basePath ?? '/store',
};
};
@@ -124,6 +162,24 @@ function App() {
const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
// Inject gradient CSS variables
React.useEffect(() => {
// appearanceSettings is already the 'data' object from Assets.php injection
// Structure: { general: { colors: { primary, secondary, accent, text, background, gradientStart, gradientEnd } } }
const colors = appearanceSettings?.general?.colors;
if (colors) {
const root = document.documentElement;
// Inject all color settings as CSS variables
if (colors.primary) root.style.setProperty('--wn-primary', colors.primary);
if (colors.secondary) root.style.setProperty('--wn-secondary', colors.secondary);
if (colors.accent) root.style.setProperty('--wn-accent', colors.accent);
if (colors.text) root.style.setProperty('--wn-text', colors.text);
if (colors.background) root.style.setProperty('--wn-background', colors.background);
if (colors.gradientStart) root.style.setProperty('--wn-gradient-start', colors.gradientStart);
if (colors.gradientEnd) root.style.setProperty('--wn-gradient-end', colors.gradientEnd);
}
}, [appearanceSettings]);
return (
<HelmetProvider>
<QueryClientProvider client={queryClient}>

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
interface SharedContentProps {
// Content
title?: string;
text?: string; // HTML content
// Image
image?: string;
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
// Layout
containerWidth?: 'full' | 'contained';
// Styles
className?: string;
titleStyle?: React.CSSProperties;
titleClassName?: string;
textStyle?: React.CSSProperties;
textClassName?: string;
headingStyle?: React.CSSProperties; // For prose headings override
imageStyle?: React.CSSProperties;
// Pro Features (for future)
buttons?: Array<{ text: string, url: string }>;
buttonStyle?: { classNames?: string; style?: React.CSSProperties };
}
export const SharedContentLayout: React.FC<SharedContentProps> = ({
title,
text,
image,
imagePosition = 'left',
containerWidth = 'contained',
className,
titleStyle,
titleClassName,
textStyle,
textClassName,
headingStyle,
buttons,
imageStyle,
buttonStyle
}) => {
const hasImage = !!image;
const isImageLeft = imagePosition === 'left';
const isImageRight = imagePosition === 'right';
const isImageTop = imagePosition === 'top';
const isImageBottom = imagePosition === 'bottom';
// Wrapper classes
const containerClasses = cn(
'w-full mx-auto px-4 sm:px-6 lg:px-8',
containerWidth === 'contained' ? 'max-w-7xl' : ''
);
const gridClasses = cn(
'mx-auto',
hasImage && (isImageLeft || isImageRight) ? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center' : 'max-w-4xl'
);
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
const proseStyle = {
...textStyle,
'--tw-prose-headings': headingStyle?.color,
'--tw-prose-body': textStyle?.color,
} as React.CSSProperties;
return (
<div className={containerClasses}>
<div className={gridClasses}>
{/* Image Side */}
{hasImage && (
<div className={cn(
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
imageWrapperOrder,
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
)} style={imageStyle}>
<img
src={image}
alt={title || 'Section Image'}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
{/* Content Side */}
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
{title && (
<h2
className={cn(
"tracking-tight text-current mb-6",
!titleClassName && "text-3xl font-bold sm:text-4xl",
titleClassName
)}
style={titleStyle}
>
{title}
</h2>
)}
{text && (
<div
className={cn(
'prose prose-lg max-w-none',
'prose-h1:text-3xl prose-h1:font-bold prose-h1:mt-4 prose-h1:mb-2',
'prose-h2:text-2xl prose-h2:font-bold prose-h2:mt-3 prose-h2:mb-2',
'prose-headings:text-[var(--tw-prose-headings)]',
'prose-p:text-[var(--tw-prose-body)]',
'text-[var(--tw-prose-body)]',
className,
textClassName
)}
style={proseStyle}
dangerouslySetInnerHTML={{ __html: text }}
/>
)}
{/* Buttons */}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-wrap gap-4">
{buttons.map((btn, idx) => (
btn.text && btn.url && (
<a
key={idx}
href={btn.url}
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
!buttonStyle?.style?.backgroundColor && "bg-primary",
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
buttonStyle?.classNames
)}
style={buttonStyle?.style}
>
{btn.text}
</a>
)
))}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,86 @@
import React from 'react';
interface SubscriptionData {
id: number;
status: string;
billing_period: string;
billing_interval: number;
start_date: string;
next_payment_date: string | null;
end_date: string | null;
}
interface Props {
subscription: SubscriptionData;
}
const SubscriptionTimeline: React.FC<Props> = ({ subscription }) => {
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const isMonth = subscription.billing_period === 'month';
const intervalLabel = `${subscription.billing_interval} ${subscription.billing_period}${subscription.billing_interval > 1 ? 's' : ''}`;
return (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-6">Subscription Timeline</h2>
<div className="relative">
{/* Connecting Line */}
<div className="absolute top-4 left-4 right-4 h-0.5 bg-gray-200" aria-hidden="true" />
<div className="relative flex justify-between">
{/* Start Node */}
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-green-100 border-2 border-green-500 flex items-center justify-center z-10 shrink-0">
<div className="w-2.5 h-2.5 rounded-full bg-green-500" />
</div>
<div className="mt-3 text-center">
<div className="text-sm font-medium text-gray-900">Started</div>
<div className="text-xs text-gray-500">{formatDate(subscription.start_date)}</div>
</div>
</div>
{/* Middle Info (Interval) */}
<div className="hidden sm:flex flex-col items-center justify-start pt-1">
<div className="bg-white px-2 text-xs text-gray-400 font-medium uppercase tracking-wider">
Every {intervalLabel}
</div>
</div>
{/* Current Payment (Active/Due) Node */}
<div className="flex flex-col items-center">
<div className="relative w-8 h-8 rounded-full bg-blue-100 border-2 border-blue-600 flex items-center justify-center z-10 shrink-0 animate-pulse-ring">
{/* Pulse Effect */}
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-20 animate-ping"></span>
<div className="w-2.5 h-2.5 rounded-full bg-blue-600" />
</div>
<div className="mt-3 text-center">
<div className="text-sm font-medium text-blue-700 font-bold">Payment Due</div>
<div className="text-xs text-blue-600 font-medium">Now</div>
</div>
</div>
{/* Next Future Node */}
<div className="flex flex-col items-center opacity-60">
<div className="w-8 h-8 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center z-10 shrink-0">
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
</div>
<div className="mt-3 text-center">
<div className="text-sm font-medium text-gray-500">Next Renewal</div>
<div className="text-xs text-gray-400">{subscription.next_payment_date ? formatDate(subscription.next_payment_date) : '...'}</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SubscriptionTimeline;

View File

@@ -68,7 +68,7 @@ export function SearchableSelect({
type="button"
role="combobox"
className={cn(
"w-full flex items-center justify-between border !rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:border-gray-400",
"w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20",
disabled && "opacity-50 cursor-not-allowed",
className
)}

View File

@@ -37,25 +37,35 @@ interface AppearanceSettings {
thankyou: any;
account: any;
};
menus: {
primary: Array<{
id: string;
label: string;
type: 'page' | 'custom';
value: string;
target: '_self' | '_blank';
}>;
mobile: Array<any>;
};
}
export function useAppearanceSettings() {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
// Get preloaded settings from window object
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
return useQuery<AppearanceSettings>({
queryKey: ['appearance-settings'],
queryFn: async () => {
const response = await fetch(`${apiRoot}/appearance/settings`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch appearance settings');
}
const data = await response.json();
return data.data;
},
@@ -68,7 +78,7 @@ export function useAppearanceSettings() {
export function useShopSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
layout: {
grid_columns: '3' as string,
@@ -93,7 +103,7 @@ export function useShopSettings() {
show_icon: true,
},
};
return {
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
@@ -105,7 +115,7 @@ export function useShopSettings() {
export function useProductSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
layout: {
image_position: 'left' as string,
@@ -127,7 +137,7 @@ export function useProductSettings() {
hide_if_empty: true,
},
};
return {
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
@@ -139,7 +149,7 @@ export function useProductSettings() {
export function useCartSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
layout: {
style: 'fullwidth' as string,
@@ -152,7 +162,7 @@ export function useCartSettings() {
shipping_calculator: false,
},
};
return {
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
@@ -162,7 +172,7 @@ export function useCartSettings() {
export function useCheckoutSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
layout: {
style: 'two-column' as string,
@@ -178,7 +188,7 @@ export function useCheckoutSettings() {
payment_icons: true,
},
};
return {
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
@@ -188,7 +198,7 @@ export function useCheckoutSettings() {
export function useThankYouSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
template: 'basic',
header_visibility: 'show',
@@ -201,7 +211,7 @@ export function useThankYouSettings() {
related_products: false,
},
};
return {
template: data?.pages?.thankyou?.template || defaultSettings.template,
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
@@ -215,7 +225,7 @@ export function useThankYouSettings() {
export function useAccountSettings() {
const { data, isLoading } = useAppearanceSettings();
const defaultSettings = {
layout: {
navigation_style: 'sidebar' as string,
@@ -228,7 +238,7 @@ export function useAccountSettings() {
account_details: true,
},
};
return {
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
@@ -238,7 +248,7 @@ export function useAccountSettings() {
export function useHeaderSettings() {
const { data, isLoading } = useAppearanceSettings();
return {
style: data?.header?.style ?? 'classic',
sticky: data?.header?.sticky ?? true,
@@ -261,7 +271,7 @@ export function useHeaderSettings() {
export function useFooterSettings() {
const { data, isLoading } = useAppearanceSettings();
return {
columns: data?.footer?.columns ?? '4',
style: data?.footer?.style ?? 'detailed',
@@ -291,6 +301,19 @@ export function useFooterSettings() {
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
},
payment: data?.footer?.payment,
copyright: data?.footer?.copyright,
isLoading,
};
};
export function useMenuSettings() {
const { data, isLoading } = useAppearanceSettings();
return {
primary: data?.menus?.primary ?? [],
mobile: data?.menus?.mobile ?? [],
isLoading,
};
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
@@ -19,114 +20,115 @@ interface WishlistItem {
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const [guestIds, setGuestIds] = useState<Set<number>>(new Set());
// Check if wishlist is enabled (default true if not explicitly set to false)
const settings = (window as any).woonoowCustomer?.settings;
const isEnabled = settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load guest wishlist from localStorage
const loadGuestWishlist = useCallback(() => {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const guestIds = JSON.parse(stored) as number[];
setProductIds(new Set(guestIds));
// Load guest wishlist on mount
useEffect(() => {
if (!isLoggedIn) {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const ids = JSON.parse(stored) as number[];
setGuestIds(new Set(ids));
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
}, []);
}, [isLoggedIn]);
// Save guest wishlist to localStorage
// Save guest wishlist helper
const saveGuestWishlist = useCallback((ids: Set<number>) => {
try {
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
setGuestIds(ids);
} catch (error) {
console.error('Failed to save guest wishlist:', error);
}
}, []);
// Load wishlist on mount
useEffect(() => {
if (isEnabled) {
if (isLoggedIn) {
loadWishlist();
} else {
loadGuestWishlist();
}
}
}, [isEnabled, isLoggedIn]);
// Fetch wishlist items (Server)
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
queryKey: ['wishlist'],
queryFn: async () => {
return await api.get<WishlistItem[]>('/account/wishlist');
},
enabled: isEnabled && isLoggedIn,
staleTime: 60 * 1000, // 1 minute cache
retry: 1,
});
const loadWishlist = useCallback(async () => {
if (!isLoggedIn) return;
try {
setIsLoading(true);
const data = await api.get<WishlistItem[]>('/account/wishlist');
setItems(data);
setProductIds(new Set(data.map(item => item.product_id)));
} catch (error) {
console.error('Failed to load wishlist:', error);
} finally {
setIsLoading(false);
}
}, [isLoggedIn]);
// Calculate merged state
const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere
const isLoading = isLoggedIn ? isServerLoading : false;
const addToWishlist = useCallback(async (productId: number) => {
// Guest mode: store in localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
newIds.add(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
// Compute set of IDs for O(1) lookup
const productIds = useMemo(() => {
if (isLoggedIn) {
return new Set(serverItems.map(item => item.product_id));
}
return guestIds;
}, [isLoggedIn, serverItems, guestIds]);
// Mutations
const addMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.post('/account/wishlist', { product_id: productId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Added to wishlist');
return true;
}
// Logged in: use API
try {
await api.post('/account/wishlist', { product_id: productId });
await loadWishlist(); // Reload to get full product details
toast.success('Added to wishlist');
return true;
} catch (error: any) {
},
onError: (error: any) => {
const message = error?.message || 'Failed to add to wishlist';
toast.error(message);
return false;
}
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
});
const removeMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.delete(`/account/wishlist/${productId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Removed from wishlist');
},
onError: () => {
toast.error('Failed to remove from wishlist');
}
});
const addToWishlist = useCallback(async (productId: number) => {
if (!isLoggedIn) {
const newIds = new Set(guestIds);
newIds.add(productId);
saveGuestWishlist(newIds);
toast.success('Added to wishlist');
return true;
}
await addMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]);
const removeFromWishlist = useCallback(async (productId: number) => {
// Guest mode: remove from localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
const newIds = new Set(guestIds);
newIds.delete(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
toast.success('Removed from wishlist');
return true;
}
// Logged in: use API
try {
await api.delete(`/account/wishlist/${productId}`);
setItems(items.filter(item => item.product_id !== productId));
setProductIds(prev => {
const newSet = new Set(prev);
newSet.delete(productId);
return newSet;
});
toast.success('Removed from wishlist');
return true;
} catch (error) {
toast.error('Failed to remove from wishlist');
return false;
}
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
await removeMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
const toggleWishlist = useCallback(async (productId: number) => {
if (productIds.has(productId)) {
@@ -145,12 +147,12 @@ export function useWishlist() {
isLoading,
isEnabled,
isLoggedIn,
count: items.length,
count: productIds.size,
productIds,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
refresh: loadWishlist,
refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
};
}

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { Search, ShoppingCart, User, Menu, X, Heart } from 'lucide-react';
import { useLayout } from '../contexts/ThemeContext';
import { useCartStore } from '../lib/cart/store';
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
import { useHeaderSettings, useFooterSettings, useMenuSettings } from '../hooks/useAppearanceSettings';
import { SearchModal } from '../components/SearchModal';
import { NewsletterForm } from '../components/NewsletterForm';
import { LayoutWrapper } from './LayoutWrapper';
@@ -51,6 +51,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist');
const footerSettings = useFooterSettings();
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
@@ -74,7 +75,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{/* Logo */}
{headerSettings.elements.logo && (
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
<Link to="/shop" className="flex items-center gap-3 group">
<Link to="/" className="flex items-center gap-3 group">
{storeLogo ? (
<img
src={storeLogo}
@@ -103,9 +104,24 @@ function ClassicLayout({ children }: BaseLayoutProps) {
{/* Navigation */}
{headerSettings.elements.navigation && (
<nav className="hidden md:flex items-center space-x-8">
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
</>
)}
</nav>
)}
@@ -177,9 +193,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav>
)}
</div>
@@ -198,9 +218,13 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</div>
{headerSettings.elements.navigation && (
<nav className="flex flex-col p-4">
<Link to="/shop" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Shop</Link>
<a href="/about" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">About</a>
<a href="/contact" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Contact</a>
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} onClick={() => setMobileMenuOpen(false)} target={item.target} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">{item.label}</a>
)
))}
</nav>
)}
</div>
@@ -326,21 +350,38 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</div>
{/* Payment Icons */}
{footerSettings.elements.payment && (
{(footerSettings.payment ? footerSettings.payment.enabled : footerSettings.elements.payment) && (
<div className="mt-8 pt-8 border-t">
<p className="text-xs text-gray-500 text-center mb-4">We accept</p>
<div className="flex justify-center gap-4 text-gray-400">
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
<p className="text-xs text-gray-500 text-center mb-4">
{footerSettings.payment?.title || 'We accept'}
</p>
<div className="flex justify-center gap-4 text-gray-400 items-center">
{footerSettings.payment?.methods && footerSettings.payment.methods.length > 0 ? (
footerSettings.payment.methods.map((method: any) => (
<div key={method.id} title={method.label}>
{method.url ? (
<img src={method.url} alt={method.label} className="h-6 w-auto object-contain" />
) : (
<span className="text-xs">💳 {method.label}</span>
)}
</div>
))
) : (
// Fallback for legacy or empty methods
<>
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
</>
)}
</div>
</div>
)}
{/* Copyright */}
{footerSettings.elements.copyright && (
{(footerSettings.copyright ? footerSettings.copyright.enabled : footerSettings.elements.copyright) && (
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
{footerSettings.copyright_text}
{footerSettings.copyright?.text || footerSettings.copyright_text}
</div>
)}
</div>
@@ -367,6 +408,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
const headerSettings = useHeaderSettings();
const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist');
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
@@ -381,7 +423,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
<div className={`flex flex-col items-center ${paddingClass}`}>
{/* Logo - Centered */}
{headerSettings.elements.logo && (
<Link to="/shop" className="mb-4">
<Link to="/" className="mb-4">
{storeLogo ? (
<img
src={storeLogo}
@@ -404,9 +446,24 @@ function ModernLayout({ children }: BaseLayoutProps) {
<nav className="hidden md:flex items-center space-x-8">
{headerSettings.elements.navigation && (
<>
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
</>
)}
</>
)}
{headerSettings.elements.search && (
@@ -456,9 +513,13 @@ function ModernLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav>
)}
</div>
@@ -503,6 +564,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
const headerSettings = useHeaderSettings();
const { isEnabled } = useModules();
const { settings: wishlistSettings } = useModuleSettings('wishlist');
const { primary: primaryMenu, mobile: mobileMenu } = useMenuSettings();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
@@ -520,7 +582,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{headerSettings.elements.logo && (
<div className="flex-shrink-0">
<Link to="/shop">
<Link to="/">
{storeLogo ? (
<img
src={storeLogo}
@@ -543,7 +605,24 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
{(headerSettings.elements.navigation || hasActions) && (
<nav className="hidden md:flex items-center space-x-8">
{headerSettings.elements.navigation && (
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
<>
{primaryMenu.length > 0 ? (
primaryMenu.map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">{item.label}</a>
)
))
) : (
<>
{(window as any).woonoowCustomer?.frontPageSlug && (
<Link to="/" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Home</Link>
)}
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
</>
)}
</>
)}
{headerSettings.elements.search && (
<button
@@ -591,7 +670,13 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
<div className="md:hidden border-t py-4">
{headerSettings.elements.navigation && (
<nav className="flex flex-col space-y-2 mb-4">
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
{(mobileMenu.length > 0 ? mobileMenu : primaryMenu).map(item => (
item.type === 'page' ? (
<Link key={item.id} to={`/${item.value.replace(/^\/+/, '')}`} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</Link>
) : (
<a key={item.id} href={item.value} target={item.target} className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">{item.label}</a>
)
))}
</nav>
)}
</div>
@@ -656,7 +741,7 @@ function LaunchLayout({ children }: BaseLayoutProps) {
<div className="container mx-auto px-4">
<div className={`flex items-center justify-center ${heightClass}`}>
{headerSettings.elements.logo && (
<Link to="/shop">
<Link to="/">
{storeLogo ? (
<img
src={storeLogo}

View File

@@ -0,0 +1,289 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Shield, Globe, Key, Check, X, Loader2, AlertTriangle } from 'lucide-react';
export default function LicenseConnect() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [licenseInfo, setLicenseInfo] = useState<any>(null);
// Get params from URL
const licenseKey = searchParams.get('license_key') || '';
const siteUrl = searchParams.get('site_url') || '';
const returnUrl = searchParams.get('return_url') || '';
const state = searchParams.get('state') || '';
const nonce = searchParams.get('nonce') || '';
// Get site name from window
const siteName = (window as any).woonoowCustomer?.siteName || 'WooNooW';
// Validate and load license info
useEffect(() => {
if (!licenseKey || !siteUrl || !state) {
setError('Invalid license connection request. Missing required parameters.');
return;
}
const loadLicenseInfo = async () => {
setLoading(true);
try {
const response = await api.get(`/licenses/oauth/validate?license_key=${encodeURIComponent(licenseKey)}&state=${encodeURIComponent(state)}`);
setLicenseInfo(response);
} catch (err: any) {
setError(err.message || 'Failed to validate license connection request.');
} finally {
setLoading(false);
}
};
loadLicenseInfo();
}, [licenseKey, siteUrl, state]);
// Handle confirmation
const handleConfirm = async () => {
setConfirming(true);
setError(null);
try {
const response = await api.post<{ success?: boolean; redirect_url?: string }>('/licenses/oauth/confirm', {
license_key: licenseKey,
site_url: siteUrl,
state: state,
nonce: nonce,
});
if (response.success && response.redirect_url) {
// Redirect to return URL with activation token
window.location.href = response.redirect_url;
} else {
setSuccess(true);
}
} catch (err: any) {
setError(err.message || 'Failed to confirm license activation.');
} finally {
setConfirming(false);
}
};
// Handle cancel
const handleCancel = () => {
if (returnUrl) {
window.location.href = `${returnUrl}?error=cancelled&message=User%20cancelled%20the%20license%20activation`;
} else {
navigate('/my-account/licenses');
}
};
// Full-page focused container
const PageWrapper = ({ children }: { children: React.ReactNode }) => (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
{/* Minimal header with brand */}
<header className="py-6 px-8 flex justify-center">
<div className="text-xl font-bold text-slate-900">{siteName}</div>
</header>
{/* Centered content */}
<main className="flex-1 flex items-center justify-center px-4 pb-12">
{children}
</main>
{/* Minimal footer */}
<footer className="py-4 text-center text-sm text-slate-500">
Secure License Activation
</footer>
</div>
);
// Render error state (when no license info is loaded)
if (error && !licenseInfo) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<X className="h-8 w-8 text-red-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
Connection Error
</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm text-center">
{error}
</div>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
Back to My Account
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render loading state
if (loading) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-12 flex flex-col items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-blue-600 mb-4" />
<p className="text-slate-600 font-medium">Validating license request...</p>
<p className="text-slate-400 text-sm mt-1">Please wait</p>
</div>
</div>
</PageWrapper>
);
}
// Render success state
if (success) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-8 w-8 text-green-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
License Activated!
</h1>
<p className="text-slate-600 text-center">
Your license has been successfully activated for the specified site.
</p>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
View My Licenses
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render confirmation page
return (
<PageWrapper>
<div className="w-full max-w-lg">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="p-8 text-center border-b bg-gradient-to-b from-blue-50 to-white">
<div className="flex justify-center mb-4">
<div className="h-20 w-20 rounded-full bg-blue-100 flex items-center justify-center">
<Shield className="h-10 w-10 text-blue-600" />
</div>
</div>
<h1 className="text-2xl font-bold text-slate-900">Activate Your License</h1>
<p className="text-slate-500 mt-2">
A site is requesting to activate your license
</p>
</div>
{/* Content */}
<div className="p-8 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
{error}
</div>
)}
{/* License Info Cards */}
<div className="space-y-3">
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Key className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">License Key</p>
<p className="text-slate-500 font-mono text-sm truncate">{licenseKey}</p>
</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Requesting Site</p>
<p className="text-slate-500 text-sm truncate">{siteUrl}</p>
</div>
</div>
{licenseInfo?.product_name && (
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Shield className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Product</p>
<p className="text-slate-500 text-sm">{licenseInfo.product_name}</p>
</div>
</div>
)}
</div>
{/* Warning */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
By confirming, you authorize this site to use your license.
Only confirm if you trust the requesting site.
</p>
</div>
</div>
{/* Footer Actions */}
<div className="px-8 py-5 bg-slate-50 border-t flex gap-3">
<Button
variant="outline"
className="flex-1 h-12"
onClick={handleCancel}
disabled={confirming}
>
Deny
</Button>
<Button
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700"
onClick={handleConfirm}
disabled={confirming}
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Activating...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Authorize
</>
)}
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}

View File

@@ -115,9 +115,19 @@ export default function OrderDetails() {
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
{order.status.replace('-', ' ').toUpperCase()}
</span>
<div className="flex items-center gap-3">
{['pending', 'failed', 'on-hold'].includes(order.status) && (
<Link
to={`/checkout/pay/${order.id}`}
className="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
>
Pay Now
</Link>
)}
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
{order.status.replace('-', ' ').toUpperCase()}
</span>
</div>
</div>
<div className="text-sm text-gray-600 mb-6">

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { ArrowLeft, Repeat, Pause, Play, XCircle, Calendar, Package, CreditCard, FileText } from 'lucide-react';
import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency';
interface SubscriptionOrder {
id: number;
order_id: number;
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
order_status: string;
created_at: string;
}
interface Subscription {
id: number;
product_id: number;
product_name: string;
product_image: string;
status: string;
billing_period: string;
billing_interval: number;
billing_schedule: string;
recurring_amount: string;
start_date: string;
trial_end_date: string | null;
next_payment_date: string | null;
end_date: string | null;
last_payment_date: string | null;
payment_method: string;
pause_count: number;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
orders: SubscriptionOrder[];
}
const statusStyles: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
};
const statusLabels: Record<string, string> = {
'pending': 'Pending',
'active': 'Active',
'on-hold': 'On Hold',
'cancelled': 'Cancelled',
'expired': 'Expired',
'pending-cancel': 'Pending Cancel',
};
const orderTypeLabels: Record<string, string> = {
'parent': 'Initial Order',
'renewal': 'Renewal',
'switch': 'Plan Switch',
'resubscribe': 'Resubscribe',
};
export default function SubscriptionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [actionLoading, setActionLoading] = useState(false);
const { data: subscription, isLoading, error } = useQuery<Subscription>({
queryKey: ['account-subscription', id],
queryFn: () => api.get(`/account/subscriptions/${id}`),
enabled: !!id,
});
const actionMutation = useMutation({
mutationFn: (action: string) => api.post(`/account/subscriptions/${id}/${action}`),
onSuccess: (_, action) => {
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
const actionLabels: Record<string, string> = {
'pause': 'paused',
'resume': 'resumed',
'cancel': 'cancelled',
};
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
setActionLoading(false);
},
onError: (error: any) => {
toast.error(error.message || 'Action failed');
setActionLoading(false);
},
});
const handleAction = (action: string) => {
setActionLoading(true);
actionMutation.mutate(action);
};
const handleRenew = async () => {
setActionLoading(true);
try {
const response = await api.post<{ order_id: number; status: string }>(`/account/subscriptions/${id}/renew`);
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
toast.success('Renewal order created');
if (response.order_id) {
// Determine destination based on functionality
// If manual payment required or just improved UX, go to payment page
navigate(`/order-pay/${response.order_id}`);
}
} catch (error: any) {
toast.error(error.message || 'Failed to renew');
} finally {
setActionLoading(false);
}
};
// Find pending renewal order
const pendingRenewalOrder = subscription?.orders?.find(
o => o.order_type === 'renewal' &&
['pending', 'wc-pending', 'on-hold', 'wc-on-hold', 'failed', 'wc-failed'].includes(o.order_status)
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !subscription) {
return (
<div className="text-center py-12">
<p className="text-red-500">Failed to load subscription</p>
<Button variant="outline" className="mt-4" onClick={() => navigate('/my-account/subscriptions')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Subscriptions
</Button>
</div>
);
}
return (
<div className="space-y-6">
<SEOHead title={`Subscription #${subscription.id}`} description="Subscription details" />
{/* Back button */}
<Link
to="/my-account/subscriptions"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Subscriptions
</Link>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Repeat className="h-6 w-6" />
Subscription #{subscription.id}
</h1>
<p className="text-gray-500 mt-1">
Started {new Date(subscription.start_date).toLocaleDateString()}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
{statusLabels[subscription.status] || subscription.status}
</span>
</div>
{/* Product Info Card */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-start gap-4">
{subscription.product_image ? (
<img
src={subscription.product_image}
alt={subscription.product_name}
className="w-20 h-20 object-cover rounded"
/>
) : (
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
<Package className="h-10 w-10 text-gray-400" />
</div>
)}
<div className="flex-1">
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
<p className="text-gray-500">{subscription.billing_schedule}</p>
<p className="text-2xl font-bold mt-2">
{formatPrice(subscription.recurring_amount)}
<span className="text-sm font-normal text-gray-500">
/{subscription.billing_period}
</span>
</p>
</div>
</div>
</div>
{/* Billing Details */}
<div className="bg-white rounded-lg border p-6">
<h3 className="font-semibold mb-4">Billing Details</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Start Date</p>
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
</div>
{subscription.next_payment_date && (
<div>
<p className="text-sm text-gray-500">Next Payment</p>
<p className="font-medium flex items-center gap-1">
<Calendar className="h-4 w-4 text-gray-400" />
{new Date(subscription.next_payment_date).toLocaleDateString()}
</p>
</div>
)}
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
<div>
<p className="text-sm text-gray-500">Trial Ends</p>
<p className="font-medium text-blue-600">
{new Date(subscription.trial_end_date).toLocaleDateString()}
</p>
</div>
)}
{subscription.end_date && (
<div>
<p className="text-sm text-gray-500">End Date</p>
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
</div>
)}
<div>
<p className="text-sm text-gray-500">Payment Method</p>
<p className="font-medium flex items-center gap-1">
<CreditCard className="h-4 w-4 text-gray-400" />
{subscription.payment_method || 'Not set'}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Times Paused</p>
<p className="font-medium">{subscription.pause_count}</p>
</div>
</div>
</div>
{/* Actions */}
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
<div className="bg-white rounded-lg border p-6">
<h3 className="font-semibold mb-4">Manage Subscription</h3>
<div className="flex items-center gap-3">
{subscription.can_pause && (
<Button
variant="outline"
onClick={() => handleAction('pause')}
disabled={actionLoading}
>
<Pause className="h-4 w-4 mr-2" />
Pause Subscription
</Button>
)}
{subscription.can_resume && (
<Button
variant="outline"
onClick={() => handleAction('resume')}
disabled={actionLoading}
>
<Play className="h-4 w-4 mr-2" />
Resume Subscription
</Button>
)}
{/* Early Renewal Button */}
{subscription.status === 'active' && (
<Button
variant="outline"
onClick={handleRenew}
disabled={actionLoading}
>
<Repeat className="h-4 w-4 mr-2" />
Renew Early
</Button>
)}
{/* Pay Pending Order Button */}
{pendingRenewalOrder && (
<Button
className='bg-green-600 hover:bg-green-700'
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
>
<CreditCard className="h-4 w-4 mr-2" />
Pay Now (#{pendingRenewalOrder.order_id})
</Button>
)}
{subscription.can_cancel && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="text-red-500 hover:text-red-600 border-red-200 hover:border-red-300"
disabled={actionLoading}
>
<XCircle className="h-4 w-4 mr-2" />
Cancel Subscription
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this subscription?
You will lose access at the end of your current billing period.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleAction('cancel')}
className="bg-red-500 hover:bg-red-600"
>
Yes, Cancel
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)}
{/* Related Orders */}
{subscription.orders && subscription.orders.length > 0 && (
<div className="bg-white rounded-lg border p-6">
<h3 className="font-semibold mb-4 flex items-center gap-2">
<FileText className="h-5 w-5" />
Payment History
</h3>
<div className="space-y-2">
{subscription.orders.map((order) => (
<Link
key={order.id}
to={`/my-account/orders/${order.order_id}`}
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="font-medium">Order #{order.order_id}</span>
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
{orderTypeLabels[order.order_type] || order.order_type}
</span>
</div>
<div className="text-sm text-gray-500">
{new Date(order.created_at).toLocaleDateString()}
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Repeat, ChevronRight, Pause, Play, XCircle, Calendar, Package } from 'lucide-react';
import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency';
interface Subscription {
id: number;
product_id: number;
product_name: string;
product_image: string;
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
billing_schedule: string;
recurring_amount: string;
next_payment_date: string | null;
start_date: string;
end_date: string | null;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
}
const statusStyles: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
};
const statusLabels: Record<string, string> = {
'pending': 'Pending',
'active': 'Active',
'on-hold': 'On Hold',
'cancelled': 'Cancelled',
'expired': 'Expired',
'pending-cancel': 'Pending Cancel',
};
export default function Subscriptions() {
const queryClient = useQueryClient();
const [actionLoading, setActionLoading] = useState<number | null>(null);
const { data: subscriptions = [], isLoading } = useQuery<Subscription[]>({
queryKey: ['account-subscriptions'],
queryFn: () => api.get('/account/subscriptions'),
});
const actionMutation = useMutation({
mutationFn: ({ id, action }: { id: number; action: string }) =>
api.post(`/account/subscriptions/${id}/${action}`),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
const actionLabels: Record<string, string> = {
'pause': 'paused',
'resume': 'resumed',
'cancel': 'cancelled',
};
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
setActionLoading(null);
},
onError: (error: any) => {
toast.error(error.message || 'Action failed');
setActionLoading(null);
},
});
const handleAction = (id: number, action: string) => {
setActionLoading(id);
actionMutation.mutate({ id, action });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<SEOHead title="My Subscriptions" description="Manage your subscriptions" />
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Repeat className="h-6 w-6" />
My Subscriptions
</h1>
<p className="text-gray-500">
Manage your recurring subscriptions
</p>
</div>
{subscriptions.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Repeat className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">You don't have any subscriptions yet.</p>
<p className="text-sm text-gray-400 mt-1">
Purchase a subscription product to get started.
</p>
</div>
) : (
<div className="space-y-4">
{subscriptions.map((sub) => (
<div key={sub.id} className="bg-white rounded-lg border overflow-hidden">
<div className="p-4">
<div className="flex items-start gap-4">
{/* Product Image */}
{sub.product_image ? (
<img
src={sub.product_image}
alt={sub.product_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center">
<Package className="h-8 w-8 text-gray-400" />
</div>
)}
{/* Subscription Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{sub.product_name}</h3>
<p className="text-sm text-gray-500">{sub.billing_schedule}</p>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusStyles[sub.status] || 'bg-gray-100'}`}>
{statusLabels[sub.status] || sub.status}
</span>
</div>
<div className="mt-3 flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500">Amount: </span>
<span className="font-medium">
{formatPrice(sub.recurring_amount)}
</span>
</div>
{sub.next_payment_date && (
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4 text-gray-400" />
<span className="text-gray-500">Next: </span>
<span className="font-medium">
{new Date(sub.next_payment_date).toLocaleDateString()}
</span>
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="mt-4 pt-4 border-t flex items-center justify-between">
<div className="flex items-center gap-2">
{sub.can_pause && (
<Button
variant="outline"
size="sm"
onClick={() => handleAction(sub.id, 'pause')}
disabled={actionLoading === sub.id}
>
<Pause className="h-4 w-4 mr-1" />
Pause
</Button>
)}
{sub.can_resume && (
<Button
variant="outline"
size="sm"
onClick={() => handleAction(sub.id, 'resume')}
disabled={actionLoading === sub.id}
>
<Play className="h-4 w-4 mr-1" />
Resume
</Button>
)}
{sub.can_cancel && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600"
disabled={actionLoading === sub.id}
>
<XCircle className="h-4 w-4 mr-1" />
Cancel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this subscription?
You will lose access at the end of your current billing period.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleAction(sub.id, 'cancel')}
className="bg-red-500 hover:bg-red-600"
>
Yes, Cancel
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<Link
to={`/my-account/subscriptions/${sub.id}`}
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
View Details
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { ReactNode, useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key, Repeat } from 'lucide-react';
import { useModules } from '@/hooks/useModules';
import { api } from '@/lib/api/client';
import {
@@ -50,17 +50,19 @@ export function AccountLayout({ children }: AccountLayoutProps) {
const allMenuItems = [
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
{ id: 'subscriptions', label: 'Subscriptions', path: '/my-account/subscriptions', icon: Repeat },
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
];
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
const menuItems = allMenuItems.filter(item => {
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
if (item.id === 'licenses') return isEnabled('licensing');
if (item.id === 'subscriptions') return isEnabled('subscription');
return true;
});

View File

@@ -10,6 +10,9 @@ import Addresses from './Addresses';
import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails';
import Licenses from './Licenses';
import LicenseConnect from './LicenseConnect';
import Subscriptions from './Subscriptions';
import SubscriptionDetail from './SubscriptionDetail';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
@@ -17,10 +20,15 @@ export default function Account() {
// Redirect to login if not authenticated
if (!user?.isLoggedIn) {
const currentPath = location.pathname;
const currentPath = location.pathname + location.search;
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
}
// Check if this is the license-connect route (render without AccountLayout)
if (location.pathname.includes('/license-connect')) {
return <LicenseConnect />;
}
return (
<Container>
<AccountLayout>
@@ -32,6 +40,8 @@ export default function Account() {
<Route path="addresses" element={<Addresses />} />
<Route path="wishlist" element={<Wishlist />} />
<Route path="licenses" element={<Licenses />} />
<Route path="subscriptions" element={<Subscriptions />} />
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>
@@ -39,4 +49,3 @@ export default function Account() {
</Container>
);
}

View File

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

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Helmet } from 'react-helmet-async';
// Section Components
import { HeroSection } from './sections/HeroSection';
import { ContentSection } from './sections/ContentSection';
import { ImageTextSection } from './sections/ImageTextSection';
import { FeatureGridSection } from './sections/FeatureGridSection';
import { CTABannerSection } from './sections/CTABannerSection';
import { ContactFormSection } from './sections/ContactFormSection';
// Types
interface SectionProp {
type: 'static' | 'dynamic';
value?: any;
source?: string;
}
interface SectionStyles {
backgroundColor?: string;
backgroundImage?: string;
backgroundOverlay?: number;
paddingTop?: string;
paddingBottom?: string;
contentWidth?: 'full' | 'contained';
}
interface ElementStyle {
color?: string;
fontSize?: string;
fontWeight?: string;
textAlign?: 'left' | 'center' | 'right';
}
interface Section {
id: string;
type: string;
layoutVariant?: string;
colorScheme?: string;
styles?: SectionStyles;
elementStyles?: Record<string, ElementStyle>;
props: Record<string, SectionProp | any>;
}
interface PageData {
id?: number;
type: 'page' | 'content';
slug?: string;
title?: string;
url?: string;
seo?: {
meta_title?: string;
meta_description?: string;
canonical?: string;
og_title?: string;
og_description?: string;
og_image?: string;
};
container_width?: string;
effective_container_width?: 'boxed' | 'fullwidth';
structure?: {
sections: Section[];
};
post?: Record<string, any>;
rendered?: {
sections: Section[];
};
}
// Section renderer map
const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
hero: HeroSection,
content: ContentSection,
'image-text': ImageTextSection,
'image_text': ImageTextSection,
'feature-grid': FeatureGridSection,
'feature_grid': FeatureGridSection,
'cta-banner': CTABannerSection,
'cta_banner': CTABannerSection,
'contact-form': ContactFormSection,
'contact_form': ContactFormSection,
};
/**
* Flatten section props by extracting .value from {type, value} objects
* This transforms { title: { type: 'static', value: 'Hello' } }
* into { title: 'Hello' }
*/
function flattenSectionProps(props: Record<string, any>): Record<string, any> {
const flattened: Record<string, any> = {};
for (const [key, value] of Object.entries(props)) {
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
// This is a {type, value} prop structure
flattened[key] = value.value;
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
// This is a dynamic prop - use source as placeholder for now
flattened[key] = `[${value.source}]`;
} else {
// Regular value, pass through
flattened[key] = value;
}
}
return flattened;
}
/**
* DynamicPageRenderer
* Renders structural pages and CPT template content
*/
interface DynamicPageRendererProps {
slug?: string;
}
export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps) {
const { pathBase, slug: paramSlug } = useParams<{ pathBase?: string; slug?: string }>();
const navigate = useNavigate();
const [notFound, setNotFound] = useState(false);
// Use prop slug if provided, otherwise use param slug
const effectiveSlug = propSlug || paramSlug;
// Determine if this is a page or CPT content
// If propSlug is provided, it's treated as a structural page (pathBase is undefined)
const isStructuralPage = !pathBase || !!propSlug;
const contentType = pathBase === 'blog' ? 'post' : pathBase;
const contentSlug = effectiveSlug || '';
// Fetch page/content data
const { data: pageData, isLoading, error } = useQuery<PageData>({
queryKey: ['dynamic-page', pathBase, effectiveSlug],
queryFn: async (): Promise<PageData> => {
if (isStructuralPage) {
// Fetch structural page - api.get returns JSON directly
const response = await api.get<PageData>(`/pages/${contentSlug}`);
return response;
} else {
// Fetch CPT content with template
const response = await api.get<PageData>(`/content/${contentType}/${contentSlug}`);
return response;
}
},
retry: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Handle 404
useEffect(() => {
if (error) {
setNotFound(true);
}
}, [error]);
// Get sections to render
const sections = isStructuralPage
? pageData?.structure?.sections || []
: pageData?.rendered?.sections || [];
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
// 404 state
if (notFound || !pageData) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
<p className="text-gray-600 mb-8">Page not found</p>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Go Home
</button>
</div>
);
}
return (
<>
{/* SEO */}
<Helmet>
<title>{pageData.seo?.meta_title || pageData.title || 'Page'}</title>
{pageData.seo?.meta_description && (
<meta name="description" content={pageData.seo.meta_description} />
)}
{pageData.seo?.canonical && (
<link rel="canonical" href={pageData.seo.canonical} />
)}
{pageData.seo?.og_title && (
<meta property="og:title" content={pageData.seo.og_title} />
)}
{pageData.seo?.og_description && (
<meta property="og:description" content={pageData.seo.og_description} />
)}
{pageData.seo?.og_image && (
<meta property="og:image" content={pageData.seo.og_image} />
)}
</Helmet>
{/* Render sections using effective container width */}
<div className={`wn-page ${pageData.effective_container_width === 'boxed' ? 'container mx-auto px-4 max-w-6xl' : ''}`}>
{sections.map((section) => {
const SectionComponent = SECTION_COMPONENTS[section.type];
if (!SectionComponent) {
// Fallback for unknown section types
return (
<div key={section.id} className={`wn-section wn-${section.type}`}>
<pre className="text-xs text-gray-500">
Unknown section type: {section.type}
</pre>
</div>
);
}
return (
<div
key={section.id}
className={`relative overflow-hidden ${!section.styles?.backgroundColor ? '' : ''}`}
style={{
backgroundColor: section.styles?.backgroundColor,
paddingTop: section.styles?.paddingTop,
paddingBottom: section.styles?.paddingBottom,
}}
>
{/* Background Image & Overlay */}
{section.styles?.backgroundImage && (
<>
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
/>
<div
className="absolute inset-0 z-0 bg-black"
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
/>
</>
)}
{/* Content Wrapper */}
<div className={`relative z-10 ${section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'}`}>
<SectionComponent
id={section.id}
section={section} // Pass full section object for components that need raw data
layout={section.layoutVariant || 'default'}
colorScheme={section.colorScheme || 'default'}
styles={section.styles}
elementStyles={section.elementStyles}
{...flattenSectionProps(section.props || {})}
/>
</div>
</div>
);
})}
{/* Empty state */}
{sections.length === 0 && (
<div className="py-20 text-center text-gray-500">
<p>This page has no content yet.</p>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { cn } from '@/lib/utils';
interface CTABannerSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
text?: string;
button_text?: string;
button_url?: string;
elementStyles?: Record<string, any>;
}
export function CTABannerSection({
id,
layout = 'default',
colorScheme = 'primary',
title,
text,
button_text,
button_url,
elementStyles,
styles,
}: CTABannerSectionProps & { styles?: Record<string, any> }) {
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const btnStyle = getTextStyles('button_text');
return (
<section
id={id}
className={cn(
'wn-section wn-cta-banner',
`wn-cta-banner--${layout}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-20',
{
'bg-primary text-primary-foreground': colorScheme === 'primary',
'bg-secondary text-secondary-foreground': colorScheme === 'secondary',
'bg-gradient-to-r from-primary to-secondary text-white': colorScheme === 'gradient',
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className={cn(
"mx-auto px-4 text-center",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{title && (
<h2
className={cn(
"wn-cta__title mb-6",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
{text && (
<p className={cn(
'wn-cta-banner__text mb-8 max-w-2xl mx-auto',
!elementStyles?.text?.fontSize && "text-lg md:text-xl",
{
'text-white/90': colorScheme === 'primary' || colorScheme === 'gradient',
'text-gray-600': colorScheme === 'muted',
},
textStyle.classNames
)}
style={textStyle.style}
>
{text}
</p>
)}
{button_text && button_url && (
<a
href={button_url}
className={cn(
'wn-cta-banner__button inline-block px-8 py-3 rounded-lg font-semibold transition-all hover:opacity-90',
!btnStyle.style?.backgroundColor && {
'bg-white': colorScheme === 'primary' || colorScheme === 'gradient',
'bg-primary': colorScheme === 'muted' || colorScheme === 'secondary',
},
!btnStyle.style?.color && {
'text-primary': colorScheme === 'primary' || colorScheme === 'gradient',
'text-white': colorScheme === 'muted' || colorScheme === 'secondary',
},
btnStyle.classNames
)}
style={btnStyle.style}
>
{button_text}
</a>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,206 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
interface ContactFormSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
webhook_url?: string;
redirect_url?: string;
fields?: string[];
elementStyles?: Record<string, any>;
}
export function ContactFormSection({
id,
layout = 'default',
colorScheme = 'default',
title,
webhook_url,
redirect_url,
fields = ['name', 'email', 'message'],
elementStyles,
styles,
}: ContactFormSectionProps & { styles?: Record<string, any> }) {
const [formData, setFormData] = useState<Record<string, string>>({});
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const buttonStyle = getTextStyles('button');
const fieldsStyle = getTextStyles('fields');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
// Submit to webhook if provided
if (webhook_url) {
await fetch(webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
}
// Redirect after submission
if (redirect_url) {
// Replace placeholders in redirect URL
let finalUrl = redirect_url;
Object.entries(formData).forEach(([key, value]) => {
finalUrl = finalUrl.replace(`{${key}}`, encodeURIComponent(value));
});
window.location.href = finalUrl;
}
} catch (err) {
setError('Failed to submit form. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<section
id={id}
className={cn(
'wn-section wn-contact-form',
`wn-scheme--${colorScheme}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-20',
{
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
}
)}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
<div className={cn(
'max-w-xl mx-auto',
{
'max-w-2xl': layout === 'wide',
}
)}>
{title && (
<h2 className={cn(
"wn-contact__title text-center mb-12",
!elementStyles?.title?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h2>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{fields.map((field) => {
const fieldLabel = field.charAt(0).toUpperCase() + field.slice(1).replace('_', ' ');
const isTextarea = field === 'message' || field === 'content';
return (
<div key={field} className="wn-contact-form__field">
<label className="block text-sm font-medium text-gray-700 mb-2">
{fieldLabel}
</label>
{isTextarea ? (
<textarea
name={field}
value={formData[field] || ''}
onChange={handleChange}
rows={5}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
) : (
<input
type={field === 'email' ? 'email' : 'text'}
name={field}
value={formData[field] || ''}
onChange={handleChange}
className={cn(
"w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary",
fieldsStyle.classNames
)}
style={{
backgroundColor: fieldsStyle.style?.backgroundColor,
color: fieldsStyle.style?.color,
borderColor: fieldsStyle.style?.borderColor,
borderRadius: fieldsStyle.style?.borderRadius,
}}
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
required
/>
)}
</div>
);
})}
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={submitting}
className={cn(
'w-full py-3 px-6 rounded-lg font-semibold',
'hover:opacity-90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
!buttonStyle.style?.backgroundColor && 'bg-primary',
!buttonStyle.style?.color && 'text-primary-foreground',
buttonStyle.classNames
)}
style={buttonStyle.style}
>
{submitting ? 'Sending...' : 'Submit'}
</button>
</form>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,259 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
interface ContentSectionProps {
section: {
id: string;
layoutVariant?: string;
colorScheme?: string;
props?: {
content?: { value: string };
cta_text?: { value: string };
cta_url?: { value: string };
};
elementStyles?: Record<string, any>;
styles?: Record<string, any>;
};
}
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
default: { bg: 'bg-white', text: 'text-gray-900' },
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
dark: { bg: 'bg-gray-900', text: 'text-white' },
blue: { bg: 'wn-primary-bg', text: 'text-white' },
primary: { bg: 'wn-primary-bg', text: 'text-white' },
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
};
const WIDTH_CLASSES: Record<string, string> = {
default: 'max-w-screen-xl mx-auto',
contained: 'max-w-screen-md mx-auto',
full: 'w-full',
};
const fontSizeToCSS = (className?: string) => {
switch (className) {
case 'text-xs': return '0.75rem';
case 'text-sm': return '0.875rem';
case 'text-base': return '1rem';
case 'text-lg': return '1.125rem';
case 'text-xl': return '1.25rem';
case 'text-2xl': return '1.5rem';
case 'text-3xl': return '1.875rem';
case 'text-4xl': return '2.25rem';
case 'text-5xl': return '3rem';
case 'text-6xl': return '3.75rem';
case 'text-7xl': return '4.5rem';
case 'text-8xl': return '6rem';
case 'text-9xl': return '8rem';
default: return undefined;
}
};
const fontWeightToCSS = (className?: string) => {
switch (className) {
case 'font-thin': return '100';
case 'font-extralight': return '200';
case 'font-light': return '300';
case 'font-normal': return '400';
case 'font-medium': return '500';
case 'font-semibold': return '600';
case 'font-bold': return '700';
case 'font-extrabold': return '800';
case 'font-black': return '900';
default: return undefined;
}
};
// Helper to generate scoped CSS for prose elements
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
const styles: string[] = [];
const scope = `#${sectionId}`; // ContentSection uses id directly on section tag
// Headings (h1-h4)
const hs = elementStyles?.heading;
if (hs) {
const headingRules = [
hs.color && `color: ${hs.color} !important;`,
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
].filter(Boolean).join(' ');
if (headingRules) {
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
}
}
// Body text (p, li)
const ts = elementStyles?.text;
if (ts) {
const textRules = [
ts.color && `color: ${ts.color} !important;`,
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
].filter(Boolean).join(' ');
if (textRules) {
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
}
}
// Explicit Spacing & List Formatting Restorations
styles.push(`
${scope} p { margin-bottom: 1em; }
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
${scope} li { margin-bottom: 0.25em; }
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
`);
// Links (a:not(.button))
const ls = elementStyles?.link;
if (ls) {
const linkRules = [
ls.color && `color: ${ls.color} !important;`,
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
].filter(Boolean).join(' ');
if (linkRules) {
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
}
if (ls.hoverColor) {
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
}
}
// Buttons (a[data-button], .button)
const bs = elementStyles?.button;
if (bs) {
const btnRules = [
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
bs.color && `color: ${bs.color} !important;`,
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
bs.padding && `padding: ${bs.padding} !important;`,
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
].filter(Boolean).join(' ');
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
}
// Images
const is = elementStyles?.image;
if (is) {
const imgRules = [
is.objectFit && `object-fit: ${is.objectFit} !important;`,
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
is.width && `width: ${is.width} !important;`,
is.height && `height: ${is.height} !important;`,
].filter(Boolean).join(' ');
if (imgRules) {
styles.push(`${scope} img { ${imgRules} }`);
}
}
return styles.join('\n');
};
export function ContentSection({ section }: ContentSectionProps) {
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
// Default to 'default' width if not specified
const layout = section.layoutVariant || 'default';
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
const heightPreset = section.styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-20',
'small': 'py-8 md:py-12',
'medium': 'py-16 md:py-24',
'large': 'py-24 md:py-32',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
const content = section.props?.content?.value || '';
// Helper to get text styles
const getTextStyles = (elementName: string) => {
const styles = section.elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor
}
};
};
const contentStyle = getTextStyles('content');
const headingStyle = getTextStyles('heading');
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const containerWidth = section.styles?.contentWidth || 'contained';
const cta_text = section.props?.cta_text?.value;
const cta_url = section.props?.cta_url?.value;
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (scheme.bg === 'wn-gradient-bg') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (scheme.bg === 'wn-primary-bg') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (scheme.bg === 'wn-secondary-bg') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
return (
<>
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
<section
id={section.id}
className={cn(
'wn-content',
'px-4 md:px-8',
heightClasses,
!scheme.bg.startsWith('wn-') && scheme.bg,
scheme.text
)}
style={getBackgroundStyle()}
>
<SharedContentLayout
text={content}
textStyle={textStyle.style}
headingStyle={headingStyle.style}
containerWidth={containerWidth as any}
className={contentStyle.classNames}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</section>
</>
);
}

View File

@@ -0,0 +1,152 @@
import { cn } from '@/lib/utils';
import * as LucideIcons from 'lucide-react';
interface FeatureItem {
title?: string;
description?: string;
icon?: string;
}
interface FeatureGridSectionProps {
id: string;
layout?: string;
colorScheme?: string;
heading?: string;
items?: FeatureItem[];
elementStyles?: Record<string, any>;
}
export function FeatureGridSection({
id,
layout = 'grid-3',
colorScheme = 'default',
heading,
items = [],
features = [],
elementStyles,
styles,
}: FeatureGridSectionProps & { features?: FeatureItem[], styles?: Record<string, any> }) {
// Use items or features (priority to items if both exist, but usually only one comes from props)
const listItems = items.length > 0 ? items : features;
const gridCols = {
'grid-2': 'md:grid-cols-2',
'grid-3': 'md:grid-cols-3',
'grid-4': 'md:grid-cols-2 lg:grid-cols-4',
}[layout] || 'md:grid-cols-3';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const headingStyle = getTextStyles('heading');
const featureItemStyle = getTextStyles('feature_item');
return (
<section
id={id}
className={cn(
'wn-section wn-feature-grid',
`wn-feature-grid--${layout}`,
`wn-scheme--${colorScheme}`,
'py-12 md:py-24',
{
// 'bg-white': colorScheme === 'default', // Removed for global styling
'bg-muted': colorScheme === 'muted',
'bg-primary text-primary-foreground': colorScheme === 'primary',
}
)}
>
<div className={cn(
"mx-auto px-4",
styles?.contentWidth === 'full' ? 'w-full' : 'container'
)}>
{heading && (
<h2
className={cn(
"wn-features__heading text-center mb-12",
!elementStyles?.heading?.fontSize && "text-3xl md:text-4xl",
!elementStyles?.heading?.fontWeight && "font-bold",
headingStyle.classNames
)}
style={headingStyle.style}
>
{heading}
</h2>
)}
<div className={cn('grid gap-8', gridCols)}>
{listItems.map((item, index) => (
<div
key={index}
className={cn(
'wn-feature-grid__item',
'p-6 rounded-xl',
!featureItemStyle.style?.backgroundColor && {
'bg-white shadow-lg': colorScheme !== 'primary',
'bg-white/10': colorScheme === 'primary',
},
featureItemStyle.classNames
)}
style={featureItemStyle.style}
>
{item.icon && (() => {
const IconComponent = (LucideIcons as any)[item.icon];
if (!IconComponent) return null;
return (
<div className="wn-feature-grid__icon mb-4 inline-block p-3 rounded-full bg-primary/10 text-primary">
<IconComponent className="w-8 h-8" />
</div>
);
})()}
{item.title && (
<h3
className={cn(
"wn-feature-grid__item-title mb-3",
!featureItemStyle.classNames && "text-xl font-semibold"
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.title}
</h3>
)}
{item.description && (
<p className={cn(
'wn-feature-grid__item-desc',
!featureItemStyle.style?.color && {
'text-gray-600': colorScheme !== 'primary',
'text-white/80': colorScheme === 'primary',
}
)}
style={{ color: featureItemStyle.style?.color }}
>
{item.description}
</p>
)}
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,239 @@
import { cn } from '@/lib/utils';
interface HeroSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
subtitle?: string;
image?: string;
cta_text?: string;
cta_url?: string;
elementStyles?: Record<string, any>;
}
export function HeroSection({
id,
layout = 'default',
colorScheme = 'default',
title,
subtitle,
image,
cta_text,
cta_url,
elementStyles,
styles,
}: HeroSectionProps & { styles?: Record<string, any> }) {
const isImageLeft = layout === 'hero-left-image' || layout === 'image-left';
const isImageRight = layout === 'hero-right-image' || layout === 'image-right';
const isCentered = layout === 'centered' || layout === 'default';
const hasCustomBackground = !!styles?.backgroundColor || !!styles?.backgroundImage;
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const subtitleStyle = getTextStyles('subtitle');
const ctaStyle = getTextStyles('cta_text');
const imageStyle = elementStyles?.['image'] || {};
// Determine height classes
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24', // Original Default
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
// Helper to get background style for dynamic schemes
const getBackgroundStyle = () => {
if (hasCustomBackground) return undefined;
if (colorScheme === 'gradient') {
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
}
if (colorScheme === 'primary') {
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
}
if (colorScheme === 'secondary') {
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
}
return undefined;
};
const isDynamicScheme = ['primary', 'secondary', 'gradient'].includes(colorScheme) && !hasCustomBackground;
return (
<section
id={id}
className={cn(
'wn-section wn-hero',
`wn-hero--${layout}`,
`wn-scheme--${colorScheme}`,
'relative overflow-hidden',
isDynamicScheme && 'text-white',
colorScheme === 'muted' && !hasCustomBackground && 'bg-muted',
)}
style={getBackgroundStyle()}
>
<div className={cn(
'mx-auto px-4',
heightClasses,
styles?.contentWidth === 'full' ? 'w-full' : 'container',
{
'flex flex-col md:flex-row items-center gap-8': isImageLeft || isImageRight,
'text-center': isCentered,
}
)}>
{/* Image - Left */}
{image && isImageLeft && (
<div className="w-full md:w-1/2">
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
</div>
</div>
)}
{/* Content */}
<div className={cn(
'wn-hero__content',
{
'w-full md:w-1/2': isImageLeft || isImageRight,
'max-w-3xl mx-auto': isCentered,
}
)}>
{title && (
<h1
className={cn(
"wn-hero__title mb-6 leading-tight",
!elementStyles?.title?.fontSize && "text-4xl md:text-5xl lg:text-6xl",
!elementStyles?.title?.fontWeight && "font-bold",
titleStyle.classNames
)}
style={titleStyle.style}
>
{title}
</h1>
)}
{subtitle && (
<p
className={cn(
"wn-hero__subtitle text-opacity-80 mb-8",
!elementStyles?.subtitle?.fontSize && "text-lg md:text-xl",
subtitleStyle.classNames
)}
style={subtitleStyle.style}
>
{subtitle}
</p>
)}
{/* Centered Image */}
<div
className={cn(
"mt-12 mx-auto rounded-lg shadow-xl overflow-hidden",
imageStyle.width ? "" : "max-w-4xl"
)}
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
>
{image && isCentered && (
<img
src={image}
alt={title || 'Hero image'}
className={cn(
"w-full rounded-[inherit]",
!imageStyle.height && "h-auto",
!imageStyle.objectFit && "object-cover"
)}
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
maxWidth: '100%',
}}
/>
)}
</div>
{cta_text && cta_url && (
<a
href={cta_url}
className={cn(
"wn-hero__cta inline-block px-8 py-3 rounded-lg font-semibold hover:opacity-90 transition-colors mt-8",
!ctaStyle.style?.backgroundColor && "bg-primary",
!ctaStyle.style?.color && "text-primary-foreground",
ctaStyle.classNames
)}
style={ctaStyle.style}
>
{cta_text}
</a>
)}
</div>
{/* Image - Right */}
{image && isImageRight && (
<div className="w-full md:w-1/2">
<div
className="rounded-lg shadow-xl overflow-hidden"
style={{
backgroundColor: imageStyle.backgroundColor,
width: imageStyle.width || 'auto',
maxWidth: '100%'
}}
>
<img
src={image}
alt={title || 'Hero image'}
className="w-full h-auto block"
style={{
objectFit: imageStyle.objectFit,
height: imageStyle.height,
}}
/>
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,104 @@
import { cn } from '@/lib/utils';
import { SharedContentLayout } from '@/components/SharedContentLayout';
interface ImageTextSectionProps {
id: string;
layout?: string;
colorScheme?: string;
title?: string;
text?: string;
image?: string;
elementStyles?: Record<string, any>;
}
export function ImageTextSection({
id,
layout = 'image-left',
colorScheme = 'default',
title,
text,
image,
cta_text,
cta_url,
elementStyles,
styles,
}: ImageTextSectionProps & { styles?: Record<string, any>, cta_text?: string, cta_url?: string }) {
const isImageLeft = layout === 'image-left' || layout === 'left';
const isImageRight = layout === 'image-right' || layout === 'right';
// Helper to get text styles (including font family)
const getTextStyles = (elementName: string) => {
const styles = elementStyles?.[elementName] || {};
return {
classNames: cn(
styles.fontSize,
styles.fontWeight,
{
'font-sans': styles.fontFamily === 'secondary',
'font-serif': styles.fontFamily === 'primary',
}
),
style: {
color: styles.color,
textAlign: styles.textAlign,
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderWidth: styles.borderWidth,
borderRadius: styles.borderRadius,
}
};
};
const titleStyle = getTextStyles('title');
const textStyle = getTextStyles('text');
const buttonStyle = getTextStyles('button');
const imageStyle = elementStyles?.['image'] || {};
// Height preset support
const heightPreset = styles?.heightPreset || 'default';
const heightMap: Record<string, string> = {
'default': 'py-12 md:py-24',
'small': 'py-8 md:py-16',
'medium': 'py-16 md:py-32',
'large': 'py-24 md:py-48',
'screen': 'min-h-screen py-20 flex items-center',
};
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-24';
return (
<section
id={id}
className={cn(
'wn-section wn-image-text',
`wn-scheme--${colorScheme}`,
heightClasses,
{
'bg-muted': colorScheme === 'muted',
'bg-primary/5': colorScheme === 'primary',
}
)}
>
<SharedContentLayout
title={title}
text={text}
image={image}
imagePosition={isImageRight ? 'right' : 'left'}
containerWidth={styles?.contentWidth === 'full' ? 'full' : 'contained'}
titleStyle={titleStyle.style}
titleClassName={titleStyle.classNames}
textStyle={textStyle.style}
textClassName={textStyle.classNames}
imageStyle={{
backgroundColor: imageStyle.backgroundColor,
objectFit: imageStyle.objectFit,
}}
buttons={cta_text && cta_url ? [{ text: cta_text, url: cta_url }] : []}
buttonStyle={{
classNames: buttonStyle.classNames,
style: buttonStyle.style
}}
/>
</section>
);
}

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
import SubscriptionTimeline from '../../components/SubscriptionTimeline';
// Define types based on CheckoutController response
// Define types based on CheckoutController response
interface BaseResponse {
ok?: boolean;
error?: string;
}
interface OrderDetailsResponse extends BaseResponse {
id: number;
number: string;
status: string;
created_via: string;
total: number;
currency: string;
currency_symbol: string;
items: Array<{
id: number;
name: string;
qty: number;
total: number;
image?: string;
}>;
available_gateways: Array<{
id: string;
title: string;
description: string;
icon?: string;
}>;
subscription?: {
id: number;
status: string;
billing_period: string;
billing_interval: number;
start_date: string;
next_payment_date: string | null;
end_date: string | null;
};
}
interface PaymentResponse extends BaseResponse {
redirect?: string;
messages?: string;
}
const OrderPay: React.FC = () => {
const { orderId } = useParams<{ orderId: string }>();
const [searchParams] = useSearchParams();
const orderKey = searchParams.get('key');
const navigate = useNavigate();
const [order, setOrder] = useState<OrderDetailsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [selectedGateway, setSelectedGateway] = useState<string>('');
const [processing, setProcessing] = useState(false);
useEffect(() => {
if (orderId) {
fetchOrder();
}
}, [orderId]);
const fetchOrder = async () => {
try {
const endpoint = `/checkout/order/${orderId}${orderKey ? `?key=${orderKey}` : ''}`;
const response = await api.get<OrderDetailsResponse>(endpoint);
if (response.error) {
toast.error(response.error);
return;
}
if (response.ok) {
setOrder(response as OrderDetailsResponse);
if (response.available_gateways?.length > 0) {
setSelectedGateway(response.available_gateways[0].id);
}
}
} catch (error) {
console.error('Failed to fetch order', error);
toast.error('Failed to load order details');
} finally {
setLoading(false);
}
};
const handlePayment = async () => {
if (!selectedGateway) {
toast.error('Please select a payment method');
return;
}
setProcessing(true);
try {
const response = await api.post<PaymentResponse>(`/checkout/pay-order/${orderId}`, {
payment_method: selectedGateway,
key: orderKey
});
if (response.ok && response.redirect) {
window.location.href = response.redirect;
} else {
toast.error(response.error || 'Payment failed');
}
} catch (error) {
console.error('Payment error', error);
toast.error('An error occurred while processing payment');
} finally {
setProcessing(false);
}
};
// Helper to format price with proper currency locale
const formatPrice = (amount: number, currency: string) => {
try {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
} catch (e) {
// Fallback
return `${currency} ${amount.toFixed(0)}`;
}
};
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
const isRenewal = order.created_via === 'subscription_renewal';
return (
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Complete Payment</h1>
{order.subscription && (
<SubscriptionTimeline subscription={order.subscription} />
)}
{isRenewal && !order.subscription && (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-blue-700">
This is a payment for your <span className="font-bold">subscription renewal</span>.
Completing this payment will extend your subscription period.
</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
<div className="space-y-4">
{order.items.map((item) => (
<div key={item.id} className="flex justify-between items-center border-b pb-4 last:border-0 last:pb-0">
<div className="flex items-center gap-4">
{item.image ? (
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded bg-gray-100" />
) : (
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400">
<span className="text-xs">No img</span>
</div>
)}
<div>
<div className="font-medium text-lg">{item.name}</div>
<div className="text-sm text-gray-500">Qty: {item.qty}</div>
</div>
</div>
<div className="font-medium text-lg">
{formatPrice(item.total, order.currency)}
</div>
</div>
))}
<div className="border-t pt-4 mt-4">
<div className="flex justify-between items-center text-gray-600 mb-2">
<span>Subtotal</span>
<span>{formatPrice(order.total, order.currency)}</span>
</div>
<div className="flex justify-between items-center font-bold text-xl mt-4">
<span>Total to Pay</span>
<span className="text-primary">{formatPrice(order.total, order.currency)}</span>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
{order.available_gateways.length === 0 ? (
<div className="p-4 bg-yellow-50 text-yellow-700 rounded-md border border-yellow-200">
No payment methods are available for this order. Please contact support.
</div>
) : (
<div className="space-y-3 mb-6">
{order.available_gateways.map((gateway) => (
<label
key={gateway.id}
className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all ${selectedGateway === gateway.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center h-5">
<input
type="radio"
name="payment_method"
value={gateway.id}
checked={selectedGateway === gateway.id}
onChange={(e) => setSelectedGateway(e.target.value)}
className="h-4 w-4 text-primary border-gray-300 focus:ring-primary"
/>
</div>
<div className="ml-3 text-sm">
<span className="block font-medium text-gray-900 text-base">
{gateway.title || gateway.id}
</span>
{gateway.description && (
<span className="block text-gray-500 mt-1">
{gateway.description}
</span>
)}
</div>
</label>
))}
</div>
)}
<button
onClick={handlePayment}
disabled={processing || !selectedGateway}
className="w-full bg-primary text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
</button>
</div>
</div>
);
};
export default OrderPay;

View File

@@ -98,25 +98,47 @@ export default function Product() {
if (!v.attributes) return false;
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
const normalizedValue = attrValue.toLowerCase().trim();
const normalizedSelectedValue = attrValue.toLowerCase().trim();
const attrNameLower = attrName.toLowerCase();
// Check all attribute keys in variation (case-insensitive)
for (const [vKey, vValue] of Object.entries(v.attributes)) {
const vKeyLower = vKey.toLowerCase();
const attrNameLower = attrName.toLowerCase();
// Find the attribute definition to get the slug
const attrDef = product.attributes?.find((a: any) => a.name === attrName);
const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (vKeyLower === `attribute_${attrNameLower}` ||
vKeyLower === `attribute_pa_${attrNameLower}` ||
vKeyLower === attrNameLower) {
// Try to find a matching key in the variation attributes
let variationValue: string | undefined = undefined;
const varValueNormalized = String(vValue).toLowerCase().trim();
if (varValueNormalized === normalizedValue) {
return true;
}
}
// Check for common WooCommerce attribute key formats
// 1. Check strict slug format (attribute_7-days-...)
if (`attribute_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrSlug}`];
}
// 2. Check pa_ format (attribute_pa_color)
else if (`attribute_pa_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
}
// 3. Fallback to name-based checks (legacy)
else if (`attribute_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrNameLower}`];
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
} else if (attrNameLower in v.attributes) {
variationValue = v.attributes[attrNameLower];
}
return false;
// If key is undefined/missing in variation, it means "Any" -> Match
if (variationValue === undefined || variationValue === null) {
return true;
}
// If empty string, it also means "Any" -> Match
const normalizedVarValue = String(variationValue).toLowerCase().trim();
if (normalizedVarValue === '') {
return true;
}
// Otherwise, values must match
return normalizedVarValue === normalizedSelectedValue;
});
});
@@ -181,11 +203,36 @@ export default function Product() {
}
}
// Construct variation params using keys from the matched variation
// but filling in values from user selection (handles "Any" variations with empty values)
let variation_params: Record<string, string> = {};
if (product.type === 'variable' && selectedVariation?.attributes) {
// Get keys from the variation's attributes (these are the correct WooCommerce keys)
Object.keys(selectedVariation.attributes).forEach(key => {
// Key format is like "attribute_7-days-auto-closing-variation-plan"
// Extract the slug part after "attribute_"
const slug = key.replace(/^attribute_/, '');
// Find the matching user-selected value by attribute name
const attrDef = product.attributes?.find((a: any) =>
a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug
);
if (attrDef && selectedAttributes[attrDef.name]) {
variation_params[key] = selectedAttributes[attrDef.name];
} else {
// Fallback to stored value if no user selection
variation_params[key] = selectedVariation.attributes[key];
}
});
}
try {
await apiClient.post(apiClient.endpoints.cart.add, {
product_id: product.id,
quantity,
variation_id: selectedVariation?.id || 0,
variation: variation_params,
});
addItem({
@@ -320,8 +367,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
}`}
aria-label={`View image ${index + 1}`}
/>
@@ -354,8 +401,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<img
@@ -446,8 +493,8 @@ export default function Product() {
key={optIndex}
onClick={() => handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`}
>
{option}
@@ -503,8 +550,8 @@ export default function Product() {
<button
onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`}
>
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''

View File

@@ -150,7 +150,7 @@ export default function Shop() {
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
className="w-full !pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
{search && (
<button

69
debug-variation.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
/**
* Debug script to check variation attribute data
* Access via: /wp-content/plugins/woonoow/debug-variation.php
* DELETE THIS FILE AFTER DEBUGGING
*/
require_once dirname(__DIR__, 3) . '/wp-load.php';
header('Content-Type: application/json');
$variation_id = isset($_GET['variation_id']) ? intval($_GET['variation_id']) : 515;
$product_id = isset($_GET['product_id']) ? intval($_GET['product_id']) : 512;
$result = [];
// Get parent product
$product = wc_get_product($product_id);
if ($product && $product->is_type('variable')) {
$result['parent_product'] = [
'id' => $product->get_id(),
'name' => $product->get_name(),
'type' => $product->get_type(),
];
// Get parent product attributes
$result['parent_attributes'] = [];
foreach ($product->get_attributes() as $key => $attribute) {
$result['parent_attributes'][$key] = [
'name' => $attribute->get_name(),
'label' => wc_attribute_label($attribute->get_name()),
'is_taxonomy' => $attribute->is_taxonomy(),
'is_variation' => $attribute->get_variation(),
'options' => $attribute->get_options(),
'sanitized_name' => sanitize_title($attribute->get_name()),
];
}
// Get available variations from parent
$result['available_variations'] = $product->get_available_variations();
}
// Get variation directly
$variation = wc_get_product($variation_id);
if ($variation && $variation->is_type('variation')) {
$result['variation'] = [
'id' => $variation->get_id(),
'name' => $variation->get_name(),
'type' => $variation->get_type(),
'parent_id' => $variation->get_parent_id(),
];
// Get variation attributes using WooCommerce method
$result['variation_attributes_wc'] = $variation->get_variation_attributes();
// Get raw post meta
global $wpdb;
$meta_rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key LIKE 'attribute_%%'",
$variation_id
));
$result['variation_meta_raw'] = $meta_rows;
// Get all meta for this variation
$result['all_meta'] = get_post_meta($variation_id);
}
echo json_encode($result, JSON_PRETTY_PRINT);

View File

@@ -1,4 +1,5 @@
<?php
/**
* WooNooW Documentation Registry
*
@@ -13,71 +14,25 @@ namespace WooNooW\Docs;
*
* @return array Documentation registry
*/
function get_docs_registry() {
function get_docs_registry()
{
$docs_dir = dirname(__FILE__);
// Core WooNooW documentation
$docs = [
'core' => [
'label' => 'WooNooW',
'label' => 'Help & Support',
'icon' => 'book-open',
'items' => [
[
'slug' => 'getting-started',
'title' => 'Getting Started',
'title' => 'Official Documentation',
'file' => $docs_dir . '/getting-started.md',
],
[
'slug' => 'installation',
'title' => 'Installation',
'file' => $docs_dir . '/installation.md',
],
[
'slug' => 'troubleshooting',
'title' => 'Troubleshooting',
'file' => $docs_dir . '/troubleshooting.md',
],
[
'slug' => 'faq',
'title' => 'FAQ',
'file' => $docs_dir . '/faq.md',
],
],
],
'configuration' => [
'label' => 'Configuration',
'icon' => 'settings',
'items' => [
[
'slug' => 'configuration/appearance',
'title' => 'Appearance Settings',
'file' => $docs_dir . '/configuration/appearance.md',
],
[
'slug' => 'configuration/spa-mode',
'title' => 'SPA Mode',
'file' => $docs_dir . '/configuration/spa-mode.md',
],
],
],
'features' => [
'label' => 'Features',
'icon' => 'layers',
'items' => [
[
'slug' => 'features/shop',
'title' => 'Shop Page',
'file' => $docs_dir . '/features/shop.md',
],
[
'slug' => 'features/checkout',
'title' => 'Checkout',
'file' => $docs_dir . '/features/checkout.md',
],
],
],
];
/**
* Filter: woonoow_docs_registry
*
@@ -111,9 +66,10 @@ function get_docs_registry() {
* @param string $slug Document slug
* @return array|null Document data with content, or null if not found
*/
function get_doc_by_slug($slug) {
function get_doc_by_slug($slug)
{
$registry = get_docs_registry();
foreach ($registry as $section) {
foreach ($section['items'] as $item) {
if ($item['slug'] === $slug) {
@@ -127,6 +83,6 @@ function get_doc_by_slug($slug) {
}
}
}
return null;
}

Some files were not shown because too many files have changed in this diff Show More