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

@@ -75,7 +75,7 @@ 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',
@@ -89,7 +89,7 @@ export function BlockRenderer({
return (
<div style={cardStyles[block.cardType]}>
<div
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600 [&_.text-link]:text-purple-600 [&_.text-link]:underline"
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
@@ -97,31 +97,45 @@ export function BlockRenderer({
);
case 'button': {
const buttonStyle: React.CSSProperties = block.style === 'solid'
? {
// 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: '#7f54b3',
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,
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 600,
};
}
const containerStyle: React.CSSProperties = {
textAlign: block.align || 'center',
};
// Width modes don't apply to plain links
if (block.style !== 'link') {
if (block.widthMode === 'full') {
buttonStyle.display = 'block';
buttonStyle.width = '100%';
@@ -130,6 +144,7 @@ export function BlockRenderer({
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
buttonStyle.width = '100%';
}
}
return (
<div style={containerStyle}>

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>
>(({ 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-[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",
"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,8 +57,33 @@ DropdownMenuSubContent.displayName =
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<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}
@@ -70,7 +95,8 @@ const DropdownMenuContent = React.forwardRef<
{...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;
@@ -16,6 +16,35 @@ export function AppProvider({
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,
});
@@ -70,11 +91,8 @@ export default function AppearanceFooter() {
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
];
// Only keeping newsletter_description, titles are now managed per column
const [labels, setLabels] = useState({
contact_title: 'Contact',
menu_title: 'Quick Links',
social_title: 'Follow Us',
newsletter_title: 'Newsletter',
newsletter_description: 'Subscribe to get updates',
});
@@ -87,8 +105,30 @@ export default function AppearanceFooter() {
if (footer) {
if (footer.columns) setColumns(footer.columns);
if (footer.style) setStyle(footer.style);
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
if (footer.elements) setElements(footer.elements);
// Handle new structure vs backward compatibility
if (footer.copyright) {
setCopyright(footer.copyright);
} else if (footer.copyright_text) {
// Migration fallback
setCopyright({
enabled: footer.elements?.copyright ?? true,
text: footer.copyright_text
});
}
if (footer.payment) {
setPayment(footer.payment);
} else if (footer.elements?.payment) {
// Migration fallback
setPayment(prev => ({ ...prev, enabled: footer.elements.payment }));
}
if (footer.elements) {
const { payment, copyright, ...rest } = footer.elements;
setElements(prev => ({ ...prev, ...rest }));
}
if (footer.social_links) setSocialLinks(footer.social_links);
if (footer.sections && footer.sections.length > 0) {
setSections(footer.sections);
@@ -96,7 +136,11 @@ export default function AppearanceFooter() {
setSections(defaultSections);
}
if (footer.contact_data) setContactData(footer.contact_data);
if (footer.labels) setLabels(footer.labels);
// Only sync description if it exists
if (footer.labels?.newsletter_description) {
setLabels({ newsletter_description: footer.labels.newsletter_description });
}
} else {
setSections(defaultSections);
}
@@ -152,7 +196,7 @@ export default function AppearanceFooter() {
...sections,
{
id: Date.now().toString(),
title: 'New Section',
title: 'New Column',
type: 'custom',
content: '',
visible: true,
@@ -168,12 +212,34 @@ export default function AppearanceFooter() {
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const addPaymentMethod = () => {
setPayment({
...payment,
methods: [...payment.methods, { id: Date.now().toString(), url: '', label: '' }]
});
};
const removePaymentMethod = (id: string) => {
setPayment({
...payment,
methods: payment.methods.filter(m => m.id !== id)
});
};
const updatePaymentMethod = (id: string, field: keyof PaymentMethod, value: string) => {
setPayment({
...payment,
methods: payment.methods.map(m => m.id === id ? { ...m, [field]: value } : m)
});
};
const handleSave = async () => {
try {
const payload = {
columns,
style,
copyrightText,
copyright,
payment,
elements,
socialLinks,
sections,
@@ -227,62 +293,14 @@ export default function AppearanceFooter() {
</SettingsSection>
</SettingsCard>
{/* Labels */}
{/* Content & Contact */}
<SettingsCard
title="Section Labels"
description="Customize footer section headings and text"
>
<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"
title="Content & Contact"
description="Manage footer content and contact details"
>
<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"
@@ -333,20 +351,16 @@ export default function AppearanceFooter() {
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
</SettingsCard>
</div>
{/* 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."
<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>
@@ -385,19 +399,21 @@ export default function AppearanceFooter() {
))}
</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' },
@@ -43,6 +44,8 @@ export default function AppearanceGeneral() {
accent: '#3b82f6',
text: '#111827',
background: '#ffffff',
gradientStart: '#9333ea', // purple-600 defaults
gradientEnd: '#3b82f6', // blue-500 defaults
});
useEffect(() => {
@@ -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,6 +76,8 @@ 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',
});
}
}
@@ -106,6 +114,7 @@ export default function AppearanceGeneral() {
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
scale: fontScale[0],
},
containerWidth,
colors,
});
@@ -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>
@@ -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,7 +11,14 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ErrorCard } from '@/components/ErrorCard';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit } from 'lucide-react';
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { formatMoney } from '@/lib/currency';
export default function CustomersIndex() {
@@ -212,8 +219,7 @@ 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>
@@ -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" />
<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')}
</button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
if (confirm(__('Are you sure you want to delete this customer?'))) {
deleteMutation.mutate([customer.id]);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))

View File

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

View File

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

View File

@@ -8,10 +8,18 @@ import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFABConfig } from '@/hooks/useFABConfig';
import { CouponFilterSheet } from './components/CouponFilterSheet';
import { CouponCard } from './components/CouponCard';
@@ -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" />
<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')}
</button>
</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,11 +130,6 @@ 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">
@@ -261,8 +255,6 @@ export default function Campaigns() {
</Table>
</div>
)}
</div>
</SettingsCard>
{/* 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,13 +73,34 @@ 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">
@@ -84,14 +112,23 @@ export default function Subscribers() {
className="!pl-9"
/>
</div>
<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 ? (
{
isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
@@ -104,6 +141,13 @@ export default function Subscribers() {
<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>
@@ -114,6 +158,13 @@ export default function Subscribers() {
<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">
@@ -134,23 +185,35 @@ export default function Subscribers() {
)}
</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" />
<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>
)}
</div>
</SettingsCard>
)
}
{/* 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')}
<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>
<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'
)}
>
<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>
<Icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
<TabsContent value="subscribers" className="space-y-4 mt-6">
<Subscribers />
</TabsContent>
<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>
<TabsContent value="campaigns" className="space-y-4 mt-6">
<Campaigns />
</TabsContent>
</Tabs>
</SettingsLayout>
{/* 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,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react';
import { Filter, PackageOpen, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
@@ -19,6 +19,13 @@ import {
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import {
Select,
@@ -94,8 +101,8 @@ export default function Orders() {
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -432,7 +439,7 @@ export default function Orders() {
/>
</td>
<td className="p-3">
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
<Link className="font-medium hover:underline" to={`/orders/${row.id}`}>#{row.number}</Link>
</td>
<td className="p-3 min-w-32">
<span title={row.date ?? ""}>
@@ -454,9 +461,36 @@ export default function Orders() {
decimals: store.decimals,
})}
</td>
<td className="p-3 text-center space-x-2">
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
<td className="p-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{__('Open menu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}`)}>
<Eye className="mr-2 h-4 w-4" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit Order')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setSelectedIds([row.id]);
setShowDeleteDialog(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
@@ -27,6 +27,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Link, useNavigate } from 'react-router-dom';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import { Skeleton } from '@/components/ui/skeleton';
@@ -62,8 +69,8 @@ export default function Products() {
const [type, setType] = useState<string | undefined>(initial.type || undefined);
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [orderby, setOrderby] = useState<'date' | 'title' | 'id' | 'modified'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
@@ -412,9 +419,37 @@ export default function Products() {
</span>
</td>
<td className="p-3 text-right">
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
<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')}
</Link>
</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,6 +457,7 @@ 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.')}`}
@@ -447,6 +491,16 @@ export default function EditTemplate() {
<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>
}
>
@@ -537,5 +591,41 @@ export default function EditTemplate() {
</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>
<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,191 +219,23 @@ 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')}
<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>
</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 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>
</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)')}
</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>
{/* Email Background & Social Icons */}

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,22 +57,50 @@ 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 (
<Routes>
{/* License Connect - Standalone focused page without layout */}
<Route path="/my-account/license-connect" element={<Account />} />
{/* All other routes wrapped in BaseLayout */}
<Route
path="/*"
element={
<BaseLayout>
<Routes>
{/* Root route redirects to initial route based on SPA mode */}
<Route path="/" element={<Navigate to={initialRoute} replace />} />
{/* 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 />
)
}
/>
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
@@ -80,6 +110,8 @@ function AppRoutes() {
<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 />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
@@ -92,10 +124,16 @@ function AppRoutes() {
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Fallback to initial route */}
<Route path="*" element={<Navigate to={initialRoute} replace />} />
{/* 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,10 +3,17 @@ import { toast } from 'sonner';
interface NewsletterFormProps {
description?: string;
gdprRequired?: boolean;
consentText?: string;
}
export function NewsletterForm({ description }: NewsletterFormProps) {
export function NewsletterForm({
description,
gdprRequired = false,
consentText = 'I agree to receive marketing emails and understand I can unsubscribe at any time.',
}: NewsletterFormProps) {
const [email, setEmail] = useState('');
const [consent, setConsent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
@@ -17,6 +24,11 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
return;
}
if (gdprRequired && !consent) {
toast.error('Please accept the terms to subscribe');
return;
}
setLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
@@ -26,7 +38,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email }),
body: JSON.stringify({ email, consent }),
});
const data = await response.json();
@@ -34,6 +46,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
if (response.ok) {
toast.success(data.message || 'Successfully subscribed to newsletter!');
setEmail('');
setConsent(false);
} else {
toast.error(data.message || 'Failed to subscribe. Please try again.');
}
@@ -48,7 +61,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
return (
<div>
{description && <p className="text-sm text-gray-600 mb-4">{description}</p>}
<form onSubmit={handleSubmit} className="space-y-2">
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
value={email}
@@ -57,9 +70,25 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
className="w-full px-4 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={loading}
/>
{gdprRequired && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-1 rounded border-gray-300"
disabled={loading}
/>
<span className="text-xs text-gray-600 leading-relaxed">
{consentText}
</span>
</label>
)}
<button
type="submit"
disabled={loading}
disabled={loading || (gdprRequired && !consent)}
className="font-[inherit] w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Subscribing...' : 'Subscribe'}

View File

@@ -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,6 +37,16 @@ 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() {
@@ -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(() => {
// Load guest wishlist on mount
useEffect(() => {
if (!isLoggedIn) {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const guestIds = JSON.parse(stored) as number[];
setProductIds(new Set(guestIds));
const ids = JSON.parse(stored) as number[];
setGuestIds(new Set(ids));
}
} 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) {
// 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,
});
// 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;
// Compute set of IDs for O(1) lookup
const productIds = useMemo(() => {
if (isLoggedIn) {
loadWishlist();
} else {
loadGuestWishlist();
return new Set(serverItems.map(item => item.product_id));
}
}
}, [isEnabled, isLoggedIn]);
return guestIds;
}, [isLoggedIn, serverItems, guestIds]);
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]);
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);
// 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');
await removeMutation.mutateAsync(productId);
return true;
} catch (error) {
toast.error('Failed to remove from wishlist');
return false;
}
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
}, [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">
{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">
<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,11 +446,26 @@ function ModernLayout({ children }: BaseLayoutProps) {
<nav className="hidden md:flex items-center space-x-8">
{headerSettings.elements.navigation && (
<>
{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 && (
<button
onClick={() => setSearchOpen(true)}
@@ -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 && (
<>
{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,10 +115,20 @@ export default function OrderDetails() {
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
<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">
Placed on {formatDate(order.date)}

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}`;
// 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();
// Check all attribute keys in variation (case-insensitive)
for (const [vKey, vValue] of Object.entries(v.attributes)) {
const vKeyLower = vKey.toLowerCase();
const normalizedSelectedValue = attrValue.toLowerCase().trim();
const attrNameLower = attrName.toLowerCase();
if (vKeyLower === `attribute_${attrNameLower}` ||
vKeyLower === `attribute_pa_${attrNameLower}` ||
vKeyLower === attrNameLower) {
// 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, '');
const varValueNormalized = String(vValue).toLowerCase().trim();
if (varValueNormalized === normalizedValue) {
// Try to find a matching key in the variation attributes
let variationValue: string | undefined = undefined;
// 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];
}
// 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;
}
return false;
// 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({

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,67 +14,21 @@ namespace WooNooW\Docs;
*
* @return array Documentation registry
*/
function get_docs_registry() {
function get_docs_registry()
{
$docs_dir = dirname(__FILE__);
// Core WooNooW documentation
$docs = [
'core' => [
'label' => 'WooNooW',
'label' => 'Help & Support',
'icon' => 'book-open',
'items' => [
[
'slug' => 'getting-started',
'title' => 'Getting Started',
'title' => 'Official Documentation',
'file' => $docs_dir . '/getting-started.md',
],
[
'slug' => 'installation',
'title' => 'Installation',
'file' => $docs_dir . '/installation.md',
],
[
'slug' => 'troubleshooting',
'title' => 'Troubleshooting',
'file' => $docs_dir . '/troubleshooting.md',
],
[
'slug' => 'faq',
'title' => 'FAQ',
'file' => $docs_dir . '/faq.md',
],
],
],
'configuration' => [
'label' => 'Configuration',
'icon' => 'settings',
'items' => [
[
'slug' => 'configuration/appearance',
'title' => 'Appearance Settings',
'file' => $docs_dir . '/configuration/appearance.md',
],
[
'slug' => 'configuration/spa-mode',
'title' => 'SPA Mode',
'file' => $docs_dir . '/configuration/spa-mode.md',
],
],
],
'features' => [
'label' => 'Features',
'icon' => 'layers',
'items' => [
[
'slug' => 'features/shop',
'title' => 'Shop Page',
'file' => $docs_dir . '/features/shop.md',
],
[
'slug' => 'features/checkout',
'title' => 'Checkout',
'file' => $docs_dir . '/features/checkout.md',
],
],
],
];
@@ -111,7 +66,8 @@ function get_docs_registry() {
* @param string $slug Document slug
* @return array|null Document data with content, or null if not found
*/
function get_doc_by_slug($slug) {
function get_doc_by_slug($slug)
{
$registry = get_docs_registry();
foreach ($registry as $section) {

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