Compare commits

...

48 Commits

Author SHA1 Message Date
Dwindi Ramadhana
3357fbfcf1 feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP) 2026-01-10 00:50:32 +07:00
Dwindi Ramadhana
d3ec580ec8 feat: cleanup and improvements for checkout fields
- Removed all debug logging (backend and frontend)
- Added filter hook 'woonoow_standard_checkout_field_keys' for extensibility
- Added form-row-wide class support to admin OrderForm
- Tax is automatically handled by WC's calculate_totals()
2026-01-09 10:06:20 +07:00
Dwindi Ramadhana
942fb48a0b fix: critical - add shipping_cost/title to sanitize_payload whitelist
ROOT CAUSE: The sanitize_payload() method was returning a whitelist of
allowed fields, but shipping_cost, shipping_title, custom_fields, and
customer_note were NOT included. This caused these values to be null
even though the frontend was sending them correctly.

Added:
- shipping_cost (float)
- shipping_title (sanitized text)
- custom_fields (array)
- customer_note (sanitized textarea)

This should fix shipping not being applied to order totals.
2026-01-09 09:45:46 +07:00
Dwindi Ramadhana
e04f1fd93f feat: add form-row-wide class support for checkout fields
- Added isFullWidthField helper to check for form-row-wide in class array
- Added getFieldWrapperClass helper to return md:col-span-2 for full width
- Applied dynamic width to billing_first_name and billing_last_name
- PHP can now control field width via class: ['form-row-wide']
- Added debug logging for shipping to help diagnose shipping not applied issue
2026-01-08 23:57:47 +07:00
Dwindi Ramadhana
c6489b6b05 fix: shipping cost applied to orders + dynamic field rendering
Shipping Fix:
- Frontend now sends shipping_cost and shipping_title in order payload
- Backend uses these values as fallback when WC zone-based rate lookup fails
- Fixes issue where Rajaongkir and other API-based shipping wasn't applied

Dynamic Field Rendering:
- Added billingFields/shippingFields filters sorted by priority
- Added getBillingField/getShippingField helpers that return undefined for hidden fields
- All standard fields now conditionally rendered based on API response
- Fields use labels and required flags from API
- Any field can be hidden via PHP snippet (type: 'hidden' or hidden: true)
- Removed unused isFieldHidden function
2026-01-08 23:41:30 +07:00
Dwindi Ramadhana
7a45b243cb fix: ThankYou page discount rows and improved hidden field detection
1. ThankYou page - Added discount row to order summary
   - All 3 template variations (receipt-inner, receipt-outer, default)
   - Shows discount with negative amount in green when > 0
   - Already had shipping/tax rows, now complete breakdown

2. Checkout - Enhanced isFieldHidden helper
   - Now checks both type='hidden' AND hidden=true flags
   - Any standard field can be hidden via PHP snippet
   - Existing city/state/postcode/country checks unchanged
2026-01-08 23:23:56 +07:00
Dwindi Ramadhana
0e561d9e8c fix: checkout issues - hidden fields, coupons, shipping in order totals
1. Hidden fields now properly hidden in SPA
   - Added billing_postcode and shipping_postcode to isFieldHidden checks
   - Fields with type='hidden' from PHP now conditionally rendered

2. Coupons now applied to order total
   - Added coupons array to order submission payload
   - CartController now calls calculate_totals() before reading discounts
   - Returns per-coupon discount amounts {code, discount, type}

3. Shipping now applied to order total
   - Already handled in submit() via find_shipping_rate_for_order
   - Frontend now sends shipping_method in payload

4. Order details now include shipping/tracking info
   - checkout/order/{id} API includes shipping_lines, tracking_number, tracking_url
   - account/orders/{id} API includes same shipping/tracking fields
   - Tracking info read from multiple plugin meta keys

5. Thank you/OrderDetails page shows shipping method and AWB
   - Shipping Method section with courier name and cost
   - AWB tracking for processing/completed orders with Track Shipment button
2026-01-08 23:04:31 +07:00
Dwindi Ramadhana
e8c60b3a09 fix: checkout improvements
1. Hidden fields now respected in SPA
   - Added isFieldHidden helper to check if PHP sets type to 'hidden'
   - Country/state/city fields conditionally rendered based on API response
   - Set default country value for hidden country fields (Indonesia-only stores)

2. Coupon discount now shows correct amount
   - Added calculate_totals() before reading discount
   - Changed coupons response to include {code, discount, type} per coupon
   - Added discount_total at root level for frontend compatibility

3. Order details page now shows shipping info and AWB tracking
   - Added shipping_lines, tracking_number, tracking_url to Order interface
   - Added Shipping Method section with courier name and cost
   - Added AWB tracking section for processing/completed orders
   - Track Shipment button with link to tracking URL
2026-01-08 20:51:26 +07:00
Dwindi Ramadhana
26faa008cb docs: update Rajaongkir snippet and add generic shipping bridge pattern
RAJAONGKIR_INTEGRATION.md:
- Hide country/state/city when Indonesia is the only allowed country
- Make destination_id required for Indonesia-only stores
- Force country to ID in session bridge
- Added billing_destination_id fallback

SHIPPING_BRIDGE_PATTERN.md:
- New generic template for shipping provider integrations
- Documents architecture, hooks, and field types
- Provides copy-paste template for new providers
- Includes checklist for new integrations
2026-01-08 15:20:25 +07:00
Dwindi Ramadhana
56b0040f7a feat(checkout): implement dynamic shipping rate fetching
Backend:
- Added /checkout/shipping-rates REST endpoint
- Returns available shipping methods from matching zone
- Triggers woonoow/shipping/before_calculate hook for Rajaongkir

Frontend:
- Added ShippingRate interface and state
- Added fetchShippingRates with 500ms debounce
- Replaced hardcoded shipping options with dynamic rates
- Added loading and empty state handling
- Added shipping_method to order submission payload

This fixes:
- Rajaongkir rates not appearing (now fetched from API)
- Free shipping showing despite disabled (now from WC zones)
2026-01-08 15:13:59 +07:00
Dwindi Ramadhana
533cf5e7d2 fix(rajaongkir): fix relative endpoint path and increase min_chars to 3
- search_endpoint changed from '/woonoow/v1/rajaongkir/destinations'
  to '/rajaongkir/destinations' (relative to API base)
- min_chars increased from 2 to 3 to reduce early API hits
2026-01-08 14:50:08 +07:00
Dwindi Ramadhana
f518d7e589 feat(checkout): fix searchable select API search and add billing destination
Fixes:
1. SearchableSelect now supports onSearch prop for API-based search
   - Added onSearch and isSearching props
   - shouldFilter disabled when onSearch provided
2. DynamicCheckoutField connects handleApiSearch to SearchableSelect
3. RAJAONGKIR_INTEGRATION.md adds both billing and shipping destination_id

This enables the destination search field to actually call the API
when user types, instead of just filtering local (empty) options.
2026-01-08 14:47:54 +07:00
Dwindi Ramadhana
f6b778c7fc fix(rajaongkir): correct API method name and remove premature country check
Root causes fixed:
1. API method: search_destination_api() not search_destination()
2. Field filter: removed country check that failed before user selection
3. Field now always added if store sells to Indonesia

The woocommerce_checkout_fields filter now:
- Checks if Rajaongkir is active
- Checks if Indonesia is in allowed countries
- Always adds field (frontend will show/hide based on country)
2026-01-08 14:38:52 +07:00
Dwindi Ramadhana
906ad38a36 docs(rajaongkir): update integration guide with correct code snippet
Based on deep analysis of Rajaongkir plugin:
- Destinations searched via RajaOngkir API (no local DB table)
- Uses Cekongkir_API::search_destination() for search
- Session 'selected_destination_id' required for rates
- Added SPA-aware checkout field injection

Code snippet includes:
1. REST endpoint /woonoow/v1/rajaongkir/destinations
2. Checkout field filter with REST context detection
3. Session bridge via woonoow/shipping/before_calculate
2026-01-08 14:17:58 +07:00
Dwindi Ramadhana
274c3d35e1 fix(checkout): fix disabled country/state and add public countries API
Issues fixed:
1. Country field was disabled when API failed (length 0)
   - Changed: disabled={countries.length <= 1} → disabled={countries.length === 1}
   - Only disables in single-country mode now

2. State field was disabled when no preloaded states
   - Changed: Falls back to text input instead of disabled SearchableSelect
   - Allows manual state entry for countries without state list

3. /countries API required admin permission
   - Added public /countries endpoint to CheckoutController
   - Uses permission_callback __return_true for customer checkout access
   - Returns countries, states, and default_country
2026-01-08 14:02:13 +07:00
Dwindi Ramadhana
6694d9e0c4 feat(checkout): dynamic checkout fields with PHP filter support
Backend (CheckoutController):
- Enhanced get_fields() API with custom_attributes, search_endpoint,
  search_param, min_chars, input_class, default
- Supports new 'searchable_select' field type for API-backed search

Customer SPA:
- Created DynamicCheckoutField component for all field types
- Checkout fetches fields from /checkout/fields API
- Renders custom fields from PHP filters (billing + shipping)
- searchable_select type with live API search
- Custom field data included in checkout submission

This enables:
- Checkout Field Editor Pro compatibility
- Rajaongkir destination_id via simple code snippet
- Any plugin using woocommerce_checkout_fields filter

Updated RAJAONGKIR_INTEGRATION.md with code snippet approach.
2026-01-08 11:48:53 +07:00
Dwindi Ramadhana
2939ebfe6b feat(checkout): searchable address fields and Rajaongkir integration
Admin SPA:
- Changed billing/shipping state from Select to SearchableSelect

Customer SPA:
- Added cmdk package for command palette
- Created popover, command, and searchable-select UI components
- Added searchable country and state fields to checkout
- Fetches countries/states from /countries API
- Auto-clears state when country changes

Backend:
- Added generic woonoow/shipping/before_calculate hook
- Removed hardcoded Rajaongkir session handling

Documentation:
- Updated RAJAONGKIR_INTEGRATION.md with:
  - Complete searchable destination selector plugin code
  - JavaScript implementation
  - React component version
  - REST API endpoint for destination search
2026-01-08 11:19:37 +07:00
Dwindi Ramadhana
786e01c8f6 feat(shipping): searchable state fields and addon hook
1. Admin OrderForm: Changed billing & shipping state from Select to
   SearchableSelect for better UX (consistent with country field)

2. OrdersController: Replaced Rajaongkir-specific hardcoded session
   handling with generic filter hook:
   do_action('woonoow/shipping/before_calculate', $shipping, $items)

3. Added RAJAONGKIR_INTEGRATION.md with:
   - Hook documentation
   - Code snippet to bridge Rajaongkir with WooNooW
   - Frontend integration examples
   - Troubleshooting guide
2026-01-08 11:00:55 +07:00
Dwindi Ramadhana
83836298ec fix(admin): WC settings link uses siteUrl + /wp-admin
The wpAdminUrl config already includes admin.php?page=woonoow,
so constructing /admin.php?page=wc-settings on top of it was wrong.

Now uses siteUrl + /wp-admin for external WC links.
2026-01-08 10:22:26 +07:00
Dwindi Ramadhana
068fbe3a26 fix(api): add fallback and debug to calculate_shipping
When WC packages array is empty, manually calculate rates by:
1. Get zone matching the shipping address
2. Call get_rates_for_package() on each method
3. Or fallback to method title/cost for simple methods like Free Shipping

Also added debug info to response to help diagnose issues.
2026-01-08 10:01:06 +07:00
Dwindi Ramadhana
ab0eb3ab28 fix(admin): shipping uses rate-level options from calculate_shipping
Removed static method-level fallback. Shipping method selector now:
1. Shows 'Enter shipping address to see available rates' when address incomplete
2. Calls calculate_shipping endpoint to get actual WC_Shipping_Rate objects
3. Displays rate-level options (e.g., JNE REG, JNE YES) not method-level

This ensures third-party shipping plugins like Rajaongkir, UPS, FedEx
display their courier rates correctly.
2026-01-08 09:56:12 +07:00
Dwindi Ramadhana
740cfcbb94 fix(admin): shipping fallback and variation attribute styling
1. Shipping method selector now shows static shippings list when
   address is not complete, instead of 'No shipping methods available'.
   Only shows the empty message when address IS complete but no methods
   matched.

2. Variation selector in Dialog and Drawer now displays attribute names
   (Size, Dispenser) in semibold and values (30ml, pump) in normal
   weight for better visual hierarchy.
2026-01-08 09:26:54 +07:00
Dwindi Ramadhana
687e51654b fix(api): normalize custom attribute meta key to lowercase
WooCommerce stores variation custom attribute meta keys in lowercase
(e.g., attribute_size), but we were using the original case from
parent attributes (e.g., attribute_Size). This caused empty attribute
values in the admin Order Form variation selector.

Fix: Use sanitize_title() to normalize the attribute name.
2026-01-08 09:19:08 +07:00
Dwindi Ramadhana
a0e580878e fix(admin): mount popover portal inside app container
Instead of mounting to body (which breaks scoped styles), we now
mount the popover portal to #woonoow-admin-app. This ensures
dropdowns inherit the correct CSS variables and styling.
2026-01-07 23:40:03 +07:00
Dwindi Ramadhana
e66f260e75 fix(admin): style cmdk components to resolve broken dropdown visuals
- Added global styles for [cmdk-root], [cmdk-list], [cmdk-item]
- Forced white background and border for [data-radix-popper-content-wrapper]
- Fixed missing styles that caused transparent/transparent dropdowns
2026-01-07 23:37:11 +07:00
Dwindi Ramadhana
a52f5fc707 fix(admin): set explicit width for product search dropdown in order form
Prevents the search dropdown from shrinking or overflowing unpredictably
in the flex container. Also ensures better alignment.
2026-01-07 23:34:48 +07:00
Dwindi Ramadhana
5170aea882 fix: hide header wishlist for logged-in users
- Guest users see wishlist icon in header (uses /wishlist page)
- Logged-in users don't see it (they use /my-account/wishlist instead)
- Applied to all 3 layout styles: Classic, Modern, Boutique
2026-01-07 23:15:02 +07:00
Dwindi Ramadhana
d262bd3ae8 fix: license generation not working - hook timing issue
Root cause: LicensingModule::init() was called from within
plugins_loaded but then tried to add ANOTHER plugins_loaded action
for LicenseManager::init(). Since plugins_loaded already fired,
LicenseManager::init() never ran and WooCommerce order hooks
were never registered.

Fix: Call self::maybe_init_manager() directly instead of
scheduling via add_action.
2026-01-07 23:07:45 +07:00
Dwindi Ramadhana
9204189448 fix: add more hooks for license generation on order completion
- Added woocommerce_payment_complete hook
- Added woocommerce_thankyou hook for COD/virtual orders
- Added is_virtual_order helper to detect virtual-only orders
- generate_licenses_for_order now called from multiple hooks
  (safe due to license_exists_for_order_item check)
2026-01-07 22:58:29 +07:00
Dwindi Ramadhana
a4a055a98e feat: add SEOHead to all SPA pages for dynamic page titles
Added SEOHead component to:
- ThankYou page (both template styles)
- Login page
- Account/Dashboard
- Account/Orders
- Account/Downloads
- Account/Addresses
- Account/Wishlist
- Account/Licenses
- Account/AccountDetails
- Public Wishlist page

Also created usePageTitle hook as alternative for non-Helmet usage.
2026-01-07 22:51:47 +07:00
Dwindi Ramadhana
d7b132d9d9 fix: dbDelta separate tables, add SEOHead for page titles
1. License table creation:
   - dbDelta requires separate calls per CREATE TABLE
   - Split into sql_licenses and sql_activations
   - Added 'PRIMARY KEY  (id)' with two spaces (dbDelta requirement)

2. Page titles:
   - Added SEOHead to Cart page (title: Shopping Cart)
   - Added SEOHead to Checkout page (title: Checkout)
   - Shop already had SEOHead

3. usePageTitle hook created (alternative to SEOHead for non-Helmet usage)
2026-01-07 22:40:45 +07:00
Dwindi Ramadhana
3a08e80c1f fix: category selection, checkout redirect, sidebar shipping visibility
1. Category Selection Bug:
   - Added 'id' alias to category/tag API responses
   - Frontend uses cat.id which was undefined (API returned term_id)

2. Checkout Redirect:
   - Changed from window.location.href + reload to navigate()
   - Added !isProcessing check to empty cart condition

3. Sidebar Shipping for Virtual-Only:
   - Hide Shipping Method section when isVirtualOnly
   - Hide Shipping row in totals when isVirtualOnly

4. License Table:
   - Table creation runs via ensure_tables() on plugins_loaded
2026-01-07 22:26:58 +07:00
Dwindi Ramadhana
2cc20ff760 fix: licensing table creation, consistent meta keys, checkout virtual detection
1. License table auto-creation:
   - Added ensure_tables() check on plugins_loaded
   - Tables created automatically if missing

2. Consistent licensing meta keys:
   - ProductsController now uses _woonoow_licensing_enabled
   - Matches LicensingModule and LicenseManager

3. Checkout virtual-only detection:
   - Added needs_shipping to Cart interface
   - Checkout uses cart.needs_shipping from WooCommerce API
   - Fallback to item-level virtual/downloadable check

4. Login redirect for logged-in users added previously
2026-01-07 22:15:51 +07:00
Dwindi Ramadhana
f334e018fa fix: 4 bugs - checkout virtual, login redirect, licensing, categories
1. Virtual-only checkout:
   - Added 'virtual' and 'downloadable' to CartController response
   - Checkout can now detect virtual-only carts

2. Login redirect:
   - Added useEffect to redirect logged-in users to /my-account

3. License generation:
   - Fixed meta key mismatch (_woonoow_licensing_enabled -> _licensing_enabled)

4. Product categories:
   - Added queryClient.invalidateQueries after creating new category
   - List now refreshes immediately
2026-01-07 21:08:01 +07:00
Dwindi Ramadhana
984f4e2db4 fix: Hide main nav menu in invoice print
- Added data-mainmenu attribute to TopNavBar component
- Added #woonoow-admin-app [data-mainmenu] to print CSS hide rules
- Now hides: header, nav, mainmenu, submenubar, bottomnav
2026-01-06 21:23:36 +07:00
Dwindi Ramadhana
b44c8b767d fix: Perfect invoice print - use specific selectors for app shell
Added specific selectors:
- #woonoow-admin-app header
- #woonoow-admin-app nav
- #woonoow-admin-app [data-submenubar]
- #woonoow-admin-app [data-bottomnav]

These target the exact WooNooW app elements that need hiding.
2026-01-06 21:11:01 +07:00
Dwindi Ramadhana
2b94f26cae fix: Invoice print layout - hide app shell, fix padding
Print CSS:
- Hide WooNooW app nav, header, submenu, bottom nav
- Set print-a4 to absolute positioning for clean print
- Added 15mm padding for print
- Hidden min-height for screen container

Invoice Component:
- Removed inline minHeight to prevent empty second page
- Added max-w-3xl and p-8 for screen display
- Added print:max-w-none print:p-0 for print mode
2026-01-06 21:06:28 +07:00
Dwindi Ramadhana
1cef11a1d2 feat: Create dedicated Invoice and Label pages
Invoice Page (/orders/:id/invoice):
- A4-ready layout (210mm x 297mm)
- Store header, invoice number, QR code
- Billing/shipping address sections
- Styled items table with alternating rows
- Totals summary with conditional display
- Thank you footer
- Back to Order and Print buttons

Label Page (/orders/:id/label):
- 4x6 inch thermal label layout
- Ship To address with phone
- Items list (physical products only)
- Shipping method
- QR code for scanning
- Back to Order and Print buttons

Order Detail:
- Removed print-mode logic
- Removed print-only layouts
- Invoice/Label buttons now link to dedicated pages
- Label button still hidden for virtual-only orders
2026-01-06 20:57:57 +07:00
Dwindi Ramadhana
40aee67c46 feat: Implement A4 invoice layout and hide Label for virtual orders
Invoice:
- Enhanced A4-ready layout with proper structure
- Store header with invoice number
- Billing/shipping address sections
- Styled items table with alternating rows
- Totals summary with conditional display
- Thank you footer

Label:
- Label button now hidden for virtual-only orders
- Uses existing isVirtualOnly detection

Print CSS:
- Added @page A4 size directive
- Print-color-adjust for background colors
- 20mm padding for proper margins

Documentation:
- Updated subscription module plan (comprehensive)
- Updated affiliate module plan (comprehensive)
- Created shipping label standardization plan
2026-01-05 19:16:13 +07:00
Dwindi Ramadhana
2efc6a7605 feat: Add variation-level license duration to product editor
- Added license_duration_days field to ProductVariant type
- Added License Duration input to each variation card
- Backend: ProductsController saves/loads variation-level _license_duration_days meta
- Allows different license periods per variation (e.g., 1-year, 2-year, lifetime)
2026-01-05 17:32:49 +07:00
Dwindi Ramadhana
60d749cd65 feat: Add Copy Cart/Checkout links and licensing settings to product editor
Copy Cart/Checkout Links:
- Added to GeneralTab for simple products (same pattern as variations)
- Link generation with add-to-cart and redirect params

Licensing Settings:
- 'Enable licensing for this product' checkbox in Additional Options
- License settings panel: activation limit, duration (days)
- State management in ProductFormTabbed
- Backend: ProductsController saves/loads licensing meta fields

Backend:
- _licensing_enabled, _license_activation_limit, _license_duration_days post meta
2026-01-05 17:10:04 +07:00
Dwindi Ramadhana
26ab626966 fix: Correct ModuleRegistry method names in ModulesController
Changed enable_module() -> enable()
Changed disable_module() -> disable()

This fixes the module toggle functionality in the admin settings.
2026-01-05 16:53:45 +07:00
Dwindi Ramadhana
3d2bab90ec feat: Complete licensing module with admin and customer UIs
Admin SPA:
- Licenses list page with search, filter, pagination
- License detail page with activation history
- Copy license key, view details, revoke functionality

Customer SPA:
- My Account > Licenses page
- View licenses with activation info
- Copy license key
- Deactivate devices

Backend integration:
- Routes registered in App.tsx and Account/index.tsx
- License nav item in account sidebar (conditional on module enabled)
2026-01-05 16:29:37 +07:00
Dwindi Ramadhana
b367c1fcf8 feat: Add licensing module backend
- LicensingSettings.php with key format, activation limits, expiry settings
- LicenseManager.php with key generation, activation/deactivation, validation
- LicensingModule.php with WooCommerce product meta integration
- LicensesController.php with admin, customer, and public API endpoints
- Database tables: woonoow_licenses, woonoow_license_activations
- has_settings enabled in ModuleRegistry
2026-01-05 16:20:32 +07:00
Dwindi Ramadhana
663e6c13e6 fix: Sync avatar to account sidebar
- Fetch avatar settings in AccountLayout on mount
- Display custom avatar or gravatar in sidebar
- Dispatch woonoow:avatar-updated event on upload/remove
- Listen for event in AccountLayout for real-time sync
2026-01-05 00:31:16 +07:00
Dwindi Ramadhana
86dca3e9c2 fix: Address issues with all 4 features
1. Admin Store Link - Add to WP admin bar (Menu.php) with proper option check
2. Activity Log - Fix Loading text to show correct state after data loads
3. Avatar Upload - Use correct option key woonoow_allow_custom_avatar
4. Downloadable Files - Connect to WooCommerce native:
   - Add downloads array to format_product_full
   - Add downloads/download_limit/download_expiry handling in update_product
   - Add downloads handling in create_product
2026-01-05 00:22:08 +07:00
Dwindi Ramadhana
51c759a4f5 feat: Add customer avatar upload and product downloadable files
Customer Avatar Upload:
- Add /account/avatar endpoint for upload/delete
- Add /account/avatar-settings endpoint for settings
- Update AccountDetails.tsx with avatar upload UI
- Support base64 image upload with validation

Product Downloadable Files:
- Create DownloadsTab component for file management
- Add downloads state to ProductFormTabbed
- Show Downloads tab when 'downloadable' is checked
- Support file name, URL, download limit, and expiry
2026-01-05 00:05:18 +07:00
Dwindi Ramadhana
6c8cbb93e6 feat: Add Store link to admin header and notification activity log
- Add Store link to admin header (visible when customer SPA is enabled)
- Add storeUrl and customerSpaEnabled to WNW_CONFIG in Assets.php and StandaloneAdmin.php
- Update window.d.ts with new WNW_CONFIG properties
- Create ActivityLog.tsx component with search, filters, and pagination
- Add /notifications/logs API endpoint to NotificationsController
- Update Notifications.tsx to link to activity log page
- Add ActivityLog route to App.tsx
2026-01-04 23:51:54 +07:00
72 changed files with 8024 additions and 1227 deletions

View File

@@ -0,0 +1,241 @@
# Affiliate Module Plan
## Overview
Referral tracking with hybrid customer/affiliate roles, integrated as a core plugin module.
---
## Module Architecture
### Core Features
- **Affiliate registration**: Customers can become affiliates
- **Approval workflow**: Manual or auto-approval of affiliates
- **Unique referral links/codes**: Each affiliate gets unique tracking
- **Commission tracking**: Track referrals and calculate earnings
- **Tiered commission rates**: Different rates per product/category/affiliate level
- **Payout management**: Track and process affiliate payouts
- **Affiliate dashboard**: Self-service stats and link generator
### Hybrid Roles
- A customer can also be an affiliate
- No separate user type; affiliate data linked to existing user
- Affiliates can still make purchases (self-referral rules configurable)
---
## Database Schema
### Table: `woonoow_affiliates`
```sql
CREATE TABLE woonoow_affiliates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
status ENUM('pending', 'active', 'rejected', 'suspended') DEFAULT 'pending',
referral_code VARCHAR(50) NOT NULL UNIQUE,
commission_rate DECIMAL(5,2) DEFAULT NULL, -- Override global rate
tier_id BIGINT UNSIGNED DEFAULT NULL,
payment_email VARCHAR(255) DEFAULT NULL,
payment_method VARCHAR(50) DEFAULT NULL,
total_earnings DECIMAL(15,2) DEFAULT 0,
total_unpaid DECIMAL(15,2) DEFAULT 0,
total_paid DECIMAL(15,2) DEFAULT 0,
referral_count INT UNSIGNED DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_referral_code (referral_code)
);
```
### Table: `woonoow_referrals`
```sql
CREATE TABLE woonoow_referrals (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
affiliate_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED DEFAULT NULL,
subtotal DECIMAL(15,2) NOT NULL,
commission_rate DECIMAL(5,2) NOT NULL,
commission_amount DECIMAL(15,2) NOT NULL,
status ENUM('pending', 'approved', 'rejected', 'paid') DEFAULT 'pending',
referral_type ENUM('link', 'code', 'coupon') DEFAULT 'link',
ip_address VARCHAR(45) DEFAULT NULL,
user_agent TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_at DATETIME DEFAULT NULL,
paid_at DATETIME DEFAULT NULL,
INDEX idx_affiliate (affiliate_id),
INDEX idx_order (order_id),
INDEX idx_status (status)
);
```
### Table: `woonoow_payouts`
```sql
CREATE TABLE woonoow_payouts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
affiliate_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(15,2) NOT NULL,
method VARCHAR(50) DEFAULT NULL,
reference VARCHAR(255) DEFAULT NULL, -- Bank ref, PayPal transaction, etc.
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME DEFAULT NULL,
INDEX idx_affiliate (affiliate_id),
INDEX idx_status (status)
);
```
### Table: `woonoow_affiliate_tiers` (Optional)
```sql
CREATE TABLE woonoow_affiliate_tiers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
commission_rate DECIMAL(5,2) NOT NULL,
min_referrals INT UNSIGNED DEFAULT 0, -- Auto-promote at X referrals
min_earnings DECIMAL(15,2) DEFAULT 0, -- Auto-promote at X earnings
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## Backend Structure
```
includes/Modules/Affiliate/
├── AffiliateModule.php # Bootstrap, hooks, tracking
├── AffiliateManager.php # Core logic
├── ReferralTracker.php # Track referral cookies/links
└── AffiliateSettings.php # Settings schema
includes/Api/AffiliatesController.php # REST endpoints
```
### AffiliateManager Methods
```php
- register($user_id) # Register user as affiliate
- approve($affiliate_id) # Approve pending affiliate
- reject($affiliate_id, $reason) # Reject application
- suspend($affiliate_id) # Suspend affiliate
- track_referral($order, $affiliate) # Create referral record
- calculate_commission($order, $affiliate) # Calculate earnings
- approve_referral($referral_id) # Approve pending referral
- create_payout($affiliate_id, $amount) # Create payout request
- process_payout($payout_id) # Mark payout complete
- get_affiliate_stats($affiliate_id) # Dashboard stats
```
### Referral Tracking
1. Affiliate shares link: `yourstore.com/?ref=CODE`
2. ReferralTracker sets cookie: `wnw_ref=CODE` (30 days default)
3. On checkout, check cookie and link to affiliate
4. On order completion, create referral record
---
## REST API Endpoints
### Admin Endpoints
```
GET /affiliates # List affiliates
GET /affiliates/{id} # Affiliate details
PUT /affiliates/{id} # Update affiliate
POST /affiliates/{id}/approve # Approve affiliate
POST /affiliates/{id}/reject # Reject affiliate
GET /affiliates/referrals # All referrals
GET /affiliates/payouts # All payouts
POST /affiliates/payouts/{id}/complete # Complete payout
```
### Customer/Affiliate Endpoints
```
GET /my-affiliate # Check if affiliate
POST /my-affiliate/register # Register as affiliate
GET /my-affiliate/stats # Dashboard stats
GET /my-affiliate/referrals # My referrals
GET /my-affiliate/payouts # My payouts
POST /my-affiliate/payout-request # Request payout
GET /my-affiliate/links # Generate referral links
```
---
## Admin SPA
### Affiliates List (`/marketing/affiliates`)
- Table: Name, Email, Status, Referrals, Earnings, Actions
- Filters: Status, Date range
- Actions: Approve, Reject, View
### Affiliate Detail (`/marketing/affiliates/:id`)
- Affiliate info card
- Stats summary
- Referrals list
- Payouts history
- Action buttons
### Referrals List (`/marketing/affiliates/referrals`)
- All referrals across affiliates
- Filters: Status, Affiliate, Date
### Payouts (`/marketing/affiliates/payouts`)
- Payout requests
- Process payouts
- Payment history
---
## Customer SPA
### Become an Affiliate (`/my-account/affiliate`)
- Registration form (if not affiliate)
- Dashboard (if affiliate)
### Affiliate Dashboard
- Stats: Total Referrals, Pending, Approved, Earnings
- Referral link generator
- Recent referrals
- Payout request button
### My Referrals
- List of referrals with status
- Commission amount
### My Payouts
- Payout history
- Pending amount
- Request payout form
---
## Settings Schema
```php
return [
'enabled' => true,
'registration_type' => 'open', // open, approval, invite
'auto_approve' => false,
'default_commission_rate' => 10, // 10%
'commission_type' => 'percentage', // percentage, flat
'cookie_duration' => 30, // days
'min_payout_amount' => 50,
'payout_methods' => ['bank_transfer', 'paypal'],
'allow_self_referral' => false,
'referral_approval' => 'auto', // auto, manual
'approval_delay_days' => 14, // Wait X days before auto-approve
];
```
---
## Implementation Priority
1. Database tables and AffiliateManager
2. ReferralTracker (cookie-based tracking)
3. Order hook to create referrals
4. Admin SPA affiliates management
5. Customer SPA affiliate dashboard
6. Payout management
7. Tier system (optional)

View File

@@ -0,0 +1,84 @@
# Shipping Label Plan
## Overview
Standardized waybill data structure for shipping label generation.
## Problem
- Different shipping carrier addons (JNE, JNT, SiCepat, etc.) store data differently
- No standard structure for label generation
- Label button needs waybill data to function
## Proposed Solution
### 1. Standardized Meta Key
Order meta: `_shipping_waybill`
### 2. Data Structure
```json
{
"tracking_number": "JNE123456789",
"carrier": "jne",
"carrier_name": "JNE Express",
"service": "REG",
"estimated_days": 3,
"sender": {
"name": "Store Name",
"address": "Full address line 1",
"city": "Jakarta",
"postcode": "12345",
"phone": "08123456789"
},
"recipient": {
"name": "Customer Name",
"address": "Full address line 1",
"city": "Bandung",
"postcode": "40123",
"phone": "08987654321"
},
"package": {
"weight": "1.5",
"weight_unit": "kg",
"dimensions": "20x15x10",
"dimensions_unit": "cm"
},
"label_url": null,
"barcode": "JNE123456789",
"barcode_type": "128",
"created_at": "2026-01-05T12:00:00+07:00"
}
```
### 3. Addon Integration Contract
Shipping addons MUST:
1. Call `update_post_meta($order_id, '_shipping_waybill', $waybill_data)`
2. Use the standard structure above
3. Set `label_url` if carrier provides downloadable PDF
4. Set `barcode` for local label generation
### 4. Label Button Behavior
1. Check if `_shipping_waybill` meta exists on order
2. If `label_url` → open carrier's PDF
3. Otherwise → generate printable label from meta data
### 5. UI Behavior
- Label button hidden if order is virtual-only
- Label button shows "Generate Label" if no waybill yet
- Label button shows "Print Label" if waybill exists
## API Endpoint (Future)
```
POST /woonoow/v1/orders/{id}/generate-waybill
- Calls shipping carrier API
- Stores waybill in standardized format
- Returns waybill data
GET /woonoow/v1/orders/{id}/waybill
- Returns current waybill data
```
## Implementation Priority
1. Define standard structure (this document)
2. Implement Label UI conditional logic
3. Create waybill API endpoint
4. Document for addon developers

View File

@@ -0,0 +1,191 @@
# Subscription Module Plan
## Overview
Recurring product subscriptions with flexible billing, integrated as a core plugin module (like Newsletter/Wishlist/Licensing).
---
## Module Architecture
### Core Features
- **Recurring billing**: Weekly, monthly, yearly, custom intervals
- **Free trials**: X days free before billing starts
- **Sign-up fees**: One-time fee on first subscription
- **Automatic renewals**: Process payment on renewal date
- **Manual renewal**: Allow customers to renew manually
- **Proration**: Calculate prorated amounts on plan changes
- **Pause/Resume**: Allow customers to pause subscriptions
### Product Integration
- Checkbox under "Additional Options": **Enable subscription for this product**
- When enabled, show subscription settings:
- Billing period (weekly/monthly/yearly/custom)
- Billing interval (every X periods)
- Free trial days
- Sign-up fee
- Subscription length (0 = unlimited)
- Variable products: Variation-level subscription settings (different durations/prices per variation)
### Integration with Licensing
- Licenses can be bound to subscriptions
- When subscription is active → license is valid
- When subscription expires/cancelled → license is revoked
- Auto-renewal keeps license active
---
## Database Schema
### Table: `woonoow_subscriptions`
```sql
CREATE TABLE woonoow_subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
variation_id BIGINT UNSIGNED DEFAULT NULL,
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
billing_interval INT UNSIGNED DEFAULT 1,
start_date DATETIME NOT NULL,
trial_end_date DATETIME DEFAULT NULL,
next_payment_date DATETIME DEFAULT NULL,
end_date DATETIME DEFAULT NULL,
last_payment_date DATETIME DEFAULT NULL,
payment_method VARCHAR(100) DEFAULT NULL,
payment_meta LONGTEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_order_id (order_id),
INDEX idx_status (status),
INDEX idx_next_payment (next_payment_date)
);
```
### Table: `woonoow_subscription_orders`
Links subscription to renewal orders:
```sql
CREATE TABLE woonoow_subscription_orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subscription_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_subscription (subscription_id),
INDEX idx_order (order_id)
);
```
---
## Backend Structure
```
includes/Modules/Subscription/
├── SubscriptionModule.php # Bootstrap, hooks, product meta
├── SubscriptionManager.php # Core logic
├── SubscriptionScheduler.php # Cron jobs for renewals
└── SubscriptionSettings.php # Settings schema
includes/Api/SubscriptionsController.php # REST endpoints
```
### SubscriptionManager Methods
```php
- create($order, $product, $user) # Create subscription from order
- renew($subscription_id) # Process renewal
- cancel($subscription_id, $reason) # Cancel subscription
- pause($subscription_id) # Pause subscription
- resume($subscription_id) # Resume paused subscription
- switch($subscription_id, $new_product) # Switch plan
- get_next_payment_date($subscription) # Calculate next date
- process_renewal_payment($subscription) # Charge payment
```
### Cron Jobs
- `woonoow_process_subscription_renewals`: Run daily, process due renewals
- `woonoow_check_expired_subscriptions`: Mark expired subscriptions
---
## REST API Endpoints
### Admin Endpoints
```
GET /subscriptions # List all subscriptions
GET /subscriptions/{id} # Get subscription details
PUT /subscriptions/{id} # Update subscription
POST /subscriptions/{id}/cancel # Cancel subscription
POST /subscriptions/{id}/renew # Force renewal
```
### Customer Endpoints
```
GET /my-subscriptions # Customer's subscriptions
GET /my-subscriptions/{id} # Subscription detail
POST /my-subscriptions/{id}/cancel # Request cancellation
POST /my-subscriptions/{id}/pause # Pause subscription
POST /my-subscriptions/{id}/resume # Resume subscription
```
---
## Admin SPA
### Subscriptions List (`/subscriptions`)
- Table: ID, Customer, Product, Status, Next Payment, Actions
- Filters: Status, Product, Date range
- Actions: View, Cancel, Renew
### Subscription Detail (`/subscriptions/:id`)
- Subscription info card
- Related orders list
- Payment history
- Action buttons: Cancel, Pause, Renew
---
## Customer SPA
### My Subscriptions (`/my-account/subscriptions`)
- List of active/past subscriptions
- Status badges
- Next payment info
- Actions: Cancel, Pause, View
### Subscription Detail
- Product info
- Billing schedule
- Payment history
- Management actions
---
## Settings Schema
```php
return [
'default_status' => 'active',
'button_text_subscribe' => 'Subscribe Now',
'button_text_renew' => 'Renew Subscription',
'allow_customer_cancel' => true,
'allow_customer_pause' => true,
'max_pause_count' => 3,
'renewal_retry_days' => [1, 3, 5], // Retry failed payments
'expire_after_failed_attempts' => 3,
'send_renewal_reminder' => true,
'reminder_days_before' => 3,
];
```
---
## Implementation Priority
1. Database tables and SubscriptionManager
2. Product meta fields for subscription settings
3. Order hook to create subscription
4. Renewal cron job
5. Admin SPA list/detail pages
6. Customer SPA pages
7. Integration with Licensing module

313
RAJAONGKIR_INTEGRATION.md Normal file
View File

@@ -0,0 +1,313 @@
# Rajaongkir Integration with WooNooW SPA
This guide explains how to integrate Rajaongkir's destination selector with WooNooW's customer checkout SPA.
---
## Prerequisites
Before using this integration:
1. **Rajaongkir Plugin Installed & Active**
2. **WooCommerce Shipping Zone Configured**
- Go to: WC → Settings → Shipping → Zones
- Add Rajaongkir method to your Indonesia zone
3. **Valid API Key** (Check in Rajaongkir settings)
4. **Couriers Selected** (In Rajaongkir settings)
---
## Code Snippet
Add this to **Code Snippets** or **WPCodebox**:
```php
<?php
/**
* Rajaongkir Bridge for WooNooW SPA Checkout
*
* Enables searchable destination field in WooNooW checkout
* and bridges data to Rajaongkir plugin.
*/
// ============================================================
// 1. REST API Endpoint: Search destinations via Rajaongkir API
// ============================================================
add_action('rest_api_init', function() {
register_rest_route('woonoow/v1', '/rajaongkir/destinations', [
'methods' => 'GET',
'callback' => 'woonoow_rajaongkir_search_destinations',
'permission_callback' => '__return_true',
'args' => [
'search' => [
'required' => false,
'type' => 'string',
],
],
]);
});
function woonoow_rajaongkir_search_destinations($request) {
$search = sanitize_text_field($request->get_param('search') ?? '');
if (strlen($search) < 3) {
return [];
}
// Check if Rajaongkir plugin is active
if (!class_exists('Cekongkir_API')) {
return new WP_Error('rajaongkir_missing', 'Rajaongkir plugin not active', ['status' => 400]);
}
// Use Rajaongkir's API class for the search
// NOTE: Method is search_destination_api() not search_destination()
$api = Cekongkir_API::get_instance();
$results = $api->search_destination_api($search);
if (is_wp_error($results)) {
error_log('Rajaongkir search error: ' . $results->get_error_message());
return [];
}
if (!is_array($results)) {
error_log('Rajaongkir search returned non-array: ' . print_r($results, true));
return [];
}
// Format for WooNooW's SearchableSelect component
$formatted = [];
foreach ($results as $r) {
$formatted[] = [
'value' => (string) ($r['id'] ?? ''),
'label' => $r['label'] ?? $r['text'] ?? '',
];
}
// Limit results
return array_slice($formatted, 0, 50);
}
// ============================================================
// 2. Add destination field and hide redundant fields for Indonesia
// The destination_id from Rajaongkir contains province/city/subdistrict
// ============================================================
add_filter('woocommerce_checkout_fields', function($fields) {
// Check if Rajaongkir is active
if (!class_exists('Cekongkir_API')) {
return $fields;
}
// Check if store sells to Indonesia (check allowed countries)
$allowed = WC()->countries->get_allowed_countries();
if (!isset($allowed['ID'])) {
return $fields;
}
// Check if Indonesia is the ONLY allowed country
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
// If Indonesia only, hide country/state/city fields (Rajaongkir destination has all this)
if ($indonesia_only) {
// Hide billing fields
if (isset($fields['billing']['billing_country'])) {
$fields['billing']['billing_country']['type'] = 'hidden';
$fields['billing']['billing_country']['default'] = 'ID';
$fields['billing']['billing_country']['required'] = false;
}
if (isset($fields['billing']['billing_state'])) {
$fields['billing']['billing_state']['type'] = 'hidden';
$fields['billing']['billing_state']['required'] = false;
}
if (isset($fields['billing']['billing_city'])) {
$fields['billing']['billing_city']['type'] = 'hidden';
$fields['billing']['billing_city']['required'] = false;
}
if (isset($fields['billing']['billing_postcode'])) {
$fields['billing']['billing_postcode']['type'] = 'hidden';
$fields['billing']['billing_postcode']['required'] = false;
}
// Hide shipping fields
if (isset($fields['shipping']['shipping_country'])) {
$fields['shipping']['shipping_country']['type'] = 'hidden';
$fields['shipping']['shipping_country']['default'] = 'ID';
$fields['shipping']['shipping_country']['required'] = false;
}
if (isset($fields['shipping']['shipping_state'])) {
$fields['shipping']['shipping_state']['type'] = 'hidden';
$fields['shipping']['shipping_state']['required'] = false;
}
if (isset($fields['shipping']['shipping_city'])) {
$fields['shipping']['shipping_city']['type'] = 'hidden';
$fields['shipping']['shipping_city']['required'] = false;
}
if (isset($fields['shipping']['shipping_postcode'])) {
$fields['shipping']['shipping_postcode']['type'] = 'hidden';
$fields['shipping']['shipping_postcode']['required'] = false;
}
}
// Destination field definition (reused for billing and shipping)
$destination_field = [
'type' => 'searchable_select',
'label' => __('Destination (Province, City, Subdistrict)', 'woonoow'),
'required' => $indonesia_only, // Required if Indonesia only
'priority' => 85,
'class' => ['form-row-wide'],
'placeholder' => __('Search destination...', 'woonoow'),
// WooNooW-specific: API endpoint configuration
// NOTE: Path is relative to /wp-json/woonoow/v1
'search_endpoint' => '/rajaongkir/destinations',
'search_param' => 'search',
'min_chars' => 3,
// Custom attribute to indicate this is for Indonesia only
'custom_attributes' => [
'data-show-for-country' => 'ID',
],
];
// Add to billing (used when "Ship to different address" is NOT checked)
$fields['billing']['billing_destination_id'] = $destination_field;
// Add to shipping (used when "Ship to different address" IS checked)
$fields['shipping']['shipping_destination_id'] = $destination_field;
return $fields;
}, 20); // Priority 20 to run after Rajaongkir's own filter
// ============================================================
// 3. Bridge WooNooW shipping data to Rajaongkir session
// Sets destination_id in WC session for Rajaongkir to use
// ============================================================
add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
// Check if Rajaongkir is active
if (!class_exists('Cekongkir_API')) {
return;
}
// For Indonesia-only stores, always set country to ID
$allowed = WC()->countries->get_allowed_countries();
$indonesia_only = count($allowed) === 1 && isset($allowed['ID']);
if ($indonesia_only) {
WC()->customer->set_shipping_country('ID');
WC()->customer->set_billing_country('ID');
} elseif (!empty($shipping['country'])) {
WC()->customer->set_shipping_country($shipping['country']);
WC()->customer->set_billing_country($shipping['country']);
}
// Only process Rajaongkir for Indonesia
$country = $shipping['country'] ?? WC()->customer->get_shipping_country();
if ($country !== 'ID') {
// Clear destination for non-Indonesia
WC()->session->__unset('selected_destination_id');
WC()->session->__unset('selected_destination_label');
return;
}
// Get destination_id from shipping data (various possible keys)
$destination_id = $shipping['destination_id']
?? $shipping['shipping_destination_id']
?? $shipping['billing_destination_id']
?? null;
if (empty($destination_id)) {
return;
}
// Set session for Rajaongkir
WC()->session->set('selected_destination_id', intval($destination_id));
// Also set label if provided
$label = $shipping['destination_label']
?? $shipping['shipping_destination_id_label']
?? $shipping['billing_destination_id_label']
?? '';
if ($label) {
WC()->session->set('selected_destination_label', sanitize_text_field($label));
}
// Clear shipping cache to force recalculation
WC()->session->set('shipping_for_package_0', false);
}, 10, 2);
```
---
## Testing
### 1. Test the API Endpoint
After adding the snippet:
```
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=bandung
```
Should return:
```json
[
{"value": "1234", "label": "Jawa Barat, Bandung, Kota"},
...
]
```
### 2. Test Checkout Flow
1. Add product to cart
2. Go to SPA checkout: `/store/checkout`
3. Set country to Indonesia
4. "Destination" field should appear
5. Type 2+ characters to search
6. Select a destination
7. Rajaongkir rates should appear
---
## Troubleshooting
### API returns empty?
Check `debug.log` for errors:
```php
// Added logging in the search function
error_log('Rajaongkir search error: ...');
```
Common issues:
- Invalid Rajaongkir API key
- Rajaongkir plugin not active
- API quota exceeded
### Field not appearing?
1. Ensure snippet is active
2. Check if store sells to Indonesia
3. Check browser console for JS errors
### Rajaongkir rates not showing?
1. Check session is set:
```php
add_action('woonoow/shipping/before_calculate', function($shipping) {
error_log('Shipping data: ' . print_r($shipping, true));
}, 5);
```
2. Check Rajaongkir is enabled in shipping zone
---
## Known Limitations
1. **Field visibility**: Currently field always shows for checkout. Future improvement: hide in React when country ≠ ID.
2. **Session timing**: Must select destination before calculating shipping.
---
## Related Documentation
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - WooNooW hooks reference

219
SHIPPING_BRIDGE_PATTERN.md Normal file
View File

@@ -0,0 +1,219 @@
# WooNooW Shipping Bridge Pattern
This document describes a generic pattern for integrating any external shipping API with WooNooW's SPA checkout.
---
## Overview
WooNooW provides hooks and endpoints that allow any shipping plugin to:
1. **Register custom checkout fields** (searchable selects, dropdowns, etc.)
2. **Bridge data to the plugin's session/API** before shipping calculation
3. **Display live rates** from external APIs
This pattern is NOT specific to Rajaongkir - it can be used for any shipping provider.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ WooNooW Customer SPA │
├─────────────────────────────────────────────────────────────────┤
│ 1. Checkout loads → calls /checkout/fields │
│ 2. Renders custom fields (e.g., searchable destination) │
│ 3. User fills form → calls /checkout/shipping-rates │
│ 4. Hook triggers → shipping plugin calculates rates │
│ 5. Rates displayed → user selects → order submitted │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Your Bridge Snippet │
├─────────────────────────────────────────────────────────────────┤
│ • woocommerce_checkout_fields → Add custom fields │
│ • register_rest_route → API endpoint for field data │
│ • woonoow/shipping/before_calculate → Set session/data │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Shipping Plugin (Any) │
├─────────────────────────────────────────────────────────────────┤
│ • Reads from WC session or customer data │
│ • Calls external API (Rajaongkir, Sicepat, JNE, etc.) │
│ • Returns rates via get_rates_for_package() │
└─────────────────────────────────────────────────────────────────┘
```
---
## Generic Bridge Template
```php
<?php
/**
* [PROVIDER_NAME] Bridge for WooNooW SPA Checkout
*
* Replace [PROVIDER_NAME], [PROVIDER_CLASS], and [PROVIDER_SESSION_KEY]
* with your shipping plugin's specifics.
*/
// ============================================================
// 1. REST API Endpoint: Search/fetch data from provider
// ============================================================
add_action('rest_api_init', function() {
register_rest_route('woonoow/v1', '/[provider]/search', [
'methods' => 'GET',
'callback' => 'woonoow_[provider]_search',
'permission_callback' => '__return_true', // Public for customer use
'args' => [
'search' => ['type' => 'string', 'required' => false],
],
]);
});
function woonoow_[provider]_search($request) {
$search = sanitize_text_field($request->get_param('search') ?? '');
if (strlen($search) < 3) {
return [];
}
// Check if plugin is active
if (!class_exists('[PROVIDER_CLASS]')) {
return new WP_Error('[provider]_missing', 'Plugin not active', ['status' => 400]);
}
// Call provider's API
// $results = [PROVIDER_CLASS]::search($search);
// Format for WooNooW's SearchableSelect
$formatted = [];
foreach ($results as $r) {
$formatted[] = [
'value' => (string) $r['id'],
'label' => $r['name'],
];
}
return array_slice($formatted, 0, 50);
}
// ============================================================
// 2. Add custom field(s) to checkout
// ============================================================
add_filter('woocommerce_checkout_fields', function($fields) {
if (!class_exists('[PROVIDER_CLASS]')) {
return $fields;
}
$custom_field = [
'type' => 'searchable_select', // or 'select', 'text'
'label' => __('[Field Label]', 'woonoow'),
'required' => true,
'priority' => 85,
'class' => ['form-row-wide'],
'placeholder' => __('Search...', 'woonoow'),
'search_endpoint' => '/[provider]/search', // Relative to /wp-json/woonoow/v1
'search_param' => 'search',
'min_chars' => 3,
];
$fields['billing']['billing_[provider]_field'] = $custom_field;
$fields['shipping']['shipping_[provider]_field'] = $custom_field;
return $fields;
}, 20);
// ============================================================
// 3. Bridge data to provider before shipping calculation
// ============================================================
add_action('woonoow/shipping/before_calculate', function($shipping, $items) {
if (!class_exists('[PROVIDER_CLASS]')) {
return;
}
// Get custom field value from shipping data
$field_value = $shipping['[provider]_field']
?? $shipping['shipping_[provider]_field']
?? $shipping['billing_[provider]_field']
?? null;
if (empty($field_value)) {
return;
}
// Set in WC session for the shipping plugin to use
WC()->session->set('[PROVIDER_SESSION_KEY]', $field_value);
// Clear shipping cache to force recalculation
WC()->session->set('shipping_for_package_0', false);
}, 10, 2);
```
---
## Supported Field Types
| Type | Description | Use Case |
|------|-------------|----------|
| `searchable_select` | Dropdown with API search | Destinations, locations, service points |
| `select` | Static dropdown | Service types, delivery options |
| `text` | Free text input | Reference numbers, notes |
| `hidden` | Hidden field | Default values, auto-set data |
---
## WooNooW Hooks Reference
| Hook | Type | Description |
|------|------|-------------|
| `woonoow/shipping/before_calculate` | Action | Called before shipping rates are calculated |
| `woocommerce_checkout_fields` | Filter | Standard WC filter for checkout fields |
---
## Examples
### Example 1: Sicepat Integration
```php
// Endpoint
register_rest_route('woonoow/v1', '/sicepat/destinations', ...);
// Session key
WC()->session->set('sicepat_destination_code', $code);
```
### Example 2: JNE Direct API
```php
// Endpoint for origin selection
register_rest_route('woonoow/v1', '/jne/origins', ...);
register_rest_route('woonoow/v1', '/jne/destinations', ...);
// Multiple session keys
WC()->session->set('jne_origin', $origin);
WC()->session->set('jne_destination', $destination);
```
---
## Checklist for New Integration
- [ ] Identify the shipping plugin's class name
- [ ] Find what session/data it reads for API calls
- [ ] Create REST endpoint for searchable data
- [ ] Add checkout field(s) via filter
- [ ] Bridge data via `woonoow/shipping/before_calculate`
- [ ] Test shipping rate calculation
- [ ] Document in dedicated `[PROVIDER]_INTEGRATION.md`
---
## Related Documentation
- [RAJAONGKIR_INTEGRATION.md](RAJAONGKIR_INTEGRATION.md) - Rajaongkir-specific implementation
- [SHIPPING_INTEGRATION.md](SHIPPING_INTEGRATION.md) - General shipping patterns
- [HOOKS_REGISTRY.md](HOOKS_REGISTRY.md) - All WooNooW hooks

View File

@@ -13,12 +13,16 @@ import OrdersIndex from '@/routes/Orders';
import OrderNew from '@/routes/Orders/New'; import OrderNew from '@/routes/Orders/New';
import OrderEdit from '@/routes/Orders/Edit'; import OrderEdit from '@/routes/Orders/Edit';
import OrderDetail from '@/routes/Orders/Detail'; import OrderDetail from '@/routes/Orders/Detail';
import OrderInvoice from '@/routes/Orders/Invoice';
import OrderLabel from '@/routes/Orders/Label';
import ProductsIndex from '@/routes/Products'; import ProductsIndex from '@/routes/Products';
import ProductNew from '@/routes/Products/New'; import ProductNew from '@/routes/Products/New';
import ProductEdit from '@/routes/Products/Edit'; import ProductEdit from '@/routes/Products/Edit';
import ProductCategories from '@/routes/Products/Categories'; import ProductCategories from '@/routes/Products/Categories';
import ProductTags from '@/routes/Products/Tags'; import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes'; import ProductAttributes from '@/routes/Products/Attributes';
import Licenses from '@/routes/Products/Licenses';
import LicenseDetail from '@/routes/Products/Licenses/Detail';
import CouponsIndex from '@/routes/Marketing/Coupons'; import CouponsIndex from '@/routes/Marketing/Coupons';
import CouponNew from '@/routes/Marketing/Coupons/New'; import CouponNew from '@/routes/Marketing/Coupons/New';
import CouponEdit from '@/routes/Marketing/Coupons/Edit'; import CouponEdit from '@/routes/Marketing/Coupons/Edit';
@@ -194,7 +198,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
const navTree = (window as any).WNW_NAV_TREE || []; const navTree = (window as any).WNW_NAV_TREE || [];
return ( return (
<div className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}> <div data-mainmenu className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2"> <div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
{navTree.map((item: any) => { {navTree.map((item: any) => {
const IconComponent = iconMap[item.icon] || Package; const IconComponent = iconMap[item.icon] || Package;
@@ -243,6 +247,7 @@ import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfigurati
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration'; import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization'; import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate'; import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
import ActivityLog from '@/routes/Settings/Notifications/ActivityLog';
import SettingsDeveloper from '@/routes/Settings/Developer'; import SettingsDeveloper from '@/routes/Settings/Developer';
import SettingsModules from '@/routes/Settings/Modules'; import SettingsModules from '@/routes/Settings/Modules';
import ModuleSettings from '@/routes/Settings/ModuleSettings'; import ModuleSettings from '@/routes/Settings/ModuleSettings';
@@ -462,6 +467,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
> >
<span>{__('WordPress')}</span> <span>{__('WordPress')}</span>
</a> </a>
{window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground" className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
@@ -471,6 +487,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
</button> </button>
</> </>
)} )}
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<ThemeToggle /> <ThemeToggle />
{showToggle && ( {showToggle && (
<button <button
@@ -518,12 +545,16 @@ function AppRoutes() {
<Route path="/products/categories" element={<ProductCategories />} /> <Route path="/products/categories" element={<ProductCategories />} />
<Route path="/products/tags" element={<ProductTags />} /> <Route path="/products/tags" element={<ProductTags />} />
<Route path="/products/attributes" element={<ProductAttributes />} /> <Route path="/products/attributes" element={<ProductAttributes />} />
<Route path="/products/licenses" element={<Licenses />} />
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
{/* Orders */} {/* Orders */}
<Route path="/orders" element={<OrdersIndex />} /> <Route path="/orders" element={<OrdersIndex />} />
<Route path="/orders/new" element={<OrderNew />} /> <Route path="/orders/new" element={<OrderNew />} />
<Route path="/orders/:id" element={<OrderDetail />} /> <Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/orders/:id/edit" element={<OrderEdit />} /> <Route path="/orders/:id/edit" element={<OrderEdit />} />
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
<Route path="/orders/:id/label" element={<OrderLabel />} />
{/* Coupons (under Marketing) */} {/* Coupons (under Marketing) */}
<Route path="/coupons" element={<CouponsIndex />} /> <Route path="/coupons" element={<CouponsIndex />} />
@@ -560,6 +591,7 @@ function AppRoutes() {
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} /> <Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} /> <Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} /> <Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
<Route path="/settings/brand" element={<SettingsIndex />} /> <Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} /> <Route path="/settings/developer" element={<SettingsDeveloper />} />
<Route path="/settings/modules" element={<SettingsModules />} /> <Route path="/settings/modules" element={<SettingsModules />} />

View File

@@ -0,0 +1,270 @@
import * as React from 'react';
import { useState } from 'react';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { api } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
export interface CheckoutField {
key: string;
fieldset: 'billing' | 'shipping' | 'account' | 'order';
type: string;
label: string;
placeholder?: string;
required: boolean;
hidden: boolean;
class?: string[];
priority: number;
options?: Record<string, string> | null;
custom: boolean;
autocomplete?: string;
validate?: string[];
input_class?: string[];
custom_attributes?: Record<string, string>;
default?: string;
// For searchable_select type
search_endpoint?: string | null;
search_param?: string;
min_chars?: number;
}
interface DynamicCheckoutFieldProps {
field: CheckoutField;
value: string;
onChange: (value: string) => void;
countryOptions?: { value: string; label: string }[];
stateOptions?: { value: string; label: string }[];
}
interface SearchOption {
value: string;
label: string;
}
export function DynamicCheckoutField({
field,
value,
onChange,
countryOptions = [],
stateOptions = [],
}: DynamicCheckoutFieldProps) {
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
const [isSearching, setIsSearching] = useState(false);
// Handle API search for searchable_select
const handleApiSearch = async (searchTerm: string) => {
if (!field.search_endpoint) return;
const minChars = field.min_chars || 2;
if (searchTerm.length < minChars) {
setSearchOptions([]);
return;
}
setIsSearching(true);
try {
const param = field.search_param || 'search';
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
setSearchOptions(Array.isArray(results) ? results : []);
} catch (error) {
console.error('Search failed:', error);
setSearchOptions([]);
} finally {
setIsSearching(false);
}
};
// Don't render hidden fields
if (field.hidden || field.type === 'hidden') {
return null;
}
// Get field key without prefix (billing_, shipping_)
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
// Determine CSS classes
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
field.class?.includes('form-row-wide');
const wrapperClass = isWide ? 'md:col-span-2' : '';
// Render based on type
const renderInput = () => {
switch (field.type) {
case 'country':
return (
<SearchableSelect
options={countryOptions}
value={value}
onChange={onChange}
placeholder={field.placeholder || 'Select country'}
disabled={countryOptions.length <= 1}
/>
);
case 'state':
return stateOptions.length > 0 ? (
<SearchableSelect
options={stateOptions}
value={value}
onChange={onChange}
placeholder={field.placeholder || 'Select state'}
/>
) : (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete}
/>
);
case 'select':
if (field.options && Object.keys(field.options).length > 0) {
const options = Object.entries(field.options).map(([val, label]) => ({
value: val,
label: String(label),
}));
return (
<SearchableSelect
options={options}
value={value}
onChange={onChange}
placeholder={field.placeholder || `Select ${field.label}`}
/>
);
}
return null;
case 'searchable_select':
return (
<SearchableSelect
options={searchOptions}
value={value}
onChange={(v) => {
onChange(v);
// Store label for display
const selected = searchOptions.find(o => o.value === v);
if (selected) {
const event = new CustomEvent('woonoow:field_label', {
detail: { key: field.key + '_label', value: selected.label }
});
document.dispatchEvent(event);
}
}}
onSearch={handleApiSearch}
isSearching={isSearching}
placeholder={field.placeholder || `Search ${field.label}...`}
emptyLabel={
isSearching
? 'Searching...'
: `Type at least ${field.min_chars || 2} characters to search`
}
/>
);
case 'textarea':
return (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
/>
);
case 'checkbox':
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={value === '1' || value === 'true'}
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
className="w-4 h-4"
/>
<span>{field.label}</span>
</label>
);
case 'radio':
if (field.options) {
return (
<div className="space-y-2">
{Object.entries(field.options).map(([val, label]) => (
<label key={val} className="flex items-center gap-2">
<input
type="radio"
name={field.key}
value={val}
checked={value === val}
onChange={() => onChange(val)}
className="w-4 h-4"
/>
<span>{String(label)}</span>
</label>
))}
</div>
);
}
return null;
case 'email':
return (
<Input
type="email"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete || 'email'}
/>
);
case 'tel':
return (
<Input
type="tel"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete || 'tel'}
/>
);
// Default: text input
default:
return (
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete}
/>
);
}
};
// Don't render label for checkbox (it's inline)
if (field.type === 'checkbox') {
return (
<div className={wrapperClass}>
{renderInput()}
</div>
);
}
return (
<div className={wrapperClass}>
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{renderInput()}
</div>
);
}

View File

@@ -13,7 +13,7 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal container={document.getElementById("woonoow-admin-app")}>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}

View File

@@ -34,6 +34,7 @@
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
@@ -63,9 +64,22 @@
} }
@layer base { @layer base {
* { @apply border-border; } * {
body { @apply bg-background text-foreground; } @apply border-border;
h1, h2, h3, h4, h5, h6 { @apply text-foreground; } }
body {
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-foreground;
}
/* Override WordPress common.css focus/active styles */ /* Override WordPress common.css focus/active styles */
a:focus, a:focus,
@@ -126,11 +140,14 @@
/* Page defaults for print */ /* Page defaults for print */
@page { @page {
size: auto; /* let the browser choose */ size: auto;
margin: 12mm; /* comfortable default */ /* let the browser choose */
margin: 12mm;
/* comfortable default */
} }
@media print { @media print {
/* Hide WordPress admin chrome */ /* Hide WordPress admin chrome */
#adminmenuback, #adminmenuback,
#adminmenuwrap, #adminmenuwrap,
@@ -139,44 +156,173 @@
#wpfooter, #wpfooter,
#screen-meta, #screen-meta,
.notice, .notice,
.update-nag { display: none !important; } .update-nag {
display: none !important;
}
/* Hide WooNooW app shell - all nav, header, submenu elements */
#woonoow-admin-app header,
#woonoow-admin-app nav,
#woonoow-admin-app [data-submenubar],
#woonoow-admin-app [data-bottomnav],
.woonoow-app-header,
.woonoow-topnav,
.woonoow-bottom-nav,
.woonoow-submenu,
.no-print {
display: none !important;
}
/* Reset layout to full-bleed for our app */ /* Reset layout to full-bleed for our app */
html, body, #wpwrap, #wpcontent { background: #fff !important; margin: 0 !important; padding: 0 !important; } html,
#woonoow-admin-app, #woonoow-admin-app > div { margin: 0 !important; padding: 0 !important; max-width: 100% !important; } body,
#wpwrap,
#wpcontent,
#woonoow-admin-app,
#woonoow-admin-app>div,
.woonoow-app {
background: #fff !important;
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
width: 100% !important;
min-height: auto !important;
overflow: visible !important;
}
/* Hide elements flagged as no-print, reveal print-only */ /* Ensure print content is visible and takes full page */
.no-print { display: none !important; } .print-a4 {
.print-only { display: block !important; } position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
min-height: auto !important;
height: auto !important;
padding: 15mm !important;
margin: 0 !important;
box-shadow: none !important;
background: white !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.print-a4 * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Ensure backgrounds print */
.print-a4 .bg-gray-50 {
background-color: #f9fafb !important;
}
.print-a4 .bg-gray-900 {
background-color: #111827 !important;
}
.print-a4 .text-white {
color: white !important;
}
/* Hide outer container styling */
.min-h-screen {
min-height: auto !important;
background: white !important;
padding: 0 !important;
}
.print-only {
display: block !important;
}
/* Improve table row density on paper */ /* Improve table row density on paper */
.print-tight tr > * { padding-top: 6px !important; padding-bottom: 6px !important; } .print-tight tr>* {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
} }
/* By default, label-only content stays hidden unless in print or label mode */ /* By default, label-only content stays hidden unless in print or label mode */
.print-only { display: none; } .print-only {
display: none;
}
/* Label mode toggled by router (?mode=label) */ /* Label mode toggled by router (?mode=label) */
.woonoow-label-mode .print-only { display: block; } .woonoow-label-mode .print-only {
display: block;
}
.woonoow-label-mode .no-print-label, .woonoow-label-mode .no-print-label,
.woonoow-label-mode .wp-header-end, .woonoow-label-mode .wp-header-end,
.woonoow-label-mode .wrap { display: none !important; } .woonoow-label-mode .wrap {
display: none !important;
}
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */ /* Optional page presets (opt-in by adding the class to a wrapper before printing) */
.print-a4 {} .print-a4 {}
.print-letter {} .print-letter {}
.print-4x6 {} .print-4x6 {}
@media print { @media print {
.print-a4 { }
/* A4 Invoice layout */
@page {
size: A4;
margin: 0;
}
.print-a4 {
width: 210mm !important;
min-height: 297mm !important;
padding: 20mm !important;
margin: 0 auto !important;
box-sizing: border-box !important;
background: white !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.print-a4 * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Ensure backgrounds print */
.print-a4 .bg-gray-50 {
background-color: #f9fafb !important;
}
.print-a4 .bg-gray-900 {
background-color: #111827 !important;
}
.print-a4 .text-white {
color: white !important;
}
.print-letter {} .print-letter {}
/* Thermal label (4x6in) with minimal margins */ /* Thermal label (4x6in) with minimal margins */
.print-4x6 { width: 6in; } .print-4x6 {
.print-4x6 * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } width: 6in;
}
.print-4x6 * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
} }
/* --- WooNooW: Popper menus & fullscreen fixes --- */ /* --- WooNooW: Popper menus & fullscreen fixes --- */
[data-radix-popper-content-wrapper] { z-index: 2147483647 !important; } [data-radix-popper-content-wrapper] {
body.woonoow-fullscreen .woonoow-app { overflow: visible; } z-index: 2147483647 !important;
}
body.woonoow-fullscreen .woonoow-app {
overflow: visible;
}
/* --- WooCommerce Admin Notices --- */ /* --- WooCommerce Admin Notices --- */
.woocommerce-message, .woocommerce-message,

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, OrdersApi } from '@/lib/api'; import { api, OrdersApi } from '@/lib/api';
import { formatRelativeOrDate } from '@/lib/dates'; import { formatRelativeOrDate } from '@/lib/dates';
import { formatMoney } from '@/lib/currency'; import { formatMoney } from '@/lib/currency';
import { ArrowLeft, Printer, ExternalLink, Loader2, Ticket, FileText, Pencil, RefreshCw } from 'lucide-react'; import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { import {
AlertDialog, AlertDialog,
@@ -48,28 +48,7 @@ export default function OrderShow() {
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW'; const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
const { setPageHeader, clearPageHeader } = usePageHeader(); const { setPageHeader, clearPageHeader } = usePageHeader();
const [params, setParams] = useSearchParams();
const mode = params.get('mode'); // undefined | 'label' | 'invoice'
const isPrintMode = mode === 'label' || mode === 'invoice';
function triggerPrint(nextMode: 'label' | 'invoice') {
params.set('mode', nextMode);
setParams(params, { replace: true });
setTimeout(() => {
window.print();
params.delete('mode');
setParams(params, { replace: true });
}, 50);
}
function printLabel() {
triggerPrint('label');
}
function printInvoice() {
triggerPrint('invoice');
}
const [showRetryDialog, setShowRetryDialog] = useState(false); const [showRetryDialog, setShowRetryDialog] = useState(false);
const qrRef = useRef<HTMLCanvasElement | null>(null);
const q = useQuery({ const q = useQuery({
queryKey: ['order', id], queryKey: ['order', id],
enabled: !!id, enabled: !!id,
@@ -154,7 +133,7 @@ export default function OrderShow() {
// Set contextual header with Back button and Edit action // Set contextual header with Back button and Edit action
useEffect(() => { useEffect(() => {
if (!order || isPrintMode) { if (!order) {
clearPageHeader(); clearPageHeader();
return; return;
} }
@@ -178,39 +157,21 @@ export default function OrderShow() {
); );
return () => clearPageHeader(); return () => clearPageHeader();
}, [order, isPrintMode, id, setPageHeader, clearPageHeader, nav]); }, [order, id, setPageHeader, clearPageHeader, nav]);
useEffect(() => {
if (!isPrintMode || !qrRef.current || !order) return;
(async () => {
try {
const mod = await import( 'qrcode' );
const QR = (mod as any).default || (mod as any);
const text = `ORDER:${order.number || id}`;
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
} catch (_) {
// optional dependency not installed; silently ignore
}
})();
}, [mode, order, id, isPrintMode]);
return ( return (
<div className={`space-y-4 ${mode === 'label' ? 'woonoow-label-mode' : ''}`}> <div className="space-y-4">
{/* Desktop extra actions - hidden on mobile, shown on desktop */} {/* Desktop extra actions - hidden on mobile, shown on desktop */}
<div className="hidden md:flex flex-wrap items-center gap-2"> <div className="hidden md:flex flex-wrap items-center gap-2">
<div className="ml-auto flex flex-wrap items-center gap-2"> <div className="ml-auto flex flex-wrap items-center gap-2">
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print order')}> <Link to={`/orders/${id}/invoice`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
<Printer className="w-4 h-4" /> {__('Print')}
</button>
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
<FileText className="w-4 h-4" /> {__('Invoice')} <FileText className="w-4 h-4" /> {__('Invoice')}
</button>
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" to={`/orders`} title={__('Back to orders list')}>
<ExternalLink className="w-4 h-4" /> {__('Orders')}
</Link> </Link>
{!isVirtualOnly && (
<Link to={`/orders/${id}/label`} className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 hover:bg-gray-50">
<Ticket className="w-4 h-4" /> {__('Label')}
</Link>
)}
</div> </div>
</div> </div>
@@ -473,84 +434,6 @@ export default function OrderShow() {
</div> </div>
</div> </div>
)} )}
{/* Print-only layouts */}
{order && (
<div className="print-only">
{mode === 'invoice' && (
<div className="max-w-[800px] mx-auto p-6 text-sm">
<div className="flex items-start justify-between mb-6">
<div>
<div className="text-xl font-semibold">Invoice</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts||0)*1000).toLocaleString()}</div>
</div>
<div className="text-right">
<div className="font-medium">{siteTitle}</div>
<div className="opacity-60 text-xs">{window.location.origin}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<div className="text-xs opacity-60 mb-1">{__('Bill To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.billing?.address || order.billing?.name || '' }} />
</div>
<div className="text-right">
<canvas ref={qrRef} className="inline-block w-24 h-24 border" />
</div>
</div>
<table className="w-full border-collapse mb-6">
<thead>
<tr>
<th className="text-left border-b py-2 pr-2">Product</th>
<th className="text-right border-b py-2 px-2">Qty</th>
<th className="text-right border-b py-2 px-2">Subtotal</th>
<th className="text-right border-b py-2 pl-2">Total</th>
</tr>
</thead>
<tbody>
{(order.items || []).map((it:any) => (
<tr key={it.id}>
<td className="py-1 pr-2">{it.name}</td>
<td className="py-1 px-2 text-right">×{it.qty}</td>
<td className="py-1 px-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="py-1 pl-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-end">
<div className="min-w-[260px]">
<div className="flex justify-between"><span>Subtotal</span><span><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Discount</span><span><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Shipping</span><span><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Tax</span><span><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between font-semibold border-t mt-2 pt-2"><span>Total</span><span><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span></div>
</div>
</div>
</div>
)}
{mode === 'label' && (
<div className="p-4 print-4x6">
<div className="border rounded p-4 h-full">
<div className="flex justify-between items-start mb-3">
<div className="text-base font-semibold">#{order.number}</div>
<canvas ref={qrRef} className="w-24 h-24 border" />
</div>
<div className="mb-3">
<div className="text-xs opacity-60 mb-1">{__('Ship To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }} />
</div>
<div className="text-xs opacity-60 mb-1">{__('Items')}</div>
<ul className="text-sm list-disc pl-4">
{(order.items||[]).map((it:any)=> (
<li key={it.id}>{it.name} ×{it.qty}</li>
))}
</ul>
</div>
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,211 @@
import React, { useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/currency';
import { ArrowLeft, Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { InlineLoadingState } from '@/components/LoadingState';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
function Money({ value, currency, symbol }: { value?: number; currency?: string; symbol?: string }) {
return <>{formatMoney(value, { currency, symbol })}</>;
}
export default function Invoice() {
const { id } = useParams<{ id: string }>();
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
const qrRef = useRef<HTMLCanvasElement | null>(null);
const q = useQuery({
queryKey: ['order', id],
enabled: !!id,
queryFn: () => api.get(`/orders/${id}`),
});
const order = q.data;
// Check if all items are virtual
const isVirtualOnly = React.useMemo(() => {
if (!order?.items || order.items.length === 0) return false;
return order.items.every((item: any) => item.virtual || item.downloadable);
}, [order?.items]);
// Generate QR code
useEffect(() => {
if (!qrRef.current || !order) return;
(async () => {
try {
const mod = await import('qrcode');
const QR = (mod as any).default || (mod as any);
const text = `ORDER:${order.number || id}`;
await QR.toCanvas(qrRef.current, text, { width: 96, margin: 1 });
} catch (_) {
// QR library not available
}
})();
}, [order, id]);
const handlePrint = () => {
window.print();
};
if (q.isLoading) {
return <InlineLoadingState message={__('Loading invoice...')} />;
}
if (q.isError) {
return (
<ErrorCard
title={__('Failed to load order')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
);
}
if (!order) return null;
return (
<div className="min-h-screen bg-gray-100">
{/* Actions bar - hidden on print */}
<div className="no-print bg-white border-b sticky top-0 z-10">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<Link to={`/orders/${id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back to Order')}
</Button>
</Link>
<Button onClick={handlePrint} size="sm">
<Printer className="w-4 h-4 mr-2" />
{__('Print Invoice')}
</Button>
</div>
</div>
{/* Invoice content */}
<div className="py-8 print:py-0 print:bg-white">
<div className="print-a4 bg-white shadow-lg print:shadow-none mx-auto max-w-3xl p-8 print:max-w-none print:p-0">
{/* Invoice Header */}
<div className="flex items-start justify-between mb-8 pb-6 border-b-2 border-gray-200">
<div>
<div className="text-3xl font-bold text-gray-900">{__('INVOICE')}</div>
<div className="text-sm text-gray-600 mt-1">#{order.number}</div>
</div>
<div className="text-right">
<div className="text-xl font-semibold text-gray-900">{siteTitle}</div>
<div className="text-sm text-gray-500 mt-1">{window.location.origin}</div>
</div>
</div>
{/* Invoice Meta */}
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{__('Invoice Date')}</div>
<div className="text-sm text-gray-900">{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}</div>
{order.payment_method && (
<>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2 mt-4">{__('Payment Method')}</div>
<div className="text-sm text-gray-900">{order.payment_method}</div>
</>
)}
</div>
<div className="flex justify-end">
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
</div>
</div>
{/* Billing & Shipping */}
<div className="grid grid-cols-2 gap-8 mb-8">
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Bill To')}</div>
<div className="text-sm text-gray-900 font-medium">{order.billing?.name || '—'}</div>
{order.billing?.email && <div className="text-sm text-gray-600">{order.billing.email}</div>}
{order.billing?.phone && <div className="text-sm text-gray-600">{order.billing.phone}</div>}
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
</div>
{!isVirtualOnly && order.shipping?.name && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">{__('Ship To')}</div>
<div className="text-sm text-gray-900 font-medium">{order.shipping?.name || '—'}</div>
<div className="text-sm text-gray-600 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
</div>
)}
</div>
{/* Items Table */}
<table className="w-full mb-8" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr className="bg-gray-900 text-white">
<th className="text-left py-3 px-4 text-sm font-semibold">{__('Product')}</th>
<th className="text-center py-3 px-4 text-sm font-semibold w-20">{__('Qty')}</th>
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Price')}</th>
<th className="text-right py-3 px-4 text-sm font-semibold w-32">{__('Total')}</th>
</tr>
</thead>
<tbody>
{(order.items || []).map((it: any, idx: number) => (
<tr key={it.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="py-3 px-4 text-sm">
<div className="font-medium text-gray-900">{it.name}</div>
{it.sku && <div className="text-xs text-gray-500">SKU: {it.sku}</div>}
</td>
<td className="py-3 px-4 text-sm text-center text-gray-600">{it.qty}</td>
<td className="py-3 px-4 text-sm text-right text-gray-600">
<Money value={it.subtotal / it.qty} currency={order.currency} symbol={order.currency_symbol} />
</td>
<td className="py-3 px-4 text-sm text-right font-medium text-gray-900">
<Money value={it.total} currency={order.currency} symbol={order.currency_symbol} />
</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="flex justify-end mb-8">
<div className="w-72">
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Subtotal')}</span>
<span className="text-gray-900"><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
{(order.totals?.discount || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Discount')}</span>
<span className="text-green-600">-<Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
{(order.totals?.shipping || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Shipping')}</span>
<span className="text-gray-900"><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
{(order.totals?.tax || 0) > 0 && (
<div className="flex justify-between py-2 text-sm">
<span className="text-gray-600">{__('Tax')}</span>
<span className="text-gray-900"><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span>
</div>
)}
<div className="flex justify-between py-3 mt-2 border-t-2 border-gray-900">
<span className="text-lg font-bold text-gray-900">{__('Total')}</span>
<span className="text-lg font-bold text-gray-900">
<Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} />
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-auto pt-8 border-t border-gray-200 text-center text-xs text-gray-500">
<p>{__('Thank you for your business!')}</p>
<p className="mt-1">{siteTitle} {window.location.origin}</p>
</div>
</div>
</div>
</div >
);
}

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { ArrowLeft, Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { InlineLoadingState } from '@/components/LoadingState';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
export default function Label() {
const { id } = useParams<{ id: string }>();
const qrRef = useRef<HTMLCanvasElement | null>(null);
const q = useQuery({
queryKey: ['order', id],
enabled: !!id,
queryFn: () => api.get(`/orders/${id}`),
});
const order = q.data;
// Generate QR code
useEffect(() => {
if (!qrRef.current || !order) return;
(async () => {
try {
const mod = await import('qrcode');
const QR = (mod as any).default || (mod as any);
const text = `ORDER:${order.number || id}`;
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
} catch (_) {
// QR library not available
}
})();
}, [order, id]);
const handlePrint = () => {
window.print();
};
if (q.isLoading) {
return <InlineLoadingState message={__('Loading label...')} />;
}
if (q.isError) {
return (
<ErrorCard
title={__('Failed to load order')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
);
}
if (!order) return null;
return (
<div className="min-h-screen bg-gray-100">
{/* Actions bar - hidden on print */}
<div className="no-print bg-white border-b sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between">
<Link to={`/orders/${id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back to Order')}
</Button>
</Link>
<Button onClick={handlePrint} size="sm">
<Printer className="w-4 h-4 mr-2" />
{__('Print Label')}
</Button>
</div>
</div>
{/* Label content - 4x6 inch thermal label size */}
<div className="py-8 print:py-0 flex justify-center">
<div
className="print-4x6 bg-white shadow-lg print:shadow-none"
style={{
width: '4in',
height: '6in',
padding: '0.5in',
boxSizing: 'border-box'
}}
>
{/* Order Number & QR */}
<div className="flex justify-between items-start mb-4">
<div>
<div className="text-2xl font-bold text-gray-900">#{order.number}</div>
<div className="text-xs text-gray-500 mt-1">
{new Date((order.date_ts || 0) * 1000).toLocaleDateString()}
</div>
</div>
<canvas ref={qrRef} className="w-24 h-24" style={{ border: '1px solid #e5e7eb' }} />
</div>
{/* Ship To */}
<div className="mb-4 p-3 border-2 border-gray-900 rounded">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('SHIP TO')}</div>
<div className="text-lg font-bold text-gray-900">{order.shipping?.name || order.billing?.name || '—'}</div>
{(order.shipping?.phone || order.billing?.phone) && (
<div className="text-sm text-gray-700 mt-1">{order.shipping?.phone || order.billing?.phone}</div>
)}
<div
className="text-sm text-gray-700 mt-2 leading-relaxed"
dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }}
/>
</div>
{/* Items */}
<div className="mb-4">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-2">{__('ITEMS')}</div>
<ul className="text-sm space-y-1">
{(order.items || []).filter((it: any) => !it.virtual && !it.downloadable).map((it: any) => (
<li key={it.id} className="flex justify-between">
<span className="truncate">{it.name}</span>
<span className="font-medium ml-2">×{it.qty}</span>
</li>
))}
</ul>
</div>
{/* Shipping Method */}
{order.shipping_method && (
<div className="pt-3 border-t border-gray-200">
<div className="text-xs font-bold text-gray-500 uppercase tracking-wide mb-1">{__('SHIPPING METHOD')}</div>
<div className="text-sm font-medium text-gray-900">{order.shipping_method}</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -39,6 +39,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { SearchableSelect } from '@/components/ui/searchable-select'; import { SearchableSelect } from '@/components/ui/searchable-select';
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
// --- Types ------------------------------------------------------------ // --- Types ------------------------------------------------------------
export type CountryOption = { code: string; name: string }; export type CountryOption = { code: string; name: string };
@@ -79,6 +80,14 @@ export type ExistingOrderDTO = {
customer_note?: string; customer_note?: string;
currency?: string; currency?: string;
currency_symbol?: string; currency_symbol?: string;
totals?: {
total_items?: number;
total_shipping?: number;
total_tax?: number;
total_discount?: number;
total?: number;
shipping?: number;
};
}; };
export type OrderPayload = { export type OrderPayload = {
@@ -91,6 +100,7 @@ export type OrderPayload = {
customer_note?: string; customer_note?: string;
register_as_member?: boolean; register_as_member?: boolean;
coupons?: string[]; coupons?: string[];
custom_fields?: Record<string, string>;
}; };
type Props = { type Props = {
@@ -189,6 +199,9 @@ export default function OrderForm({
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]); const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
const [couponValidating, setCouponValidating] = React.useState(false); const [couponValidating, setCouponValidating] = React.useState(false);
// Custom field values (for plugin fields like destination_id)
const [customFieldData, setCustomFieldData] = React.useState<Record<string, string>>({});
// Fetch dynamic checkout fields based on cart items // Fetch dynamic checkout fields based on cart items
const { data: checkoutFields } = useQuery({ const { data: checkoutFields } = useQuery({
queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))], queryKey: ['checkout-fields', items.map(i => ({ product_id: i.product_id, qty: i.qty }))],
@@ -201,10 +214,46 @@ export default function OrderForm({
enabled: items.length > 0, enabled: items.length > 0,
}); });
// Apply default values from API for hidden fields (like customer checkout does)
React.useEffect(() => {
if (!checkoutFields?.fields) return;
// Initialize custom field defaults
const customDefaults: Record<string, string> = {};
checkoutFields.fields.forEach((field: any) => {
if (field.default && field.custom) {
customDefaults[field.key] = field.default;
}
});
if (Object.keys(customDefaults).length > 0) {
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
}
// Set billing country default for hidden fields (e.g., Indonesia-only stores)
const billingCountryField = checkoutFields.fields.find((f: any) => f.key === 'billing_country');
if ((billingCountryField?.type === 'hidden' || billingCountryField?.hidden) && billingCountryField.default && !bCountry) {
setBCountry(billingCountryField.default);
}
// Set shipping country default for hidden fields
const shippingCountryField = checkoutFields.fields.find((f: any) => f.key === 'shipping_country');
if ((shippingCountryField?.type === 'hidden' || shippingCountryField?.hidden) && shippingCountryField.default && !shippingData.country) {
setShippingData(prev => ({ ...prev, country: shippingCountryField.default }));
}
}, [checkoutFields?.fields]);
// Get effective shipping address (use billing if not shipping to different address) // Get effective shipping address (use billing if not shipping to different address)
const effectiveShippingAddress = React.useMemo(() => { const effectiveShippingAddress = React.useMemo(() => {
// Get destination_id from custom fields (Rajaongkir)
const destinationId = shipDiff
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
if (shipDiff) { if (shipDiff) {
return shippingData; return {
...shippingData,
destination_id: destinationId || undefined,
};
} }
// Use billing address // Use billing address
return { return {
@@ -214,22 +263,19 @@ export default function OrderForm({
postcode: bPost, postcode: bPost,
address_1: bAddr1, address_1: bAddr1,
address_2: '', address_2: '',
destination_id: destinationId || undefined,
}; };
}, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1]); }, [shipDiff, shippingData, bCountry, bState, bCity, bPost, bAddr1, customFieldData]);
// Check if shipping address is complete enough to calculate rates // Check if shipping address is complete enough to calculate rates
// Should match customer checkout: just needs country to fetch (destination_id can provide more specific rates)
const isShippingAddressComplete = React.useMemo(() => { const isShippingAddressComplete = React.useMemo(() => {
const addr = effectiveShippingAddress; const addr = effectiveShippingAddress;
// Need at minimum: country, state (if applicable), city // Need at minimum: country OR destination_id
if (!addr.country) return false; // destination_id from Rajaongkir is sufficient to calculate shipping
if (!addr.city) return false; if (addr.destination_id) return true;
// If country has states, require state return !!addr.country;
const countryStates = states[addr.country]; }, [effectiveShippingAddress]);
if (countryStates && Object.keys(countryStates).length > 0 && !addr.state) {
return false;
}
return true;
}, [effectiveShippingAddress, states]);
// Debounce city input to avoid hitting backend on every keypress // Debounce city input to avoid hitting backend on every keypress
const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city); const [debouncedCity, setDebouncedCity] = React.useState(effectiveShippingAddress.city);
@@ -244,7 +290,7 @@ export default function OrderForm({
// Calculate shipping rates dynamically // Calculate shipping rates dynamically
const { data: shippingRates, isLoading: shippingLoading } = useQuery({ const { data: shippingRates, isLoading: shippingLoading } = useQuery({
queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode], queryKey: ['shipping-rates', items.map(i => ({ product_id: i.product_id, qty: i.qty })), effectiveShippingAddress.country, effectiveShippingAddress.state, debouncedCity, effectiveShippingAddress.postcode, effectiveShippingAddress.destination_id],
queryFn: async () => { queryFn: async () => {
return api.post('/shipping/calculate', { return api.post('/shipping/calculate', {
items: items.map(i => ({ product_id: i.product_id, qty: i.qty })), items: items.map(i => ({ product_id: i.product_id, qty: i.qty })),
@@ -424,6 +470,43 @@ export default function OrderForm({
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` })); const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name })); const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
// Helper to get billing field config from API - returns null if hidden
const getBillingField = (key: string) => {
if (!checkoutFields?.fields) return { label: '', required: false }; // fallback when API not loaded
const field = checkoutFields.fields.find((f: any) => f.key === key);
// Check both hidden flag and type === 'hidden'
if (!field || field.hidden || field.type === 'hidden') return null;
return field;
};
// Helper to check if billing field should have full width
const isBillingFieldWide = (key: string) => {
const field = getBillingField(key);
if (!field) return false;
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
return hasFormRowWide || ['billing_address_1', 'billing_address_2'].includes(key);
};
// Derive custom fields from API (for plugin fields like destination_id)
const billingCustomFields = React.useMemo(() => {
if (!checkoutFields?.fields) return [];
return checkoutFields.fields
.filter((f: any) => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden')
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
}, [checkoutFields?.fields]);
const shippingCustomFields = React.useMemo(() => {
if (!checkoutFields?.fields) return [];
return checkoutFields.fields
.filter((f: any) => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden')
.sort((a: any, b: any) => (a.priority || 10) - (b.priority || 10));
}, [checkoutFields?.fields]);
// Helper to handle custom field changes
const handleCustomFieldChange = (key: string, value: string) => {
setCustomFieldData(prev => ({ ...prev, [key]: value }));
};
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -453,6 +536,7 @@ export default function OrderForm({
customer_note: note || undefined, customer_note: note || undefined,
items: itemsEditable ? items : undefined, items: itemsEditable ? items : undefined,
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined, coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
custom_fields: Object.keys(customFieldData).length > 0 ? customFieldData : undefined,
}; };
try { try {
@@ -729,10 +813,10 @@ export default function OrderForm({
</DialogHeader> </DialogHeader>
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
{selectedProduct.variations?.map((variation) => { {selectedProduct.variations?.map((variation) => {
const variationLabel = Object.entries(variation.attributes) // Build formatted label with styled key:value pairs
.map(([key, value]) => `${key}: ${value || ''}`) const variationParts = Object.entries(variation.attributes)
.filter(([_, value]) => value) // Remove empty values .filter(([_, value]) => value) // Remove empty values
.join(', '); .map(([key, value]) => ({ key, value: value || '' }));
return ( return (
<button <button
@@ -759,7 +843,7 @@ export default function OrderForm({
product_id: selectedProduct.id, product_id: selectedProduct.id,
variation_id: variation.id, variation_id: variation.id,
name: selectedProduct.name, name: selectedProduct.name,
variation_name: variationLabel, variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
price: variation.price, price: variation.price,
regular_price: variation.regular_price, regular_price: variation.regular_price,
sale_price: variation.sale_price, sale_price: variation.sale_price,
@@ -777,7 +861,15 @@ export default function OrderForm({
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium">{variationLabel}</div> <div className="text-sm">
{variationParts.map((part, idx) => (
<span key={part.key}>
<span className="font-semibold">{part.key}:</span>{' '}
<span>{part.value}</span>
{idx < variationParts.length - 1 && ', '}
</span>
))}
</div>
{variation.sku && ( {variation.sku && (
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
SKU: {variation.sku} SKU: {variation.sku}
@@ -827,10 +919,10 @@ export default function OrderForm({
</DrawerHeader> </DrawerHeader>
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto"> <div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{selectedProduct.variations?.map((variation) => { {selectedProduct.variations?.map((variation) => {
const variationLabel = Object.entries(variation.attributes) // Build formatted label with styled key:value pairs
.map(([key, value]) => `${key}: ${value || ''}`) const variationParts = Object.entries(variation.attributes)
.filter(([_, value]) => value) // Remove empty values .filter(([_, value]) => value) // Remove empty values
.join(', '); .map(([key, value]) => ({ key, value: value || '' }));
return ( return (
<button <button
@@ -857,7 +949,7 @@ export default function OrderForm({
product_id: selectedProduct.id, product_id: selectedProduct.id,
variation_id: variation.id, variation_id: variation.id,
name: selectedProduct.name, name: selectedProduct.name,
variation_name: variationLabel, variation_name: variationParts.map(p => `${p.key}: ${p.value}`).join(', '),
price: variation.price, price: variation.price,
regular_price: variation.regular_price, regular_price: variation.regular_price,
sale_price: variation.sale_price, sale_price: variation.sale_price,
@@ -875,7 +967,15 @@ export default function OrderForm({
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium">{variationLabel}</div> <div className="text-sm">
{variationParts.map((part, idx) => (
<span key={part.key}>
<span className="font-semibold">{part.key}:</span>{' '}
<span>{part.value}</span>
{idx < variationParts.length - 1 && ', '}
</span>
))}
</div>
{variation.sku && ( {variation.sku && (
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
SKU: {variation.sku} SKU: {variation.sku}
@@ -990,7 +1090,8 @@ export default function OrderForm({
)} )}
</div> </div>
)} )}
{/* Billing address - only show full address for physical products */} {/* Billing address - only show when items are added (so checkout fields API is loaded) */}
{items.length > 0 && (
<div className="rounded border p-4 space-y-3"> <div className="rounded border p-4 space-y-3">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{__('Billing address')}</h3> <h3 className="text-sm font-medium">{__('Billing address')}</h3>
@@ -1061,45 +1162,116 @@ export default function OrderForm({
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> {/* Dynamic billing fields - respects API visibility, labels, required status */}
<Label>{__('First name')}</Label> {getBillingField('billing_first_name') && (
<Input className="rounded-md border px-3 py-2" value={bFirst} onChange={e=>setBFirst(e.target.value)} /> <div className={isBillingFieldWide('billing_first_name') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_first_name')?.label || __('First name')}
{getBillingField('billing_first_name')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bFirst}
onChange={e => setBFirst(e.target.value)}
required={getBillingField('billing_first_name')?.required}
/>
</div> </div>
<div> )}
<Label>{__('Last name')}</Label> {getBillingField('billing_last_name') && (
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e=>setBLast(e.target.value)} /> <div className={isBillingFieldWide('billing_last_name') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_last_name')?.label || __('Last name')}
{getBillingField('billing_last_name')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bLast}
onChange={e => setBLast(e.target.value)}
required={getBillingField('billing_last_name')?.required}
/>
</div> </div>
<div> )}
<Label>{__('Email')}</Label> {getBillingField('billing_email') && (
<div className={isBillingFieldWide('billing_email') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_email')?.label || __('Email')}
{getBillingField('billing_email')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input <Input
inputMode="email" inputMode="email"
autoComplete="email" autoComplete="email"
className="rounded-md border px-3 py-2 appearance-none" className="rounded-md border px-3 py-2 appearance-none"
value={bEmail} value={bEmail}
onChange={e => setBEmail(e.target.value)} onChange={e => setBEmail(e.target.value)}
required={getBillingField('billing_email')?.required}
/> />
</div> </div>
<div> )}
<Label>{__('Phone')}</Label> {getBillingField('billing_phone') && (
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e=>setBPhone(e.target.value)} /> <div className={isBillingFieldWide('billing_phone') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_phone')?.label || __('Phone')}
{getBillingField('billing_phone')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bPhone}
onChange={e => setBPhone(e.target.value)}
required={getBillingField('billing_phone')?.required}
/>
</div> </div>
{/* Only show full address fields for physical products */} )}
{/* Address fields - only shown for physical products AND when not hidden by API */}
{hasPhysicalProduct && ( {hasPhysicalProduct && (
<> <>
{getBillingField('billing_address_1') && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label>{__('Address')}</Label> <Label>
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e=>setBAddr1(e.target.value)} /> {getBillingField('billing_address_1')?.label || __('Address')}
{getBillingField('billing_address_1')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bAddr1}
onChange={e => setBAddr1(e.target.value)}
required={getBillingField('billing_address_1')?.required}
/>
</div> </div>
<div> )}
<Label>{__('City')}</Label> {getBillingField('billing_city') && (
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e=>setBCity(e.target.value)} /> <div className={isBillingFieldWide('billing_city') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_city')?.label || __('City')}
{getBillingField('billing_city')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bCity}
onChange={e => setBCity(e.target.value)}
required={getBillingField('billing_city')?.required}
/>
</div> </div>
<div> )}
<Label>{__('Postcode')}</Label> {getBillingField('billing_postcode') && (
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e=>setBPost(e.target.value)} /> <div className={isBillingFieldWide('billing_postcode') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_postcode')?.label || __('Postcode')}
{getBillingField('billing_postcode')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
className="rounded-md border px-3 py-2"
value={bPost}
onChange={e => setBPost(e.target.value)}
required={getBillingField('billing_postcode')?.required}
/>
</div> </div>
<div> )}
<Label>{__('Country')}</Label> {getBillingField('billing_country') && (
<div className={isBillingFieldWide('billing_country') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_country')?.label || __('Country')}
{getBillingField('billing_country')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<SearchableSelect <SearchableSelect
options={countryOptions} options={countryOptions}
value={bCountry} value={bCountry}
@@ -1108,23 +1280,39 @@ export default function OrderForm({
disabled={oneCountryOnly} disabled={oneCountryOnly}
/> />
</div> </div>
<div>
<Label>{__('State/Province')}</Label>
<Select value={bState} onValueChange={setBState}>
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
<SelectContent className="max-h-64">
{bStateOptions.length ? bStateOptions.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
)) : (
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
)} )}
</SelectContent> {getBillingField('billing_state') && (
</Select> <div className={isBillingFieldWide('billing_state') ? 'md:col-span-2' : ''}>
<Label>
{getBillingField('billing_state')?.label || __('State/Province')}
{getBillingField('billing_state')?.required && <span className="text-destructive ml-1">*</span>}
</Label>
<SearchableSelect
options={bStateOptions}
value={bState}
onChange={setBState}
placeholder={bStateOptions.length ? __('Select state') : __('N/A')}
disabled={!bStateOptions.length}
/>
</div> </div>
)}
</> </>
)} )}
{/* Billing custom fields from plugins (e.g., destination_id from Rajaongkir) */}
{hasPhysicalProduct && billingCustomFields.map((field: CheckoutField) => (
<DynamicCheckoutField
key={field.key}
field={field}
value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={bStateOptions}
/>
))}
</div> </div>
</div> </div>
)}
{/* Conditional: Only show address fields and shipping for physical products */} {/* Conditional: Only show address fields and shipping for physical products */}
{!hasPhysicalProduct && ( {!hasPhysicalProduct && (
@@ -1149,19 +1337,35 @@ export default function OrderForm({
<h3 className="text-sm font-medium">{__('Shipping address')}</h3> <h3 className="text-sm font-medium">{__('Shipping address')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{checkoutFields.fields {checkoutFields.fields
.filter((f: any) => f.fieldset === 'shipping' && !f.hidden) .filter((f: any) => f.fieldset === 'shipping' && !f.hidden && f.type !== 'hidden')
.sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0)) .sort((a: any, b: any) => (a.priority || 0) - (b.priority || 0))
.map((field: any) => { .map((field: any) => {
const isWide = ['address_1', 'address_2'].includes(field.key.replace('shipping_', '')); // Check for full width: address fields or form-row-wide class from PHP
const hasFormRowWide = Array.isArray(field.class) && field.class.includes('form-row-wide');
const isWide = hasFormRowWide || ['address_1', 'address_2'].includes(field.key.replace('shipping_', ''));
const fieldKey = field.key.replace('shipping_', ''); const fieldKey = field.key.replace('shipping_', '');
// For searchable_select, DynamicCheckoutField renders its own label wrapper
if (field.type === 'searchable_select') {
return (
<DynamicCheckoutField
key={field.key}
field={field}
value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }))}
/>
);
}
return ( return (
<div key={field.key} className={isWide ? 'md:col-span-2' : ''}> <div key={field.key} className={isWide ? 'md:col-span-2' : ''}>
<Label> <Label>
{field.label} {field.label}
{field.required && <span className="text-destructive ml-1">*</span>} {field.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
{field.type === 'select' && field.options ? ( {field.type === 'select' && field.options && field.key !== 'shipping_state' ? (
<Select <Select
value={shippingData[fieldKey] || ''} value={shippingData[fieldKey] || ''}
onValueChange={(v) => setShippingData({ ...shippingData, [fieldKey]: v })} onValueChange={(v) => setShippingData({ ...shippingData, [fieldKey]: v })}
@@ -1183,10 +1387,30 @@ export default function OrderForm({
placeholder={field.placeholder || __('Select country')} placeholder={field.placeholder || __('Select country')}
disabled={oneCountryOnly} disabled={oneCountryOnly}
/> />
) : field.key === 'shipping_state' && field.options ? (
<SearchableSelect
options={Object.entries(field.options).map(([value, label]: [string, any]) => ({ value, label }))}
value={shippingData.state || ''}
onChange={(v) => setShippingData({ ...shippingData, state: v })}
placeholder={field.placeholder || __('Select state')}
disabled={!Object.keys(field.options).length}
/>
) : field.type === 'textarea' ? ( ) : field.type === 'textarea' ? (
<Textarea <Textarea
value={shippingData[fieldKey] || ''} value={field.custom ? customFieldData[field.key] || '' : shippingData[fieldKey] || ''}
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})} onChange={(e) => field.custom
? handleCustomFieldChange(field.key, e.target.value)
: setShippingData({ ...shippingData, [fieldKey]: e.target.value })
}
placeholder={field.placeholder}
required={field.required}
/>
) : field.custom ? (
// For other custom field types, store in customFieldData
<Input
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
value={customFieldData[field.key] || ''}
onChange={(e) => handleCustomFieldChange(field.key, e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
required={field.required} required={field.required}
/> />
@@ -1250,7 +1474,12 @@ export default function OrderForm({
{hasPhysicalProduct && ( {hasPhysicalProduct && (
<div> <div>
<Label>{__('Shipping method')}</Label> <Label>{__('Shipping method')}</Label>
{shippingLoading ? ( {!isShippingAddressComplete ? (
/* Prompt user to enter address first */
<div className="text-sm text-muted-foreground py-2 italic">
{__('Enter shipping address to see available rates')}
</div>
) : shippingLoading ? (
<div className="text-sm text-muted-foreground py-2">{__('Calculating rates...')}</div> <div className="text-sm text-muted-foreground py-2">{__('Calculating rates...')}</div>
) : shippingRates?.methods && shippingRates.methods.length > 0 ? ( ) : shippingRates?.methods && shippingRates.methods.length > 0 ? (
<Select value={shippingMethod} onValueChange={setShippingMethod}> <Select value={shippingMethod} onValueChange={setShippingMethod}>
@@ -1263,17 +1492,9 @@ export default function OrderForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : shippingData.country ? (
<div className="text-sm text-muted-foreground py-2">{__('No shipping methods available')}</div>
) : ( ) : (
<Select value={shippingMethod} onValueChange={setShippingMethod}> /* Address is complete but no methods returned */
<SelectTrigger className="w-full"><SelectValue placeholder={shippings.length ? __('Select shipping') : __('No methods')} /></SelectTrigger> <div className="text-sm text-muted-foreground py-2">{__('No shipping methods available for this address')}</div>
<SelectContent>
{shippings.map(s => (
<SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>
))}
</SelectContent>
</Select>
)} )}
</div> </div>
)} )}

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
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 { ArrowLeft, Key, Monitor, Globe, Clock } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface Activation {
id: number;
license_id: number;
domain: string | null;
ip_address: string | null;
machine_id: string | null;
user_agent: string | null;
status: 'active' | 'deactivated';
activated_at: string;
deactivated_at: string | null;
}
interface LicenseDetail {
id: number;
license_key: string;
product_id: number;
product_name: string;
order_id: number;
user_id: number;
user_email: string;
user_name: string;
status: 'active' | 'revoked' | 'expired';
activation_limit: number;
activation_count: number;
activations_remaining: number;
expires_at: string | null;
is_expired: boolean;
created_at: string;
updated_at: string;
activations: Activation[];
}
export default function LicenseDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: license, isLoading } = useQuery<LicenseDetail>({
queryKey: ['license', id],
queryFn: () => api.get(`/licenses/${id}`),
enabled: !!id,
});
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-primary"></div>
</div>
);
}
if (!license) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">{__('License not found')}</p>
<Button variant="link" onClick={() => navigate('/products/licenses')}>
{__('Back to Licenses')}
</Button>
</div>
);
}
const getStatusBadge = () => {
if (license.status === 'revoked') {
return <Badge variant="destructive">{__('Revoked')}</Badge>;
}
if (license.is_expired) {
return <Badge variant="secondary">{__('Expired')}</Badge>;
}
return <Badge variant="default">{__('Active')}</Badge>;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/products/licenses')}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
{__('License Details')}
</h1>
<p className="text-muted-foreground font-mono text-sm">{license.license_key}</p>
</div>
<div className="ml-auto">{getStatusBadge()}</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Product')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">{license.product_name}</p>
<p className="text-xs text-muted-foreground">Order #{license.order_id}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Customer')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">{license.user_name}</p>
<p className="text-xs text-muted-foreground">{license.user_email}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Activations')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
</p>
<p className="text-xs text-muted-foreground">
{license.activations_remaining === -1 ? __('Unlimited') : `${license.activations_remaining} ${__('remaining')}`}
</p>
</CardContent>
</Card>
</div>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{__('Dates')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{__('Created')}</p>
<p>{new Date(license.created_at).toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">{__('Expires')}</p>
<p className={license.is_expired ? 'text-red-500' : ''}>
{license.expires_at ? new Date(license.expires_at).toLocaleDateString() : __('Never')}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Activations */}
<Card>
<CardHeader>
<CardTitle>{__('Activation History')}</CardTitle>
<CardDescription>
{__('All activations and deactivations for this license')}
</CardDescription>
</CardHeader>
<CardContent>
{license.activations.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
{__('No activations yet')}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Domain/Machine')}</TableHead>
<TableHead>{__('IP Address')}</TableHead>
<TableHead>{__('Activated')}</TableHead>
<TableHead>{__('Status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{license.activations.map((activation) => (
<TableRow key={activation.id}>
<TableCell>
<div className="flex items-center gap-2">
{activation.domain ? (
<>
<Globe className="h-4 w-4 text-muted-foreground" />
<span>{activation.domain}</span>
</>
) : activation.machine_id ? (
<>
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">{activation.machine_id}</span>
</>
) : (
<span className="text-muted-foreground">{__('Unknown')}</span>
)}
</div>
</TableCell>
<TableCell>
<span className="font-mono text-xs">{activation.ip_address || '-'}</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" />
{new Date(activation.activated_at).toLocaleString()}
</div>
</TableCell>
<TableCell>
{activation.status === 'active' ? (
<Badge variant="default">{__('Active')}</Badge>
) : (
<Badge variant="secondary">{__('Deactivated')}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Search, Key, Ban, Eye, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { useNavigate } from 'react-router-dom';
interface License {
id: number;
license_key: string;
product_id: number;
product_name: string;
order_id: number;
user_id: number;
user_email: string;
user_name: string;
status: 'active' | 'revoked' | 'expired';
activation_limit: number;
activation_count: number;
activations_remaining: number;
expires_at: string | null;
is_expired: boolean;
created_at: string;
}
interface LicensesResponse {
licenses: License[];
total: number;
page: number;
per_page: number;
}
export default function Licenses() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { data, isLoading } = useQuery<LicensesResponse>({
queryKey: ['licenses', { search, status, page }],
queryFn: () => api.get('/licenses', {
params: { search, status: status || undefined, page, per_page: 20 }
}),
});
const revokeMutation = useMutation({
mutationFn: (id: number) => api.del(`/licenses/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['licenses'] });
toast.success(__('License revoked'));
},
onError: () => {
toast.error(__('Failed to revoke license'));
},
});
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
toast.success(__('License key copied'));
};
const getStatusBadge = (license: License) => {
if (license.status === 'revoked') {
return <Badge variant="destructive">{__('Revoked')}</Badge>;
}
if (license.is_expired) {
return <Badge variant="secondary">{__('Expired')}</Badge>;
}
return <Badge variant="default">{__('Active')}</Badge>;
};
const totalPages = data ? Math.ceil(data.total / data.per_page) : 1;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
{__('Licenses')}
</h1>
<p className="text-muted-foreground">
{__('Manage software licenses for your digital products')}
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={__('Search license keys...')}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="!pl-9"
/>
</div>
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={__('All Statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All Statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="revoked">{__('Revoked')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('License Key')}</TableHead>
<TableHead>{__('Product')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Activations')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Expires')}</TableHead>
<TableHead className="w-[100px]">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
{__('Loading...')}
</TableCell>
</TableRow>
) : data?.licenses.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{__('No licenses found')}
</TableCell>
</TableRow>
) : (
data?.licenses.map((license) => (
<TableRow key={license.id}>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{license.license_key}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(license.license_key)}
>
{copiedKey === license.license_key ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{license.product_name}</span>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{license.user_name}</p>
<p className="text-xs text-muted-foreground">{license.user_email}</p>
</div>
</TableCell>
<TableCell>
<span className={license.activations_remaining === 0 ? 'text-red-500' : ''}>
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
</span>
</TableCell>
<TableCell>{getStatusBadge(license)}</TableCell>
<TableCell>
{license.expires_at ? (
<span className={license.is_expired ? 'text-red-500' : ''}>
{new Date(license.expires_at).toLocaleDateString()}
</span>
) : (
<span className="text-muted-foreground">{__('Never')}</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/products/licenses/${license.id}`)}
>
<Eye className="h-4 w-4" />
</Button>
{license.status === 'active' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Ban className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Revoke License')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This will permanently revoke the license. The customer will no longer be able to use it.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(license.id)}
className="bg-destructive text-destructive-foreground"
>
{__('Revoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{__('Showing')} {((page - 1) * 20) + 1} - {Math.min(page * 20, data?.total || 0)} {__('of')} {data?.total || 0}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
{__('Previous')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
{__('Next')}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -21,7 +21,7 @@ export function DirectCartLinks({ productId, productType, variations = [] }: Dir
const [copiedLink, setCopiedLink] = useState<string | null>(null); const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin; const siteUrl = window.location.origin;
const spaPagePath = '/store'; // This should ideally come from settings const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => { const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
const params = new URLSearchParams(); const params = new URLSearchParams();

View File

@@ -4,12 +4,13 @@ import { api } from '@/lib/api';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm'; import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { Package, DollarSign, Layers, Tag } from 'lucide-react'; import { Package, DollarSign, Layers, Tag, Download } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { GeneralTab } from './tabs/GeneralTab'; import { GeneralTab } from './tabs/GeneralTab';
import { InventoryTab } from './tabs/InventoryTab'; import { InventoryTab } from './tabs/InventoryTab';
import { VariationsTab, ProductVariant } from './tabs/VariationsTab'; import { VariationsTab, ProductVariant } from './tabs/VariationsTab';
import { OrganizationTab } from './tabs/OrganizationTab'; import { OrganizationTab } from './tabs/OrganizationTab';
import { DownloadsTab, DownloadableFile } from './tabs/DownloadsTab';
// Types // Types
export type ProductFormData = { export type ProductFormData = {
@@ -32,6 +33,13 @@ export type ProductFormData = {
virtual?: boolean; virtual?: boolean;
downloadable?: boolean; downloadable?: boolean;
featured?: boolean; featured?: boolean;
downloads?: DownloadableFile[];
download_limit?: string;
download_expiry?: string;
// Licensing
licensing_enabled?: boolean;
license_activation_limit?: string;
license_duration_days?: string;
}; };
type Props = { type Props = {
@@ -75,6 +83,12 @@ export function ProductFormTabbed({
const [virtual, setVirtual] = useState(initial?.virtual || false); const [virtual, setVirtual] = useState(initial?.virtual || false);
const [downloadable, setDownloadable] = useState(initial?.downloadable || false); const [downloadable, setDownloadable] = useState(initial?.downloadable || false);
const [featured, setFeatured] = useState(initial?.featured || false); const [featured, setFeatured] = useState(initial?.featured || false);
const [downloads, setDownloads] = useState<DownloadableFile[]>(initial?.downloads || []);
const [downloadLimit, setDownloadLimit] = useState(initial?.download_limit || '');
const [downloadExpiry, setDownloadExpiry] = useState(initial?.download_expiry || '');
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 [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode) // Update form state when initial data changes (for edit mode)
@@ -99,6 +113,12 @@ export function ProductFormTabbed({
setVirtual(initial.virtual || false); setVirtual(initial.virtual || false);
setDownloadable(initial.downloadable || false); setDownloadable(initial.downloadable || false);
setFeatured(initial.featured || false); setFeatured(initial.featured || false);
setDownloads(initial.downloads || []);
setDownloadLimit(initial.download_limit || '');
setDownloadExpiry(initial.download_expiry || '');
setLicensingEnabled(initial.licensing_enabled || false);
setLicenseActivationLimit(initial.license_activation_limit || '');
setLicenseDurationDays(initial.license_duration_days || '');
} }
}, [initial, mode]); }, [initial, mode]);
@@ -155,6 +175,12 @@ export function ProductFormTabbed({
virtual, virtual,
downloadable, downloadable,
featured, featured,
downloads: downloadable ? downloads : undefined,
download_limit: downloadable ? downloadLimit : undefined,
download_expiry: downloadable ? downloadExpiry : undefined,
licensing_enabled: licensingEnabled,
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
}; };
await onSubmit(payload); await onSubmit(payload);
@@ -169,6 +195,7 @@ export function ProductFormTabbed({
const tabs = [ const tabs = [
{ id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> }, { id: 'general', label: __('General'), icon: <Package className="w-4 h-4" /> },
{ id: 'inventory', label: __('Inventory'), icon: <Layers className="w-4 h-4" /> }, { id: 'inventory', label: __('Inventory'), icon: <Layers className="w-4 h-4" /> },
...(downloadable ? [{ id: 'downloads', label: __('Downloads'), icon: <Download className="w-4 h-4" /> }] : []),
...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []), ...(type === 'variable' ? [{ id: 'variations', label: __('Variations'), icon: <Layers className="w-4 h-4" /> }] : []),
{ id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> }, { id: 'organization', label: __('Organization'), icon: <Tag className="w-4 h-4" /> },
]; ];
@@ -203,6 +230,13 @@ export function ProductFormTabbed({
setRegularPrice={setRegularPrice} setRegularPrice={setRegularPrice}
salePrice={salePrice} salePrice={salePrice}
setSalePrice={setSalePrice} setSalePrice={setSalePrice}
productId={productId}
licensingEnabled={licensingEnabled}
setLicensingEnabled={setLicensingEnabled}
licenseActivationLimit={licenseActivationLimit}
setLicenseActivationLimit={setLicenseActivationLimit}
licenseDurationDays={licenseDurationDays}
setLicenseDurationDays={setLicenseDurationDays}
/> />
</FormSection> </FormSection>
@@ -218,6 +252,20 @@ export function ProductFormTabbed({
/> />
</FormSection> </FormSection>
{/* Downloads Tab (only for downloadable products) */}
{downloadable && (
<FormSection id="downloads">
<DownloadsTab
downloads={downloads}
setDownloads={setDownloads}
downloadLimit={downloadLimit}
setDownloadLimit={setDownloadLimit}
downloadExpiry={downloadExpiry}
setDownloadExpiry={setDownloadExpiry}
/>
</FormSection>
)}
{/* Variations Tab (only for variable products) */} {/* Variations Tab (only for variable products) */}
{type === 'variable' && ( {type === 'variable' && (
<FormSection id="variations"> <FormSection id="variations">

View File

@@ -0,0 +1,195 @@
import React from 'react';
import { __ } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, Trash2, FileIcon, Plus, GripVertical } from 'lucide-react';
import { openWPMediaGallery } from '@/lib/wp-media';
export interface DownloadableFile {
id?: string;
name: string;
file: string; // URL
}
type DownloadsTabProps = {
downloads: DownloadableFile[];
setDownloads: (files: DownloadableFile[]) => void;
downloadLimit: string;
setDownloadLimit: (value: string) => void;
downloadExpiry: string;
setDownloadExpiry: (value: string) => void;
};
export function DownloadsTab({
downloads,
setDownloads,
downloadLimit,
setDownloadLimit,
downloadExpiry,
setDownloadExpiry,
}: DownloadsTabProps) {
const addFile = () => {
openWPMediaGallery((files) => {
const newDownloads = files.map(file => ({
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name || file.title || 'Untitled',
file: file.url,
}));
setDownloads([...downloads, ...newDownloads]);
});
};
const removeFile = (index: number) => {
const newDownloads = downloads.filter((_, i) => i !== index);
setDownloads(newDownloads);
};
const updateFileName = (index: number, name: string) => {
const newDownloads = [...downloads];
newDownloads[index] = { ...newDownloads[index], name };
setDownloads(newDownloads);
};
const updateFileUrl = (index: number, file: string) => {
const newDownloads = [...downloads];
newDownloads[index] = { ...newDownloads[index], file };
setDownloads(newDownloads);
};
return (
<Card>
<CardHeader>
<CardTitle>{__('Downloadable Files')}</CardTitle>
<CardDescription>
{__('Add files that customers can download after purchase')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Downloadable Files List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>{__('Files')}</Label>
<Button type="button" variant="outline" size="sm" onClick={addFile}>
<Plus className="w-4 h-4 mr-2" />
{__('Add File')}
</Button>
</div>
{downloads.length > 0 ? (
<div className="space-y-3">
{downloads.map((download, index) => (
<div
key={download.id || index}
className="flex items-center gap-3 p-3 border rounded-lg bg-muted/30"
>
<GripVertical className="w-4 h-4 text-muted-foreground cursor-move" />
<FileIcon className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs text-muted-foreground">{__('File Name')}</Label>
<Input
value={download.name}
onChange={(e) => updateFileName(index, e.target.value)}
placeholder={__('My Downloadable File')}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">{__('File URL')}</Label>
<div className="flex gap-2 mt-1">
<Input
value={download.file}
onChange={(e) => updateFileUrl(index, e.target.value)}
placeholder="https://..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
openWPMediaGallery((files) => {
if (files.length > 0) {
updateFileUrl(index, files[0].url);
if (!download.name || download.name === 'Untitled') {
updateFileName(index, files[0].name || files[0].title || 'Untitled');
}
}
});
}}
>
<Upload className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => removeFile(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<FileIcon className="mx-auto h-12 w-12 mb-2 opacity-50" />
<p className="text-sm">{__('No downloadable files added yet')}</p>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={addFile}>
<Upload className="w-4 h-4 mr-2" />
{__('Choose files from Media Library')}
</Button>
</div>
)}
</div>
{/* Download Settings */}
<div className="border-t pt-6">
<h3 className="font-medium mb-4">{__('Download Settings')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="download_limit">{__('Download Limit')}</Label>
<Input
id="download_limit"
type="number"
min="0"
value={downloadLimit}
onChange={(e) => setDownloadLimit(e.target.value)}
placeholder={__('Unlimited')}
className="mt-1.5"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Leave blank for unlimited downloads.')}
</p>
</div>
<div>
<Label htmlFor="download_expiry">{__('Download Expiry (days)')}</Label>
<Input
id="download_expiry"
type="number"
min="0"
value={downloadExpiry}
onChange={(e) => setDownloadExpiry(e.target.value)}
placeholder={__('Never expires')}
className="mt-1.5"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Leave blank for downloads that never expire.')}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -8,7 +8,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { DollarSign, Upload, X, Image as ImageIcon } from 'lucide-react'; import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency'; import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor'; import { RichTextEditor } from '@/components/RichTextEditor';
import { openWPMediaGallery } from '@/lib/wp-media'; import { openWPMediaGallery } from '@/lib/wp-media';
@@ -40,6 +41,15 @@ type GeneralTabProps = {
setRegularPrice: (value: string) => void; setRegularPrice: (value: string) => void;
salePrice: string; salePrice: string;
setSalePrice: (value: string) => void; setSalePrice: (value: string) => void;
// For copy links
productId?: number;
// Licensing
licensingEnabled?: boolean;
setLicensingEnabled?: (value: boolean) => void;
licenseActivationLimit?: string;
setLicenseActivationLimit?: (value: string) => void;
licenseDurationDays?: string;
setLicenseDurationDays?: (value: string) => void;
}; };
export function GeneralTab({ export function GeneralTab({
@@ -67,6 +77,13 @@ export function GeneralTab({
setRegularPrice, setRegularPrice,
salePrice, salePrice,
setSalePrice, setSalePrice,
productId,
licensingEnabled,
setLicensingEnabled,
licenseActivationLimit,
setLicenseActivationLimit,
licenseDurationDays,
setLicenseDurationDays,
}: GeneralTabProps) { }: GeneralTabProps) {
const savingsPercent = const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice) salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
@@ -75,6 +92,30 @@ export function GeneralTab({
const store = getStoreCurrency(); const store = getStoreCurrency();
// Copy link state and helpers
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return '';
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
params.set('redirect', redirect);
return `${siteUrl}${spaPagePath}?${params.toString()}`;
};
const copyToClipboard = async (link: string, label: string) => {
try {
await navigator.clipboard.writeText(link);
setCopiedLink(link);
toast.success(`${label} link copied!`);
setTimeout(() => setCopiedLink(null), 2000);
} catch (err) {
toast.error('Failed to copy link');
}
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -387,8 +428,104 @@ export function GeneralTab({
{__('Featured product (show in featured sections)')} {__('Featured product (show in featured sections)')}
</Label> </Label>
</div> </div>
{/* Licensing option */}
{setLicensingEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="licensing-enabled"
checked={licensingEnabled || false}
onCheckedChange={(checked) => setLicensingEnabled(checked as boolean)}
/>
<Label htmlFor="licensing-enabled" className="cursor-pointer font-normal flex items-center gap-1">
<Key className="h-3 w-3" />
{__('Enable licensing for this product')}
</Label>
</div>
{/* Licensing settings panel */}
{licensingEnabled && (
<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">{__('Activation Limit')}</Label>
<Input
type="number"
min="0"
placeholder={__('0 = unlimited')}
value={licenseActivationLimit || ''}
onChange={(e) => setLicenseActivationLimit?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('0 or empty = use global default')}
</p>
</div>
<div>
<Label className="text-xs">{__('License Duration (Days)')}</Label>
<Input
type="number"
min="0"
placeholder={__('365')}
value={licenseDurationDays || ''}
onChange={(e) => setLicenseDurationDays?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('0 = never expires')}
</p>
</div> </div>
</div> </div>
</div>
)}
</>
)}
</div>
</div>
{/* Direct Cart Links - Simple products only */}
{productId && type === 'simple' && (
<>
<Separator />
<div className="space-y-3">
<Label>{__('Direct-to-Cart Links')}</Label>
<p className="text-xs text-muted-foreground">
{__('Share these links to add this product directly to cart or checkout')}
</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateSimpleLink('cart'), 'Cart')}
className="flex-1"
>
{copiedLink === generateSimpleLink('cart') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Cart Link')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateSimpleLink('checkout'), 'Checkout')}
className="flex-1"
>
{copiedLink === generateSimpleLink('checkout') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Checkout Link')}
</Button>
</div>
</div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -26,6 +27,7 @@ export function OrganizationTab({
selectedTags, selectedTags,
setSelectedTags, setSelectedTags,
}: OrganizationTabProps) { }: OrganizationTabProps) {
const queryClient = useQueryClient();
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [newTagName, setNewTagName] = useState(''); const [newTagName, setNewTagName] = useState('');
const [creatingCategory, setCreatingCategory] = useState(false); const [creatingCategory, setCreatingCategory] = useState(false);
@@ -46,7 +48,8 @@ export function OrganizationTab({
if (response.id) { if (response.id) {
setSelectedCategories([...selectedCategories, response.id]); setSelectedCategories([...selectedCategories, response.id]);
} }
// Note: Parent component should refetch categories // Invalidate categories query to refresh the list
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
} catch (error: any) { } catch (error: any) {
toast.error(error.message || __('Failed to create category')); toast.error(error.message || __('Failed to create category'));
} finally { } finally {
@@ -183,8 +186,7 @@ export function OrganizationTab({
setSelectedTags([...selectedTags, tag.id]); setSelectedTags([...selectedTags, tag.id]);
} }
}} }}
className={`px-3 py-1 rounded-full text-sm border transition-colors ${ className={`px-3 py-1 rounded-full text-sm border transition-colors ${selectedTags.includes(tag.id)
selectedTags.includes(tag.id)
? 'bg-primary text-primary-foreground border-primary' ? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-accent' : 'bg-background border-border hover:bg-accent'
}`} }`}

View File

@@ -22,6 +22,7 @@ export type ProductVariant = {
manage_stock?: boolean; manage_stock?: boolean;
stock_status?: 'instock' | 'outofstock' | 'onbackorder'; stock_status?: 'instock' | 'outofstock' | 'onbackorder';
image?: string; image?: string;
license_duration_days?: string;
}; };
type VariationsTabProps = { type VariationsTabProps = {
@@ -45,7 +46,7 @@ export function VariationsTab({
const [copiedLink, setCopiedLink] = useState<string | null>(null); const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin; const siteUrl = window.location.origin;
const spaPagePath = '/store'; const spaPagePath = (window as any).WNW_CONFIG?.storeUrl ? new URL((window as any).WNW_CONFIG.storeUrl).pathname : '/store';
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => { const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return ''; if (!productId) return '';
@@ -276,6 +277,26 @@ export function VariationsTab({
)} )}
</div> </div>
</div> </div>
{/* License Duration - only show if licensing is enabled on product */}
<div className="col-span-2 md:col-span-4">
<Label className="text-xs">{__('License Duration (Days)')}</Label>
<Input
type="number"
min="0"
placeholder={__('Leave empty to use product default')}
value={variation.license_duration_days || ''}
onChange={(e) => {
const updated = [...variations];
updated[index].license_duration_days = e.target.value;
setVariations(updated);
}}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('Override license duration for this variation. 0 = never expires.')}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Input <Input
placeholder={__('SKU')} placeholder={__('SKU')}

View File

@@ -154,12 +154,14 @@ export default function NotificationsSettings() {
</p> </p>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{__('Coming soon')} {__('Sent, Failed, Pending')}
</div> </div>
<Button variant="outline" size="sm" disabled> <Link to="/settings/notifications/activity-log">
<Button variant="outline" size="sm">
{__('View Log')} {__('View Log')}
<ChevronRight className="ml-2 h-4 w-4" /> <ChevronRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,245 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { SettingsLayout } from '../components/SettingsLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
ArrowLeft,
Mail,
Bell,
MessageCircle,
Send,
CheckCircle2,
XCircle,
Clock,
RefreshCw,
Filter,
Search
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface NotificationLogEntry {
id: number;
channel: 'email' | 'push' | 'whatsapp' | 'telegram';
event: string;
recipient: string;
subject?: string;
status: 'sent' | 'failed' | 'pending' | 'queued';
created_at: string;
sent_at?: string;
error_message?: string;
}
interface NotificationLogsResponse {
logs: NotificationLogEntry[];
total: number;
page: number;
per_page: number;
}
const channelIcons: Record<string, React.ReactNode> = {
email: <Mail className="h-4 w-4" />,
push: <Bell className="h-4 w-4" />,
whatsapp: <MessageCircle className="h-4 w-4" />,
telegram: <Send className="h-4 w-4" />,
};
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
sent: { icon: <CheckCircle2 className="h-4 w-4" />, color: 'text-green-600 bg-green-50', label: 'Sent' },
failed: { icon: <XCircle className="h-4 w-4" />, color: 'text-red-600 bg-red-50', label: 'Failed' },
pending: { icon: <Clock className="h-4 w-4" />, color: 'text-yellow-600 bg-yellow-50', label: 'Pending' },
queued: { icon: <RefreshCw className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50', label: 'Queued' },
};
export default function ActivityLog() {
const [search, setSearch] = React.useState('');
const [channelFilter, setChannelFilter] = React.useState('all');
const [statusFilter, setStatusFilter] = React.useState('all');
const [page, setPage] = React.useState(1);
const { data, isLoading, error, refetch } = useQuery<NotificationLogsResponse>({
queryKey: ['notification-logs', page, channelFilter, statusFilter, search],
queryFn: async () => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('per_page', '20');
if (channelFilter !== 'all') params.set('channel', channelFilter);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
return api.get(`/notifications/logs?${params.toString()}`);
},
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
return (
<SettingsLayout
title={__('Activity Log')}
description={__('View notification history and delivery status')}
action={
<Link to="/settings/notifications">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
{__('Back')}
</Button>
</Link>
}
>
<div className="space-y-6">
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={__('Search by recipient or subject...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="!pl-9"
/>
</div>
</div>
<Select value={channelFilter} onValueChange={setChannelFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={__('Channel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Channels')}</SelectItem>
<SelectItem value="email">{__('Email')}</SelectItem>
<SelectItem value="push">{__('Push')}</SelectItem>
<SelectItem value="whatsapp">{__('WhatsApp')}</SelectItem>
<SelectItem value="telegram">{__('Telegram')}</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={__('Status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Status')}</SelectItem>
<SelectItem value="sent">{__('Sent')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="queued">{__('Queued')}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Activity Log Table */}
<Card>
<CardHeader>
<CardTitle>{__('Recent Activity')}</CardTitle>
<CardDescription>
{isLoading
? __('Loading...')
: data?.total
? `${data.total} ${__('notifications found')}`
: __('No notifications recorded yet')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="h-8 w-8 mx-auto mb-2 animate-spin" />
<p>{__('Loading activity log...')}</p>
</div>
) : error ? (
<div className="text-center py-12 text-muted-foreground">
<XCircle className="h-8 w-8 mx-auto mb-2 text-red-500" />
<p>{__('Failed to load activity log')}</p>
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
{__('Try Again')}
</Button>
</div>
) : !data?.logs?.length ? (
<div className="text-center py-12 text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p className="text-lg font-medium">{__('No notifications yet')}</p>
<p className="text-sm">{__('Notification activities will appear here once sent.')}</p>
</div>
) : (
<div className="space-y-4">
{data.logs.map((log) => (
<div
key={log.id}
className="flex items-start gap-4 p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
{/* Channel Icon */}
<div className="p-2 bg-muted rounded-lg">
{channelIcons[log.channel] || <Bell className="h-4 w-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{log.event}</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${statusConfig[log.status]?.color || 'text-gray-600 bg-gray-50'}`}
>
{statusConfig[log.status]?.icon}
{statusConfig[log.status]?.label || log.status}
</span>
</div>
<p className="text-sm text-muted-foreground truncate">
{__('To')}: {log.recipient}
{log.subject && `${log.subject}`}
</p>
{log.error_message && (
<p className="text-sm text-red-600 mt-1">
{__('Error')}: {log.error_message}
</p>
)}
</div>
{/* Timestamp */}
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(log.sent_at || log.created_at)}
</div>
</div>
))}
{/* Pagination */}
{data.total > 20 && (
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
{__('Previous')}
</Button>
<span className="text-sm text-muted-foreground">
{__('Page')} {page} {__('of')} {Math.ceil(data.total / 20)}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(data.total / 20)}
onClick={() => setPage(p => p + 1)}
>
{__('Next')}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</SettingsLayout>
);
}

View File

@@ -31,7 +31,8 @@ interface ShippingZone {
export default function ShippingPage() { export default function ShippingPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin'; // Use siteUrl + /wp-admin since wpAdminUrl already includes admin.php?page=woonoow
const wcAdminUrl = ((window as any).WNW_CONFIG?.siteUrl || '') + '/wp-admin';
const [togglingMethod, setTogglingMethod] = useState<string | null>(null); const [togglingMethod, setTogglingMethod] = useState<string | null>(null);
const [selectedZone, setSelectedZone] = useState<any | null>(null); const [selectedZone, setSelectedZone] = useState<any | null>(null);
const [showAddMethod, setShowAddMethod] = useState(false); const [showAddMethod, setShowAddMethod] = useState(false);
@@ -481,8 +482,7 @@ export default function ShippingPage() {
<div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} /> <div className="font-medium" dangerouslySetInnerHTML={{ __html: rate.name }} />
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} /> <span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<span className={`text-xs px-2 py-0.5 rounded-full ${ <span className={`text-xs px-2 py-0.5 rounded-full ${rate.enabled
rate.enabled
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
}`}> }`}>
@@ -695,8 +695,7 @@ export default function ShippingPage() {
<div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} /> <div className="font-medium text-sm line-clamp-1" dangerouslySetInnerHTML={{ __html: rate.name }} />
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5"> <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} /> <span className="font-semibold" dangerouslySetInnerHTML={{ __html: rate.price }} />
<span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${ <span className={`px-1.5 py-0.5 rounded-full whitespace-nowrap ${rate.enabled
rate.enabled
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
}`}> }`}>

View File

@@ -41,6 +41,10 @@ interface WNW_CONFIG {
decimalSeparator: string; decimalSeparator: string;
decimals: number; decimals: number;
}; };
storeUrl?: string;
customerSpaEnabled?: boolean;
nonce?: string;
pluginUrl?: string;
} }
declare global { declare global {

View File

@@ -25,6 +25,7 @@
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.547.0", "lucide-react": "^0.547.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -3597,6 +3598,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -27,6 +27,7 @@
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.547.0", "lucide-react": "^0.547.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -59,16 +59,12 @@ const getAppearanceSettings = () => {
const getInitialRoute = () => { const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app'); const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route'); const initialRoute = appEl?.getAttribute('data-initial-route');
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
console.log('[WooNooW Customer] App element:', appEl);
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
return initialRoute || '/shop'; // Default to shop if not specified return initialRoute || '/shop'; // Default to shop if not specified
}; };
// Router wrapper component that uses hooks requiring Router context // Router wrapper component that uses hooks requiring Router context
function AppRoutes() { function AppRoutes() {
const initialRoute = getInitialRoute(); const initialRoute = getInitialRoute();
console.log('[WooNooW Customer] Using initial route:', initialRoute);
return ( return (
<BaseLayout> <BaseLayout>

View File

@@ -0,0 +1,296 @@
import React, { useState, useEffect } from 'react';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { api } from '@/lib/api/client';
interface CheckoutField {
key: string;
fieldset: 'billing' | 'shipping' | 'account' | 'order';
type: string;
label: string;
placeholder?: string;
required: boolean;
hidden: boolean;
class?: string[];
priority: number;
options?: Record<string, string> | null;
custom: boolean;
autocomplete?: string;
validate?: string[];
input_class?: string[];
custom_attributes?: Record<string, string>;
default?: string;
// For searchable_select type
search_endpoint?: string | null;
search_param?: string;
min_chars?: number;
}
interface DynamicCheckoutFieldProps {
field: CheckoutField;
value: string;
onChange: (value: string) => void;
countryOptions?: { value: string; label: string }[];
stateOptions?: { value: string; label: string }[];
}
interface SearchOption {
value: string;
label: string;
}
export function DynamicCheckoutField({
field,
value,
onChange,
countryOptions = [],
stateOptions = [],
}: DynamicCheckoutFieldProps) {
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
const [isSearching, setIsSearching] = useState(false);
// For searchable_select with API endpoint
useEffect(() => {
if (field.type !== 'searchable_select' || !field.search_endpoint) {
return;
}
// If we have a value but no options yet, we might need to load it
// This handles pre-selected values
}, [field.type, field.search_endpoint, value]);
// Handle API search for searchable_select
const handleApiSearch = async (searchTerm: string) => {
if (!field.search_endpoint) return;
const minChars = field.min_chars || 2;
if (searchTerm.length < minChars) {
setSearchOptions([]);
return;
}
setIsSearching(true);
try {
const param = field.search_param || 'search';
const results = await api.get<SearchOption[]>(field.search_endpoint, { [param]: searchTerm });
setSearchOptions(Array.isArray(results) ? results : []);
} catch (error) {
console.error('Search failed:', error);
setSearchOptions([]);
} finally {
setIsSearching(false);
}
};
// Don't render hidden fields
if (field.hidden) {
return null;
}
// Get field key without prefix (billing_, shipping_)
const fieldName = field.key.replace(/^(billing_|shipping_)/, '');
// Determine CSS classes
const isWide = ['address_1', 'address_2', 'email'].includes(fieldName) ||
field.class?.includes('form-row-wide');
const wrapperClass = isWide ? 'md:col-span-2' : '';
// Render based on type
const renderInput = () => {
switch (field.type) {
case 'country':
return (
<SearchableSelect
options={countryOptions}
value={value}
onChange={onChange}
placeholder={field.placeholder || 'Select country'}
disabled={countryOptions.length <= 1}
/>
);
case 'state':
return stateOptions.length > 0 ? (
<SearchableSelect
options={stateOptions}
value={value}
onChange={onChange}
placeholder={field.placeholder || 'Select state'}
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete}
className="w-full border !rounded-lg px-4 py-2"
/>
);
case 'select':
if (field.options && Object.keys(field.options).length > 0) {
const options = Object.entries(field.options).map(([val, label]) => ({
value: val,
label: String(label),
}));
return (
<SearchableSelect
options={options}
value={value}
onChange={onChange}
placeholder={field.placeholder || `Select ${field.label}`}
/>
);
}
return null;
case 'searchable_select':
return (
<SearchableSelect
options={searchOptions}
value={value}
onChange={(v) => {
onChange(v);
// Also store label for display
const selected = searchOptions.find(o => o.value === v);
if (selected) {
// Store label in a hidden field with _label suffix
const event = new CustomEvent('woonoow:field_label', {
detail: { key: field.key + '_label', value: selected.label }
});
document.dispatchEvent(event);
}
}}
onSearch={handleApiSearch}
isSearching={isSearching}
placeholder={field.placeholder || `Search ${field.label}...`}
emptyLabel={
isSearching
? 'Searching...'
: `Type at least ${field.min_chars || 2} characters to search`
}
/>
);
case 'textarea':
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
className="w-full border !rounded-lg px-4 py-2 min-h-[100px]"
/>
);
case 'checkbox':
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={value === '1' || value === 'true'}
onChange={(e) => onChange(e.target.checked ? '1' : '0')}
className="w-4 h-4"
/>
<span>{field.label}</span>
</label>
);
case 'radio':
if (field.options) {
return (
<div className="space-y-2">
{Object.entries(field.options).map(([val, label]) => (
<label key={val} className="flex items-center gap-2">
<input
type="radio"
name={field.key}
value={val}
checked={value === val}
onChange={() => onChange(val)}
className="w-4 h-4"
/>
<span>{String(label)}</span>
</label>
))}
</div>
);
}
return null;
case 'email':
return (
<input
type="email"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete || 'email'}
className="w-full border !rounded-lg px-4 py-2"
/>
);
case 'tel':
return (
<input
type="tel"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete || 'tel'}
className="w-full border !rounded-lg px-4 py-2"
/>
);
case 'password':
return (
<input
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
className="w-full border !rounded-lg px-4 py-2"
/>
);
// Default: text input
default:
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
autoComplete={field.autocomplete}
className="w-full border !rounded-lg px-4 py-2"
/>
);
}
};
// Don't render label for checkbox (it's inline)
if (field.type === 'checkbox') {
return (
<div className={wrapperClass}>
{renderInput()}
</div>
);
}
return (
<div className={wrapperClass}>
<label className="block text-sm font-medium mb-2">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{renderInput()}
</div>
);
}
export type { CheckoutField };

View File

@@ -0,0 +1,107 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-gray-900",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 border-none",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-gray-900",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-gray-100 data-[selected=true]:text-gray-900 data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-white p-4 shadow-md outline-none 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,123 @@
import * as React from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
} from "@/components/ui/command";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Option {
value: string;
label: string;
searchText?: string;
}
interface Props {
value?: string;
onChange?: (v: string) => void;
options: Option[];
placeholder?: string;
emptyLabel?: string;
className?: string;
disabled?: boolean;
// For API-based search
onSearch?: (searchTerm: string) => void;
isSearching?: boolean;
}
export function SearchableSelect({
value,
onChange,
options,
placeholder = "Select...",
emptyLabel = "No results found.",
className,
disabled = false,
onSearch,
isSearching = false,
}: Props) {
const [open, setOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const selected = options.find((o) => o.value === value);
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
// Handle search input changes
const handleSearchChange = (value: string) => {
setSearchValue(value);
if (onSearch) {
onSearch(value);
}
};
// Determine if we should use local filtering or API-based search
const shouldFilter = !onSearch;
return (
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<button
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",
disabled && "opacity-50 cursor-not-allowed",
className
)}
disabled={disabled}
aria-disabled={disabled}
>
<span className={selected ? "text-gray-900" : "text-gray-400"}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown className="opacity-50 h-4 w-4 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[--radix-popover-trigger-width]"
align="start"
sideOffset={4}
>
<Command shouldFilter={shouldFilter}>
<CommandInput
placeholder="Search..."
value={searchValue}
onValueChange={handleSearchChange}
/>
<CommandList>
<CommandEmpty>
{isSearching ? "Searching..." : emptyLabel}
</CommandEmpty>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.searchText || opt.label || opt.value}
onSelect={() => {
onChange?.(opt.value);
setOpen(false);
setSearchValue("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
opt.value === value ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -37,19 +37,9 @@ export function useAddToCartFromUrl() {
// Skip if already processed // Skip if already processed
if (processedRef.current.has(requestKey)) { if (processedRef.current.has(requestKey)) {
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
return; return;
} }
console.log('[WooNooW] Add to cart from URL:', {
productId,
variationId,
quantity,
redirect,
fullUrl: window.location.href,
requestKey,
});
// Mark as processed // Mark as processed
processedRef.current.add(requestKey); processedRef.current.add(requestKey);
@@ -58,7 +48,6 @@ export function useAddToCartFromUrl() {
// Update cart store with fresh data from API // Update cart store with fresh data from API
if (cartData) { if (cartData) {
setCart(cartData); setCart(cartData);
console.log('[WooNooW] Cart updated with fresh data:', cartData);
} }
// Remove URL parameters after adding to cart // Remove URL parameters after adding to cart
@@ -68,7 +57,6 @@ export function useAddToCartFromUrl() {
// Navigate based on redirect parameter // Navigate based on redirect parameter
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart'; const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
if (!location.pathname.includes(targetPage)) { if (!location.pathname.includes(targetPage)) {
console.log(`[WooNooW] Navigating to ${targetPage}`);
navigate(targetPage); navigate(targetPage);
} }
}) })
@@ -98,8 +86,6 @@ async function addToCart(
body.variation_id = parseInt(variationId, 10); body.variation_id = parseInt(variationId, 10);
} }
console.log('[WooNooW] Adding to cart:', body);
const response = await fetch(`${apiRoot}/cart/add`, { const response = await fetch(`${apiRoot}/cart/add`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -116,7 +102,6 @@ async function addToCart(
} }
const data = await response.json(); const data = await response.json();
console.log('[WooNooW] Product added to cart:', data);
// API returns {message, cart_item_key, cart} on success // API returns {message, cart_item_key, cart} on success
if (data.cart_item_key && data.cart) { if (data.cart_item_key && data.cart) {

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
/**
* Hook to set the document title dynamically
* @param title - The page title to set
* @param suffix - Optional suffix (default: store name from settings)
*/
export function usePageTitle(title: string, suffix?: string) {
useEffect(() => {
const storeName = (window as any).woonoowCustomer?.storeName || 'Store';
const finalSuffix = suffix ?? storeName;
document.title = title ? `${title} | ${finalSuffix}` : finalSuffix;
// Cleanup: restore original title when component unmounts
return () => {
// Don't restore - let the next page set its own title
};
}, [title, suffix]);
}
export default usePageTitle;

View File

@@ -134,8 +134,8 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</Link> </Link>
))} ))}
{/* Wishlist */} {/* Wishlist - Only for guests (logged-in users use /my-account/wishlist) */}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && ( {headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
<Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline"> <Link to="/wishlist" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Heart className="h-5 w-5" /> <Heart className="h-5 w-5" />
<span className="hidden lg:block">Wishlist</span> <span className="hidden lg:block">Wishlist</span>
@@ -428,7 +428,8 @@ function ModernLayout({ children }: BaseLayoutProps) {
</Link> </Link>
) )
)} )}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && ( {/* Wishlist - Only for guests */}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
<Link to="/wishlist" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline"> <Link to="/wishlist" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Heart className="h-4 w-4" /> Wishlist <Heart className="h-4 w-4" /> Wishlist
</Link> </Link>
@@ -561,7 +562,8 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
<User className="h-4 w-4" /> Account <User className="h-4 w-4" /> Account
</Link> </Link>
))} ))}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && ( {/* Wishlist - Only for guests */}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && !user?.isLoggedIn && (
<Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline"> <Link to="/wishlist" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Heart className="h-4 w-4" /> Wishlist <Heart className="h-4 w-4" /> Wishlist
</Link> </Link>

View File

@@ -20,6 +20,7 @@ export interface Cart {
tax: number; tax: number;
shipping: number; shipping: number;
total: number; total: number;
needs_shipping?: boolean;
coupon?: { coupon?: {
code: string; code: string;
discount: number; discount: number;

View File

@@ -1,6 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import SEOHead from '@/components/SEOHead';
interface AvatarSettings {
allow_custom_avatar: boolean;
current_avatar: string | null;
gravatar_url: string;
}
export default function AccountDetails() { export default function AccountDetails() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -16,8 +23,14 @@ export default function AccountDetails() {
confirmPassword: '', confirmPassword: '',
}); });
// Avatar state
const [avatarSettings, setAvatarSettings] = useState<AvatarSettings | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
loadProfile(); loadProfile();
loadAvatarSettings();
}, []); }, []);
const loadProfile = async () => { const loadProfile = async () => {
@@ -36,6 +49,89 @@ export default function AccountDetails() {
} }
}; };
const loadAvatarSettings = async () => {
try {
const data = await api.get<AvatarSettings>('/account/avatar-settings');
setAvatarSettings(data);
} catch (error) {
console.error('Load avatar settings error:', error);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast.error('Please upload a valid image (JPG, PNG, GIF, or WebP)');
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('Image size must be less than 2MB');
return;
}
setUploadingAvatar(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onloadend = async () => {
try {
const result = await api.post<{ avatar_url: string }>('/account/avatar', {
avatar: reader.result,
});
setAvatarSettings(prev => prev ? {
...prev,
current_avatar: result.avatar_url,
} : null);
// Dispatch event to sync sidebar avatar
window.dispatchEvent(new CustomEvent('woonoow:avatar-updated', { detail: { avatar_url: result.avatar_url } }));
toast.success('Avatar uploaded successfully');
} catch (error: any) {
console.error('Upload avatar error:', error);
toast.error(error.message || 'Failed to upload avatar');
} finally {
setUploadingAvatar(false);
}
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Read file error:', error);
toast.error('Failed to read image file');
setUploadingAvatar(false);
}
};
const handleRemoveAvatar = async () => {
setUploadingAvatar(true);
try {
await api.delete('/account/avatar');
setAvatarSettings(prev => prev ? {
...prev,
current_avatar: null,
} : null);
// Dispatch event to sync sidebar avatar (will fall back to gravatar)
window.dispatchEvent(new CustomEvent('woonoow:avatar-updated', { detail: { avatar_url: null } }));
toast.success('Avatar removed');
} catch (error: any) {
console.error('Remove avatar error:', error);
toast.error(error.message || 'Failed to remove avatar');
} finally {
setUploadingAvatar(false);
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
@@ -91,6 +187,8 @@ export default function AccountDetails() {
} }
}; };
const currentAvatarUrl = avatarSettings?.current_avatar || avatarSettings?.gravatar_url;
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -101,8 +199,60 @@ export default function AccountDetails() {
return ( return (
<div> <div>
<SEOHead title="Account Details" description="Edit your account information" />
<h1 className="text-2xl font-bold mb-6">Account Details</h1> <h1 className="text-2xl font-bold mb-6">Account Details</h1>
{/* Avatar Section */}
{avatarSettings?.allow_custom_avatar && (
<div className="mb-8 pb-8 border-b">
<h2 className="text-xl font-semibold mb-4">Profile Photo</h2>
<div className="flex items-center gap-6">
<div className="relative">
<img
src={currentAvatarUrl || '/placeholder-avatar.png'}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
{uploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{uploadingAvatar ? 'Uploading...' : 'Upload Photo'}
</button>
{avatarSettings?.current_avatar && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={uploadingAvatar}
className="px-4 py-2 border border-red-500 text-red-500 rounded-lg hover:bg-red-50 transition-colors disabled:opacity-50"
>
Remove Photo
</button>
)}
<p className="text-xs text-gray-500">
JPG, PNG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react'; import { MapPin, Plus, Edit, Trash2, Star } from 'lucide-react';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
import { DynamicCheckoutField } from '@/components/DynamicCheckoutField';
interface Address { interface Address {
id: number; id: number;
@@ -19,6 +21,35 @@ interface Address {
email?: string; email?: string;
phone?: string; phone?: string;
is_default: boolean; is_default: boolean;
// Custom fields
[key: string]: any;
}
interface CheckoutField {
key: string;
fieldset: 'billing' | 'shipping' | 'account' | 'order';
type: string;
label: string;
placeholder?: string;
required: boolean;
hidden: boolean;
class?: string[];
priority: number;
options?: Record<string, string> | null;
custom: boolean;
autocomplete?: string;
validate?: string[];
input_class?: string[];
custom_attributes?: Record<string, string>;
default?: string;
search_endpoint?: string | null;
search_param?: string;
min_chars?: number;
}
interface CountryOption {
value: string;
label: string;
} }
export default function Addresses() { export default function Addresses() {
@@ -26,23 +57,94 @@ export default function Addresses() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null); const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const [formData, setFormData] = useState<Partial<Address>>({ const [formData, setFormData] = useState<Record<string, any>>({
label: '', label: '',
type: 'both', type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false, is_default: false,
}); });
// Checkout fields from API
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
const [countryOptions, setCountryOptions] = useState<CountryOption[]>([]);
const [stateOptions, setStateOptions] = useState<CountryOption[]>([]);
const [loadingFields, setLoadingFields] = useState(true);
// Fetch checkout fields and countries
useEffect(() => {
const loadFieldsAndCountries = async () => {
try {
// Fetch checkout fields (POST method required by API)
const fieldsResponse = await api.post<{ fields: CheckoutField[] }>('/checkout/fields', {});
setCheckoutFields(fieldsResponse.fields || []);
// Fetch countries
const countriesResponse = await api.get<{ countries: Record<string, string> }>('/countries');
if (countriesResponse.countries) {
const options = Object.entries(countriesResponse.countries).map(([code, name]) => ({
value: code,
label: String(name),
}));
setCountryOptions(options);
}
} catch (error) {
console.error('Failed to load checkout fields:', error);
} finally {
setLoadingFields(false);
}
};
loadFieldsAndCountries();
}, []);
// Listen for field label events from DynamicCheckoutField (searchable_select)
// This captures the human-readable label alongside the ID value
useEffect(() => {
const handleFieldLabel = (event: CustomEvent<{ key: string; value: string }>) => {
const { key, value } = event.detail;
setFormData(prev => ({
...prev,
[key]: value,
}));
};
document.addEventListener('woonoow:field_label', handleFieldLabel as EventListener);
return () => {
document.removeEventListener('woonoow:field_label', handleFieldLabel as EventListener);
};
}, []);
// Fetch states when country changes
useEffect(() => {
const country = formData.country || formData.billing_country || '';
if (!country) {
setStateOptions([]);
return;
}
const loadStates = async () => {
try {
const response = await api.get<{ states: Record<string, string> }>(`/countries/${country}/states`);
if (response.states) {
const options = Object.entries(response.states).map(([code, name]) => ({
value: code,
label: String(name),
}));
setStateOptions(options);
} else {
setStateOptions([]);
}
} catch {
setStateOptions([]);
}
};
loadStates();
}, [formData.country, formData.billing_country]);
// Filter billing fields - API already returns them sorted by priority
const billingFields = useMemo(() => {
return checkoutFields
.filter(f => f.fieldset === 'billing' && !f.hidden && f.type !== 'hidden');
}, [checkoutFields]);
useEffect(() => { useEffect(() => {
loadAddresses(); loadAddresses();
}, []); }, []);
@@ -75,40 +177,86 @@ export default function Addresses() {
} }
}; };
// Helper to get field value - handles both prefixed and non-prefixed keys
const getFieldValue = (key: string): string => {
// Try exact key first
if (formData[key] !== undefined) return String(formData[key] || '');
// Try without prefix
const unprefixed = key.replace(/^billing_/, '');
if (formData[unprefixed] !== undefined) return String(formData[unprefixed] || '');
return '';
};
// Helper to set field value - stores both prefixed and unprefixed for compatibility
const setFieldValue = (key: string, value: string) => {
const unprefixed = key.replace(/^billing_/, '');
setFormData(prev => ({
...prev,
[key]: value,
[unprefixed]: value,
}));
};
const handleAdd = () => { const handleAdd = () => {
setEditingAddress(null); setEditingAddress(null);
setFormData({ // Initialize with defaults from API fields
const defaults: Record<string, any> = {
label: '', label: '',
type: 'both', type: 'both',
first_name: '',
last_name: '',
company: '',
address_1: '',
address_2: '',
city: '',
state: '',
postcode: '',
country: 'ID',
email: '',
phone: '',
is_default: false, is_default: false,
};
billingFields.forEach(field => {
if (field.default) {
const unprefixed = field.key.replace(/^billing_/, '');
defaults[field.key] = field.default;
defaults[unprefixed] = field.default;
}
}); });
setFormData(defaults);
setShowModal(true); setShowModal(true);
}; };
const handleEdit = (address: Address) => { const handleEdit = (address: Address) => {
setEditingAddress(address); setEditingAddress(address);
setFormData(address); // Map address fields to formData with both prefixed and unprefixed keys
const data: Record<string, any> = { ...address };
// Add billing_ prefixed versions
Object.entries(address).forEach(([key, value]) => {
data[`billing_${key}`] = value;
});
setFormData(data);
setShowModal(true); setShowModal(true);
}; };
const handleSave = async () => { const handleSave = async () => {
try { try {
// Prepare payload with unprefixed keys
const payload: Record<string, any> = {
label: formData.label,
type: formData.type,
is_default: formData.is_default,
};
// Add all fields (unprefixed)
billingFields.forEach(field => {
const unprefixed = field.key.replace(/^billing_/, '');
payload[unprefixed] = getFieldValue(field.key);
// Also include _label fields if they exist (for searchable_select fields)
const labelKey = field.key + '_label';
if (formData[labelKey]) {
const unprefixedLabel = unprefixed + '_label';
payload[unprefixedLabel] = formData[labelKey];
}
});
if (editingAddress) { if (editingAddress) {
await api.put(`/account/addresses/${editingAddress.id}`, formData); await api.put(`/account/addresses/${editingAddress.id}`, payload);
toast.success('Address updated successfully'); toast.success('Address updated successfully');
} else { } else {
await api.post('/account/addresses', formData); await api.post('/account/addresses', payload);
toast.success('Address added successfully'); toast.success('Address added successfully');
} }
setShowModal(false); setShowModal(false);
@@ -143,6 +291,13 @@ export default function Addresses() {
} }
}; };
// Check if a field should be wide (full width)
const isFieldWide = (field: CheckoutField): boolean => {
const fieldName = field.key.replace(/^billing_/, '');
return ['address_1', 'address_2', 'email'].includes(fieldName) ||
field.class?.includes('form-row-wide') || false;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -153,6 +308,7 @@ export default function Addresses() {
return ( return (
<div> <div>
<SEOHead title="Addresses" description="Manage your shipping and billing addresses" />
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Addresses</h1> <h1 className="text-2xl font-bold">Addresses</h1>
<button <button
@@ -243,22 +399,27 @@ export default function Addresses() {
{editingAddress ? 'Edit Address' : 'Add New Address'} {editingAddress ? 'Edit Address' : 'Add New Address'}
</h2> </h2>
{loadingFields ? (
<div className="py-8 text-center text-gray-500">Loading form fields...</div>
) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Label field - always shown */}
<div> <div>
<label className="block text-sm font-medium mb-1">Label *</label> <label className="block text-sm font-medium mb-1">Label *</label>
<input <input
type="text" type="text"
value={formData.label} value={formData.label || ''}
onChange={(e) => setFormData({ ...formData, label: e.target.value })} onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder="e.g., Home, Office, Parents" placeholder="e.g., Home, Office, Parents"
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border !rounded-lg"
/> />
</div> </div>
{/* Address Type - always shown */}
<div> <div>
<label className="block text-sm font-medium mb-1">Address Type *</label> <label className="block text-sm font-medium mb-1">Address Type *</label>
<select <select
value={formData.type} value={formData.type || 'both'}
onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })} onChange={(e) => setFormData({ ...formData, type: e.target.value as Address['type'] })}
className="w-full px-3 py-2 border rounded-lg" className="w-full px-3 py-2 border rounded-lg"
> >
@@ -268,136 +429,39 @@ export default function Addresses() {
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* Dynamic fields from checkout API - DynamicCheckoutField renders its own labels */}
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="block text-sm font-medium mb-1">First Name *</label> {billingFields.map((field) => (
<input <DynamicCheckoutField
type="text" key={field.key}
value={formData.first_name} field={field}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} value={getFieldValue(field.key)}
className="w-full px-3 py-2 border rounded-lg" onChange={(v) => setFieldValue(field.key, v)}
countryOptions={countryOptions}
stateOptions={stateOptions}
/> />
</div> ))}
<div>
<label className="block text-sm font-medium mb-1">Last Name *</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div> </div>
<div> {/* Set as default checkbox */}
<label className="block text-sm font-medium mb-1">Company</label> <div className="flex items-center gap-2 pt-2">
<input
type="text"
value={formData.company}
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 1 *</label>
<input
type="text"
value={formData.address_1}
onChange={(e) => setFormData({ ...formData, address_1: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Address Line 2</label>
<input
type="text"
value={formData.address_2}
onChange={(e) => setFormData({ ...formData, address_2: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">City *</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">State/Province *</label>
<input
type="text"
value={formData.state}
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Postcode *</label>
<input
type="text"
value={formData.postcode}
onChange={(e) => setFormData({ ...formData, postcode: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Country *</label>
<input
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
id="is_default" id="is_default"
checked={formData.is_default} checked={formData.is_default || false}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4" className="w-4 h-4"
/> />
<label htmlFor="is_default" className="text-sm">Set as default address</label> <label htmlFor="is_default" className="text-sm">Set as default address</label>
</div> </div>
</div> </div>
)}
<div className="flex gap-3 mt-6"> <div className="flex gap-3 mt-6">
<button <button
onClick={handleSave} onClick={handleSave}
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors" disabled={loadingFields}
className="font-[inherit] flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
Save Address Save Address
</button> </button>

View File

@@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ShoppingBag, Package, MapPin, User } from 'lucide-react'; import { ShoppingBag, Package, MapPin, User } from 'lucide-react';
import SEOHead from '@/components/SEOHead';
export default function Dashboard() { export default function Dashboard() {
const user = (window as any).woonoowCustomer?.user; const user = (window as any).woonoowCustomer?.user;
return ( return (
<div> <div>
<SEOHead title="My Account" description="Manage your account" />
<h1 className="text-2xl font-bold mb-6"> <h1 className="text-2xl font-bold mb-6">
Hello {user?.display_name || 'there'}! Hello {user?.display_name || 'there'}!
</h1> </h1>

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import SEOHead from '@/components/SEOHead';
interface DownloadItem { interface DownloadItem {
download_id: string; download_id: string;
@@ -97,6 +98,7 @@ export default function Downloads() {
return ( return (
<div> <div>
<SEOHead title="Downloads" description="Your purchased downloads" />
<h1 className="text-2xl font-bold mb-6">Downloads</h1> <h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -0,0 +1,260 @@
import React, { useState } from 'react';
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 { Key, Copy, Check, ChevronDown, ChevronUp, Monitor, Globe, Power } from 'lucide-react';
import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
interface Activation {
id: number;
domain: string | null;
machine_id: string | null;
ip_address: string | null;
status: 'active' | 'deactivated';
activated_at: string;
}
interface License {
id: number;
license_key: string;
product_name: string;
status: 'active' | 'revoked' | 'expired';
activation_limit: number;
activation_count: number;
activations_remaining: number;
expires_at: string | null;
is_expired: boolean;
created_at: string;
activations: Activation[];
}
export default function Licenses() {
const queryClient = useQueryClient();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [expandedLicense, setExpandedLicense] = useState<number | null>(null);
const { data: licenses = [], isLoading } = useQuery<License[]>({
queryKey: ['account-licenses'],
queryFn: () => api.get('/account/licenses'),
});
const deactivateMutation = useMutation({
mutationFn: ({ licenseId, activationId }: { licenseId: number; activationId: number }) =>
api.post(`/account/licenses/${licenseId}/deactivate`, { activation_id: activationId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['account-licenses'] });
toast.success('Activation deactivated successfully');
},
onError: () => {
toast.error('Failed to deactivate');
},
});
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
toast.success('License key copied to clipboard');
};
const getStatusStyle = (license: License) => {
if (license.status === 'revoked') {
return 'bg-red-100 text-red-800';
}
if (license.is_expired) {
return 'bg-gray-100 text-gray-800';
}
return 'bg-green-100 text-green-800';
};
const getStatusLabel = (license: License) => {
if (license.status === 'revoked') return 'Revoked';
if (license.is_expired) return 'Expired';
return 'Active';
};
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="Licenses" description="Manage your software licenses" />
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
My Licenses
</h1>
<p className="text-gray-500">
Manage your software licenses and activations
</p>
</div>
{licenses.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Key className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">You don't have any licenses yet.</p>
<p className="text-sm text-gray-400 mt-1">
Purchase a product with licensing to get started.
</p>
</div>
) : (
<div className="space-y-4">
{licenses.map((license) => (
<div key={license.id} className="bg-white rounded-lg border overflow-hidden">
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold">{license.product_name}</h3>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono">
{license.license_key}
</code>
<button
onClick={() => copyToClipboard(license.license_key)}
className="p-1 hover:bg-gray-100 rounded"
>
{copiedKey === license.license_key ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(license)}`}>
{getStatusLabel(license)}
</span>
<button
onClick={() => setExpandedLicense(expandedLicense === license.id ? null : license.id)}
className="p-1 hover:bg-gray-100 rounded"
>
{expandedLicense === license.id ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
{/* Summary Info */}
<div className="grid grid-cols-3 gap-4 mt-4 text-sm">
<div>
<p className="text-gray-500">Activations</p>
<p className="font-medium">
{license.activation_count} / {license.activation_limit === 0 ? '' : license.activation_limit}
</p>
</div>
<div>
<p className="text-gray-500">Purchased</p>
<p className="font-medium">
{new Date(license.created_at).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-gray-500">Expires</p>
<p className={`font-medium ${license.is_expired ? 'text-red-500' : ''}`}>
{license.expires_at
? new Date(license.expires_at).toLocaleDateString()
: 'Never'}
</p>
</div>
</div>
</div>
{/* Expanded Content - Activations */}
{expandedLicense === license.id && (
<div className="border-t p-4 bg-gray-50">
<h4 className="font-medium mb-3">Active Devices</h4>
{license.activations.filter(a => a.status === 'active').length === 0 ? (
<p className="text-sm text-gray-500 py-4 text-center">
No active devices. Activate your license on a device to see it here.
</p>
) : (
<div className="space-y-2">
{license.activations
.filter(a => a.status === 'active')
.map((activation) => (
<div
key={activation.id}
className="flex items-center justify-between bg-white p-3 rounded border"
>
<div className="flex items-center gap-2">
{activation.domain ? (
<>
<Globe className="h-4 w-4 text-gray-400" />
<span className="text-sm">{activation.domain}</span>
</>
) : activation.machine_id ? (
<>
<Monitor className="h-4 w-4 text-gray-400" />
<span className="text-sm font-mono">
{activation.machine_id.substring(0, 16)}...
</span>
</>
) : (
<span className="text-gray-400 text-sm">Unknown device</span>
)}
<span className="text-gray-400 text-xs">
{new Date(activation.activated_at).toLocaleDateString()}
</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600">
<Power className="h-4 w-4 mr-1" />
Deactivate
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate Device</AlertDialogTitle>
<AlertDialogDescription>
This will deactivate the license on this device. You can reactivate it later if you have available activation slots.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deactivateMutation.mutate({
licenseId: license.id,
activationId: activation.id,
})}
>
Deactivate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -12,6 +12,13 @@ interface OrderItem {
image?: string; image?: string;
} }
interface ShippingLine {
id: number;
method_title: string;
method_id: string;
total: string;
}
interface Order { interface Order {
id: number; id: number;
order_number: string; order_number: string;
@@ -26,6 +33,11 @@ interface Order {
shipping: any; shipping: any;
payment_method_title: string; payment_method_title: string;
needs_shipping: boolean; needs_shipping: boolean;
shipping_lines?: ShippingLine[];
// Tracking info (may be added by shipping plugins)
tracking_number?: string;
tracking_url?: string;
meta_data?: Array<{ key: string; value: string }>;
} }
export default function OrderDetails() { export default function OrderDetails() {
@@ -211,6 +223,59 @@ export default function OrderDetails() {
{order.payment_method_title || 'Not specified'} {order.payment_method_title || 'Not specified'}
</div> </div>
</div> </div>
{/* Shipping Method - only for physical product orders */}
{order.needs_shipping && order.shipping_lines && order.shipping_lines.length > 0 && (
<div className="mt-6 border rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b">
<h2 className="text-base font-medium">Shipping Method</h2>
</div>
<div className="p-4">
{order.shipping_lines.map((line) => (
<div key={line.id} className="flex justify-between text-sm">
<span>{line.method_title}</span>
<span className="font-medium">{line.total}</span>
</div>
))}
{/* AWB Tracking - show for processing or completed orders */}
{(order.status === 'processing' || order.status === 'completed') && (
<div className="mt-4 pt-4 border-t">
{order.tracking_number ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Tracking Number</span>
<span className="font-medium font-mono">{order.tracking_number}</span>
</div>
{order.tracking_url ? (
<a
href={order.tracking_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Track Shipment
</a>
) : (
<p className="text-sm text-gray-500">
Use the tracking number above to track your shipment on your courier's website.
</p>
)}
</div>
) : (
<div className="text-sm text-gray-500">
<p>Your order is being processed. Tracking information will be available once your order has been shipped.</p>
</div>
)}
</div>
)}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { Package, Eye } from 'lucide-react'; import { Package, Eye } from 'lucide-react';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
interface Order { interface Order {
id: number; id: number;
@@ -86,6 +87,7 @@ export default function Orders() {
return ( return (
<div> <div>
<SEOHead title="Orders" description="View your order history" />
<h1 className="text-2xl font-bold mb-6">Orders</h1> <h1 className="text-2xl font-bold mb-6">Orders</h1>
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -8,6 +8,7 @@ import { formatPrice } from '@/lib/currency';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useModules } from '@/hooks/useModules'; import { useModules } from '@/hooks/useModules';
import { useModuleSettings } from '@/hooks/useModuleSettings'; import { useModuleSettings } from '@/hooks/useModuleSettings';
import SEOHead from '@/components/SEOHead';
interface WishlistItem { interface WishlistItem {
product_id: number; product_id: number;
@@ -126,6 +127,7 @@ export default function Wishlist() {
return ( return (
<div> <div>
<SEOHead title="Wishlist" description="Your saved products" />
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>

View File

@@ -1,7 +1,8 @@
import React, { ReactNode, useState } from 'react'; import React, { ReactNode, useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react'; import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
import { useModules } from '@/hooks/useModules'; import { useModules } from '@/hooks/useModules';
import { api } from '@/lib/api/client';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -24,6 +25,27 @@ export function AccountLayout({ children }: AccountLayoutProps) {
const { isEnabled } = useModules(); const { isEnabled } = useModules();
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false; const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
// Fetch avatar settings
useEffect(() => {
const fetchAvatar = async () => {
try {
const data = await api.get<{ current_avatar: string | null; gravatar_url: string }>('/account/avatar-settings');
setAvatarUrl(data.current_avatar || data.gravatar_url);
} catch (error) {
console.error('Failed to fetch avatar:', error);
}
};
fetchAvatar();
// Listen for avatar updates
const handleAvatarUpdate = (e: CustomEvent) => {
setAvatarUrl(e.detail?.avatar_url || null);
};
window.addEventListener('woonoow:avatar-updated' as any, handleAvatarUpdate);
return () => window.removeEventListener('woonoow:avatar-updated' as any, handleAvatarUpdate);
}, []);
const allMenuItems = [ const allMenuItems = [
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
@@ -31,13 +53,16 @@ export function AccountLayout({ children }: AccountLayoutProps) {
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download }, { id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin }, { id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart }, { 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: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
]; ];
// Filter out wishlist if module disabled or settings disabled // Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
const menuItems = allMenuItems.filter(item => const menuItems = allMenuItems.filter(item => {
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled) if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
); if (item.id === 'licenses') return isEnabled('licensing');
return true;
});
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
@@ -55,10 +80,12 @@ export function AccountLayout({ children }: AccountLayoutProps) {
}); });
// Full page reload to clear cookies and refresh state // Full page reload to clear cookies and refresh state
window.location.href = window.location.origin + '/store/'; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
window.location.href = window.location.origin + basePath + '/';
} catch (error) { } catch (error) {
// Even on error, try to redirect and let server handle session // Even on error, try to redirect and let server handle session
window.location.href = window.location.origin + '/store/'; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
window.location.href = window.location.origin + basePath + '/';
} }
}; };
@@ -106,9 +133,17 @@ export function AccountLayout({ children }: AccountLayoutProps) {
<aside className="bg-white rounded-lg border p-4"> <aside className="bg-white rounded-lg border p-4">
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-3 pb-4 border-b"> <div className="flex items-center gap-3 pb-4 border-b">
{avatarUrl ? (
<img
src={avatarUrl}
alt={user?.display_name || 'User'}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<User className="w-6 h-6 text-gray-600" /> <User className="w-6 h-6 text-gray-600" />
</div> </div>
)}
<div> <div>
<p className="font-semibold">{user?.display_name || 'User'}</p> <p className="font-semibold">{user?.display_name || 'User'}</p>
<p className="text-sm text-gray-500">{user?.email}</p> <p className="text-sm text-gray-500">{user?.email}</p>

View File

@@ -9,6 +9,7 @@ import Downloads from './Downloads';
import Addresses from './Addresses'; import Addresses from './Addresses';
import Wishlist from './Wishlist'; import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails'; import AccountDetails from './AccountDetails';
import Licenses from './Licenses';
export default function Account() { export default function Account() {
const user = (window as any).woonoowCustomer?.user; const user = (window as any).woonoowCustomer?.user;
@@ -30,6 +31,7 @@ export default function Account() {
<Route path="downloads" element={<Downloads />} /> <Route path="downloads" element={<Downloads />} />
<Route path="addresses" element={<Addresses />} /> <Route path="addresses" element={<Addresses />} />
<Route path="wishlist" element={<Wishlist />} /> <Route path="wishlist" element={<Wishlist />} />
<Route path="licenses" element={<Licenses />} />
<Route path="account-details" element={<AccountDetails />} /> <Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} /> <Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes> </Routes>
@@ -37,3 +39,4 @@ export default function Account() {
</Container> </Container>
); );
} }

View File

@@ -13,6 +13,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react'; import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -155,6 +156,7 @@ export default function Cart() {
return ( return (
<Container> <Container>
<SEOHead title="Shopping Cart" description="Review your shopping cart" />
<div className={`py-8 ${layout.style === 'boxed' ? 'max-w-5xl mx-auto' : ''}`}> <div className={`py-8 ${layout.style === 'boxed' ? 'max-w-5xl mx-auto' : ''}`}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">

View File

@@ -3,7 +3,10 @@ import { useNavigate } from 'react-router-dom';
import { useCartStore } from '@/lib/cart/store'; import { useCartStore } from '@/lib/cart/store';
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings'; import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { DynamicCheckoutField, type CheckoutField } from '@/components/DynamicCheckoutField';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react'; import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -41,17 +44,22 @@ export default function Checkout() {
const [discountTotal, setDiscountTotal] = useState(0); const [discountTotal, setDiscountTotal] = useState(0);
const user = (window as any).woonoowCustomer?.user; const user = (window as any).woonoowCustomer?.user;
// Check if cart contains only virtual/downloadable products // Check if cart needs shipping (virtual-only carts don't need shipping)
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
const isVirtualOnly = React.useMemo(() => { const isVirtualOnly = React.useMemo(() => {
// Prefer the needs_shipping flag from the cart API ( WooCommerce calculates this properly)
if (typeof cart.needs_shipping === 'boolean') {
return !cart.needs_shipping;
}
// Fallback: check individual items if needs_shipping not available
if (cart.items.length === 0) return false; if (cart.items.length === 0) return false;
return cart.items.every(item => item.virtual || item.downloadable); return cart.items.every(item => item.virtual || item.downloadable);
}, [cart.items]); }, [cart.items, cart.needs_shipping]);
// Calculate totals // Calculate totals
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0); const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const shipping = isVirtualOnly ? 0 : 0; // No shipping for virtual products
const tax = 0; // TODO: Calculate tax const tax = 0; // TODO: Calculate tax
const total = subtotal + shipping + tax; // Note: shipping is calculated from dynamic shippingCost state (defined below)
// Form state // Form state
const [billingData, setBillingData] = useState({ const [billingData, setBillingData] = useState({
@@ -90,7 +98,265 @@ export default function Checkout() {
const [showBillingForm, setShowBillingForm] = useState(true); const [showBillingForm, setShowBillingForm] = useState(true);
const [showShippingForm, setShowShippingForm] = useState(true); const [showShippingForm, setShowShippingForm] = useState(true);
// Load saved addresses // Countries and states data
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
const [states, setStates] = useState<Record<string, Record<string, string>>>({});
const [defaultCountry, setDefaultCountry] = useState('');
// Load countries and states
useEffect(() => {
const loadCountries = async () => {
try {
const data = await api.get<{
countries: { code: string; name: string }[];
states: Record<string, Record<string, string>>;
default_country: string;
}>('/countries');
setCountries(data.countries || []);
setStates(data.states || {});
setDefaultCountry(data.default_country || '');
// Set default country if not already set
if (!billingData.country && data.default_country) {
setBillingData(prev => ({ ...prev, country: data.default_country }));
}
if (!shippingData.country && data.default_country) {
setShippingData(prev => ({ ...prev, country: data.default_country }));
}
} catch (error) {
console.error('Failed to load countries:', error);
}
};
loadCountries();
}, []);
// Country/state options for SearchableSelect
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
const billingStateOptions = Object.entries(states[billingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
const shippingStateOptions = Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
// Clear state when country changes
useEffect(() => {
if (billingData.country && billingData.state) {
const countryStates = states[billingData.country] || {};
if (!countryStates[billingData.state]) {
setBillingData(prev => ({ ...prev, state: '' }));
}
}
}, [billingData.country, states]);
useEffect(() => {
if (shippingData.country && shippingData.state) {
const countryStates = states[shippingData.country] || {};
if (!countryStates[shippingData.state]) {
setShippingData(prev => ({ ...prev, state: '' }));
}
}
}, [shippingData.country, states]);
// Dynamic checkout fields from API
const [checkoutFields, setCheckoutFields] = useState<CheckoutField[]>([]);
const [customFieldData, setCustomFieldData] = useState<Record<string, string>>({});
// Dynamic shipping rates
interface ShippingRate {
id: string;
label: string;
cost: number;
method_id: string;
instance_id: number;
}
const [shippingRates, setShippingRates] = useState<ShippingRate[]>([]);
const [selectedShippingRate, setSelectedShippingRate] = useState<string>('');
const [isLoadingRates, setIsLoadingRates] = useState(false);
const [shippingCost, setShippingCost] = useState(0);
// Fetch checkout fields from API
useEffect(() => {
const loadCheckoutFields = async () => {
if (cart.items.length === 0) return;
try {
const items = cart.items.map(item => ({
product_id: item.product_id,
qty: item.quantity,
}));
const data = await api.post<{
ok: boolean;
fields: CheckoutField[];
is_digital_only: boolean;
}>('/checkout/fields', { items, is_digital_only: isVirtualOnly });
if (data.ok && data.fields) {
setCheckoutFields(data.fields);
// Initialize custom field values with defaults
const customDefaults: Record<string, string> = {};
data.fields.forEach(field => {
if (field.default) {
customDefaults[field.key] = field.default;
}
});
if (Object.keys(customDefaults).length > 0) {
setCustomFieldData(prev => ({ ...customDefaults, ...prev }));
}
// Set billing default values for hidden fields (e.g., Indonesia-only stores)
const billingCountryField = data.fields.find(f => f.key === 'billing_country');
if (billingCountryField?.type === 'hidden' && billingCountryField.default) {
setBillingData(prev => ({ ...prev, country: billingCountryField.default || prev.country }));
}
// Set shipping default values for hidden fields
const shippingCountryField = data.fields.find(f => f.key === 'shipping_country');
if (shippingCountryField?.type === 'hidden' && shippingCountryField.default) {
setShippingData(prev => ({ ...prev, country: shippingCountryField.default || prev.country }));
}
}
} catch (error) {
console.error('Failed to load checkout fields:', error);
}
};
loadCheckoutFields();
}, [cart.items, isVirtualOnly]);
// NOTE: Old isFieldHidden function removed - now using getBillingField/getShippingField
// which return undefined for hidden fields (type: 'hidden' or hidden: true)
// Get all billing fields from API (standard + custom), filtered and sorted by priority
// This allows ANY field to be hidden via PHP (type: 'hidden' or hidden: true)
const billingFields = checkoutFields
.filter(f => f.fieldset === 'billing' && f.type !== 'hidden' && !f.hidden)
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
// Get all shipping fields from API (standard + custom), filtered and sorted by priority
const shippingFields = checkoutFields
.filter(f => f.fieldset === 'shipping' && f.type !== 'hidden' && !f.hidden)
.sort((a, b) => (a.priority || 10) - (b.priority || 10));
// Helper to get a billing field from API by key (for checking if it should be rendered)
const getBillingField = (key: string) => billingFields.find(f => f.key === key);
const getShippingField = (key: string) => shippingFields.find(f => f.key === key);
// Helper to determine if a field should be full width based on class array from PHP
// Supports: form-row-wide, form-row-first (half), form-row-last (half)
const isFullWidthField = (fieldKey: string, fieldset: 'billing' | 'shipping' = 'billing'): boolean => {
const field = fieldset === 'billing' ? getBillingField(fieldKey) : getShippingField(fieldKey);
if (!field?.class || !Array.isArray(field.class)) return false;
return field.class.includes('form-row-wide');
};
// Helper to get wrapper className for a field (handles width based on PHP class)
const getFieldWrapperClass = (fieldKey: string, fieldset: 'billing' | 'shipping' = 'billing', defaultFullWidth = false): string => {
if (isFullWidthField(fieldKey, fieldset) || defaultFullWidth) {
return 'md:col-span-2';
}
return ''; // Default half width in 2-column grid
};
// Filter custom fields by fieldset (legacy support - for plugins that add non-standard fields)
const billingCustomFields = checkoutFields.filter(f => f.fieldset === 'billing' && f.custom && !f.hidden && f.type !== 'hidden');
const shippingCustomFields = checkoutFields.filter(f => f.fieldset === 'shipping' && f.custom && !f.hidden && f.type !== 'hidden');
// Handler for custom field changes
const handleCustomFieldChange = (key: string, value: string) => {
setCustomFieldData(prev => ({ ...prev, [key]: value }));
};
// Listen for label events from searchable_select
useEffect(() => {
const handleLabelEvent = (e: Event) => {
const { key, value } = (e as CustomEvent).detail;
setCustomFieldData(prev => ({ ...prev, [key]: value }));
};
document.addEventListener('woonoow:field_label', handleLabelEvent);
return () => document.removeEventListener('woonoow:field_label', handleLabelEvent);
}, []);
// Fetch shipping rates when address changes
const fetchShippingRates = async () => {
if (isVirtualOnly || !cart.items.length) return;
// Get address data (use shipping if different, otherwise billing)
const addressData = shipToDifferentAddress ? shippingData : billingData;
// Need at least country to calculate shipping
if (!addressData.country) {
setShippingRates([]);
return;
}
setIsLoadingRates(true);
try {
const items = cart.items.map(item => ({
product_id: item.product_id,
quantity: item.quantity,
}));
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
const response = await api.post<{ ok: boolean; rates: ShippingRate[]; zone_name?: string }>('/checkout/shipping-rates', {
shipping: {
country: addressData.country,
state: addressData.state,
city: addressData.city,
postcode: addressData.postcode,
destination_id: destinationId || undefined,
},
items,
});
if (response.ok && response.rates) {
setShippingRates(response.rates);
// Auto-select first rate if none selected
if (response.rates.length > 0 && !selectedShippingRate) {
setSelectedShippingRate(response.rates[0].id);
setShippingCost(response.rates[0].cost);
}
}
} catch (error) {
console.error('Failed to fetch shipping rates:', error);
setShippingRates([]);
} finally {
setIsLoadingRates(false);
}
};
// Trigger shipping rate fetch when address or destination changes
useEffect(() => {
const addressData = shipToDifferentAddress ? shippingData : billingData;
const destinationId = shipToDifferentAddress
? customFieldData['shipping_destination_id']
: customFieldData['billing_destination_id'];
// Debounce the fetch
const timeoutId = setTimeout(() => {
if (addressData.country) {
fetchShippingRates();
}
}, 500);
return () => clearTimeout(timeoutId);
}, [
billingData.country, billingData.state, billingData.city, billingData.postcode,
shippingData.country, shippingData.state, shippingData.city, shippingData.postcode,
shipToDifferentAddress,
customFieldData['billing_destination_id'],
customFieldData['shipping_destination_id'],
cart.items.length,
]);
// Update shipping cost when rate selected
const handleShippingRateChange = (rateId: string) => {
setSelectedShippingRate(rateId);
const rate = shippingRates.find(r => r.id === rateId);
setShippingCost(rate?.cost || 0);
};
useEffect(() => { useEffect(() => {
const loadAddresses = async () => { const loadAddresses = async () => {
if (!user?.isLoggedIn) { if (!user?.isLoggedIn) {
@@ -271,6 +537,10 @@ export default function Checkout() {
state: billingData.state, state: billingData.state,
postcode: billingData.postcode, postcode: billingData.postcode,
country: billingData.country, country: billingData.country,
// Include custom billing fields
...Object.fromEntries(
billingCustomFields.map(f => [f.key.replace('billing_', ''), customFieldData[f.key] || ''])
),
}, },
shipping: shipToDifferentAddress ? { shipping: shipToDifferentAddress ? {
first_name: shippingData.firstName, first_name: shippingData.firstName,
@@ -281,11 +551,22 @@ export default function Checkout() {
postcode: shippingData.postcode, postcode: shippingData.postcode,
country: shippingData.country, country: shippingData.country,
ship_to_different: true, ship_to_different: true,
// Include custom shipping fields
...Object.fromEntries(
shippingCustomFields.map(f => [f.key.replace('shipping_', ''), customFieldData[f.key] || ''])
),
} : { } : {
ship_to_different: false, ship_to_different: false,
}, },
payment_method: paymentMethod, payment_method: paymentMethod,
shipping_method: selectedShippingRate || undefined, // Selected shipping rate ID
// Also send shipping cost/title for direct use when WC rate lookup fails (API-based shipping like Rajaongkir)
shipping_cost: shippingCost,
shipping_title: shippingRates.find(r => r.id === selectedShippingRate)?.label || '',
coupons: appliedCoupons.map(c => c.code), // Send applied coupon codes
customer_note: orderNotes, customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
}; };
// Submit order // Submit order
@@ -298,11 +579,11 @@ export default function Checkout() {
toast.success('Order placed successfully!'); toast.success('Order placed successfully!');
// Use full page reload instead of SPA routing // Navigate to thank you page via SPA routing
// This ensures auto-registered users get their auth cookies properly set // Using window.location.replace to prevent back button issues
const thankYouUrl = `${window.location.origin}/store/#/order-received/${data.order_id}?key=${data.order_key}`; const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
window.location.href = thankYouUrl; navigate(thankYouUrl, { replace: true });
window.location.reload(); return; // Stop execution here
} else { } else {
throw new Error(data.error || 'Failed to create order'); throw new Error(data.error || 'Failed to create order');
} }
@@ -314,8 +595,8 @@ export default function Checkout() {
} }
}; };
// Empty cart redirect // Empty cart redirect (but only if not processing)
if (cart.items.length === 0) { if (cart.items.length === 0 && !isProcessing) {
return ( return (
<Container> <Container>
<div className="text-center py-16"> <div className="text-center py-16">
@@ -333,6 +614,7 @@ export default function Checkout() {
return ( return (
<Container> <Container>
<SEOHead title="Checkout" description="Complete your purchase" />
<div className="py-8"> <div className="py-8">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
@@ -419,100 +701,146 @@ export default function Checkout() {
<div className="bg-white border rounded-lg p-6"> <div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Billing Details</h2> <h2 className="text-xl font-bold mb-4">Billing Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> {/* All fields conditionally rendered based on API response */}
<label className="block text-sm font-medium mb-2">First Name *</label> {/* This allows ANY field to be hidden via PHP snippet */}
{/* Width controlled via class: ['form-row-wide'] from PHP */}
{getBillingField('billing_first_name') && (
<div className={getFieldWrapperClass('billing_first_name', 'billing')}>
<label className="block text-sm font-medium mb-2">{getBillingField('billing_first_name')?.label || 'First Name'} {getBillingField('billing_first_name')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getBillingField('billing_first_name')?.required}
value={billingData.firstName} value={billingData.firstName}
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })} onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
<div> )}
<label className="block text-sm font-medium mb-2">Last Name *</label> {getBillingField('billing_last_name') && (
<div className={getFieldWrapperClass('billing_last_name', 'billing')}>
<label className="block text-sm font-medium mb-2">{getBillingField('billing_last_name')?.label || 'Last Name'} {getBillingField('billing_last_name')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getBillingField('billing_last_name')?.required}
value={billingData.lastName} value={billingData.lastName}
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })} onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{getBillingField('billing_email') && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Email Address *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_email')?.label || 'Email Address'} {getBillingField('billing_email')?.required && '*'}</label>
<input <input
type="email" type="email"
required required={getBillingField('billing_email')?.required}
value={billingData.email} value={billingData.email}
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })} onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{getBillingField('billing_phone') && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Phone *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_phone')?.label || 'Phone'} {getBillingField('billing_phone')?.required && '*'}</label>
<input <input
type="tel" type="tel"
required required={getBillingField('billing_phone')?.required}
value={billingData.phone} value={billingData.phone}
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })} onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{/* Address fields - only for physical products */} {/* Address fields - only for physical products */}
{!isVirtualOnly && ( {!isVirtualOnly && (
<> <>
{getBillingField('billing_address_1') && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-2">Street Address *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_address_1')?.label || 'Street Address'} {getBillingField('billing_address_1')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getBillingField('billing_address_1')?.required}
value={billingData.address} value={billingData.address}
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })} onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{/* City field - conditionally rendered based on API */}
{getBillingField('billing_city') && (
<div> <div>
<label className="block text-sm font-medium mb-2">City *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_city')?.label || 'City'} {getBillingField('billing_city')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getBillingField('billing_city')?.required}
value={billingData.city} value={billingData.city}
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })} onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{/* Country field - conditionally rendered based on API */}
{getBillingField('billing_country') && (
<div> <div>
<label className="block text-sm font-medium mb-2">State / Province *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_country')?.label || 'Country'} {getBillingField('billing_country')?.required && '*'}</label>
<SearchableSelect
options={countryOptions}
value={billingData.country}
onChange={(v) => setBillingData({ ...billingData, country: v })}
placeholder="Select country"
disabled={countries.length === 1}
/>
</div>
)}
{/* State field - conditionally rendered based on API */}
{getBillingField('billing_state') && (
<div>
<label className="block text-sm font-medium mb-2">{getBillingField('billing_state')?.label || 'State / Province'} {getBillingField('billing_state')?.required && '*'}</label>
{billingStateOptions.length > 0 ? (
<SearchableSelect
options={billingStateOptions}
value={billingData.state}
onChange={(v) => setBillingData({ ...billingData, state: v })}
placeholder="Select state"
/>
) : (
<input <input
type="text" type="text"
required
value={billingData.state} value={billingData.state}
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })} onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
className="w-full border rounded-lg px-4 py-2" placeholder="Enter state/province"
className="w-full border !rounded-lg px-4 py-2"
/> />
)}
</div> </div>
)}
{/* Postcode field - conditionally rendered based on API */}
{getBillingField('billing_postcode') && (
<div> <div>
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label> <label className="block text-sm font-medium mb-2">{getBillingField('billing_postcode')?.label || 'Postcode / ZIP'} {getBillingField('billing_postcode')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getBillingField('billing_postcode')?.required}
value={billingData.postcode} value={billingData.postcode}
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })} onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
<div> )}
<label className="block text-sm font-medium mb-2">Country *</label>
<input {/* Custom billing fields from plugins */}
type="text" {billingCustomFields.map(field => (
required <DynamicCheckoutField
value={billingData.country} key={field.key}
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })} field={field}
className="w-full border rounded-lg px-4 py-2" value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={billingStateOptions}
/> />
</div> ))}
</> </>
)} )}
</div> </div>
@@ -604,76 +932,116 @@ export default function Checkout() {
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */} {/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
{(!selectedShippingAddressId || showShippingForm) && ( {(!selectedShippingAddressId || showShippingForm) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> {/* Dynamic shipping fields using getShippingField like billing */}
<label className="block text-sm font-medium mb-2">First Name *</label> {getShippingField('shipping_first_name') && (
<div className={getFieldWrapperClass('shipping_first_name', 'shipping')}>
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_first_name')?.label || 'First Name'} {getShippingField('shipping_first_name')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getShippingField('shipping_first_name')?.required}
value={shippingData.firstName} value={shippingData.firstName}
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
<div> )}
<label className="block text-sm font-medium mb-2">Last Name *</label> {getShippingField('shipping_last_name') && (
<div className={getFieldWrapperClass('shipping_last_name', 'shipping')}>
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_last_name')?.label || 'Last Name'} {getShippingField('shipping_last_name')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getShippingField('shipping_last_name')?.required}
value={shippingData.lastName} value={shippingData.lastName}
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
<div className="md:col-span-2"> )}
<label className="block text-sm font-medium mb-2">Street Address *</label> {getShippingField('shipping_address_1') && (
<div className={getFieldWrapperClass('shipping_address_1', 'shipping') || 'md:col-span-2'}>
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_address_1')?.label || 'Street Address'} {getShippingField('shipping_address_1')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getShippingField('shipping_address_1')?.required}
value={shippingData.address} value={shippingData.address}
onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, address: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{/* City field - conditionally rendered based on API */}
{getShippingField('shipping_city') && (
<div> <div>
<label className="block text-sm font-medium mb-2">City *</label> <label className="block text-sm font-medium mb-2">{getShippingField('shipping_city')?.label || 'City'} {getShippingField('shipping_city')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getShippingField('shipping_city')?.required}
value={shippingData.city} value={shippingData.city}
onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, city: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
)}
{/* Country field - conditionally rendered based on API */}
{getShippingField('shipping_country') && (
<div> <div>
<label className="block text-sm font-medium mb-2">State / Province *</label> <label className="block text-sm font-medium mb-2">{getShippingField('shipping_country')?.label || 'Country'} {getShippingField('shipping_country')?.required && '*'}</label>
<SearchableSelect
options={countryOptions}
value={shippingData.country}
onChange={(v) => setShippingData({ ...shippingData, country: v })}
placeholder="Select country"
disabled={countries.length === 1}
/>
</div>
)}
{/* State field - conditionally rendered based on API */}
{getShippingField('shipping_state') && (
<div>
<label className="block text-sm font-medium mb-2">{getShippingField('shipping_state')?.label || 'State / Province'} {getShippingField('shipping_state')?.required && '*'}</label>
{shippingStateOptions.length > 0 ? (
<SearchableSelect
options={shippingStateOptions}
value={shippingData.state}
onChange={(v) => setShippingData({ ...shippingData, state: v })}
placeholder="Select state"
/>
) : (
<input <input
type="text" type="text"
required
value={shippingData.state} value={shippingData.state}
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
className="w-full border rounded-lg px-4 py-2" placeholder="Enter state/province"
className="w-full border !rounded-lg px-4 py-2"
/> />
)}
</div> </div>
)}
{/* Postcode field - conditionally rendered based on API */}
{getShippingField('shipping_postcode') && (
<div> <div>
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label> <label className="block text-sm font-medium mb-2">{getShippingField('shipping_postcode')?.label || 'Postcode / ZIP'} {getShippingField('shipping_postcode')?.required && '*'}</label>
<input <input
type="text" type="text"
required required={getShippingField('shipping_postcode')?.required}
value={shippingData.postcode} value={shippingData.postcode}
onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })} onChange={(e) => setShippingData({ ...shippingData, postcode: e.target.value })}
className="w-full border rounded-lg px-4 py-2" className="w-full border !rounded-lg px-4 py-2"
/> />
</div> </div>
<div> )}
<label className="block text-sm font-medium mb-2">Country *</label>
<input {/* Custom shipping fields from plugins */}
type="text" {shippingCustomFields.map(field => (
required <DynamicCheckoutField
value={shippingData.country} key={field.key}
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })} field={field}
className="w-full border rounded-lg px-4 py-2" value={customFieldData[field.key] || ''}
onChange={(v) => handleCustomFieldChange(field.key, v)}
countryOptions={countryOptions}
stateOptions={shippingStateOptions}
/> />
</div> ))}
</div> </div>
)} )}
</> </>
@@ -689,7 +1057,7 @@ export default function Checkout() {
value={orderNotes} value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)} onChange={(e) => setOrderNotes(e.target.value)}
placeholder="Notes about your order, e.g. special notes for delivery." placeholder="Notes about your order, e.g. special notes for delivery."
className="w-full border rounded-lg px-4 py-2 h-32" className="w-full border !rounded-lg px-4 py-2 h-32"
/> />
</div> </div>
)} )}
@@ -765,24 +1133,45 @@ export default function Checkout() {
</div> </div>
{/* Shipping Options */} {/* Shipping Options */}
{elements.shipping_options && ( {!isVirtualOnly && elements.shipping_options && (
<div className="mb-4 pb-4 border-b"> <div className="mb-4 pb-4 border-b">
<h3 className="font-medium mb-3">Shipping Method</h3> <h3 className="font-medium mb-3">Shipping Method</h3>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50"> {isLoadingRates ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 p-3 text-sm text-gray-500">
<input type="radio" name="shipping" value="free" defaultChecked className="w-4 h-4" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Free Shipping</span> <span>Calculating shipping rates...</span>
</div> </div>
<span className="text-sm font-medium">Free</span> ) : shippingRates.length === 0 ? (
</label> <div className="p-3 text-sm text-gray-500">
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50"> {billingData.country
<div className="flex items-center gap-2"> ? 'No shipping methods available for your location'
<input type="radio" name="shipping" value="express" className="w-4 h-4" /> : 'Enter your address to see shipping options'}
<span className="text-sm">Express Shipping</span>
</div> </div>
<span className="text-sm font-medium">$15.00</span> ) : (
shippingRates.map((rate) => (
<label
key={rate.id}
className={`flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 ${selectedShippingRate === rate.id ? 'border-primary bg-primary/5' : ''
}`}
>
<div className="flex items-center gap-2">
<input
type="radio"
name="shipping_method"
value={rate.id}
checked={selectedShippingRate === rate.id}
onChange={() => handleShippingRateChange(rate.id)}
className="w-4 h-4"
/>
<span className="text-sm">{rate.label}</span>
</div>
<span className="text-sm font-medium">
{rate.cost === 0 ? 'Free' : formatPrice(rate.cost)}
</span>
</label> </label>
))
)}
</div> </div>
</div> </div>
)} )}
@@ -800,10 +1189,12 @@ export default function Checkout() {
<span>-{formatPrice(discountTotal)}</span> <span>-{formatPrice(discountTotal)}</span>
</div> </div>
)} )}
{!isVirtualOnly && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Shipping</span> <span>Shipping</span>
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span> <span>{shippingCost === 0 ? 'Free' : formatPrice(shippingCost)}</span>
</div> </div>
)}
{tax > 0 && ( {tax > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Tax</span> <span>Tax</span>
@@ -812,7 +1203,7 @@ export default function Checkout() {
)} )}
<div className="border-t pt-2 flex justify-between font-bold text-lg"> <div className="border-t pt-2 flex justify-between font-bold text-lg">
<span>Total</span> <span>Total</span>
<span>{formatPrice(total - discountTotal)}</span> <span>{formatPrice(subtotal + (isVirtualOnly ? 0 : shippingCost) + tax - discountTotal)}</span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -12,6 +13,14 @@ export default function Login() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirect') || '/my-account'; const redirectTo = searchParams.get('redirect') || '/my-account';
// Redirect logged-in users to account page
useEffect(() => {
const user = (window as any).woonoowCustomer?.user;
if (user?.isLoggedIn) {
navigate(redirectTo, { replace: true });
}
}, [navigate, redirectTo]);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -91,7 +100,8 @@ export default function Login() {
// Set the target URL with hash route, then force reload // Set the target URL with hash route, then force reload
// The hash change alone doesn't reload the page, so cookies won't be refreshed // The hash change alone doesn't reload the page, so cookies won't be refreshed
const targetUrl = window.location.origin + '/store/#' + redirectTo; const basePath = (window as any).woonoowCustomer?.basePath || '/store';
const targetUrl = window.location.origin + basePath + '/#' + redirectTo;
window.location.href = targetUrl; window.location.href = targetUrl;
// Force page reload to refresh cookies and server-side state // Force page reload to refresh cookies and server-side state
window.location.reload(); window.location.reload();
@@ -107,6 +117,7 @@ export default function Login() {
return ( return (
<Container> <Container>
<SEOHead title="Login" description="Sign in to your account" />
<div className="min-h-[60vh] flex items-center justify-center py-12"> <div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Back link */} {/* Back link */}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useParams, Link, useSearchParams } from 'react-router-dom';
import { useThankYouSettings } from '@/hooks/useAppearanceSettings'; import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead';
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react'; import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
@@ -74,6 +75,7 @@ export default function ThankYou() {
if (template === 'receipt') { if (template === 'receipt') {
return ( return (
<div style={{ backgroundColor }}> <div style={{ backgroundColor }}>
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
<Container> <Container>
<div className="py-12 max-w-2xl mx-auto"> <div className="py-12 max-w-2xl mx-auto">
{/* Receipt Container */} {/* Receipt Container */}
@@ -145,6 +147,12 @@ export default function ThankYou() {
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span> <span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
</div> </div>
)} )}
{parseFloat(order.discount_total || 0) > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>DISCOUNT:</span>
<span className="font-mono">-{formatPrice(parseFloat(order.discount_total))}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2"> <div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
<span>TOTAL:</span> <span>TOTAL:</span>
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span> <span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
@@ -261,6 +269,12 @@ export default function ThankYou() {
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span> <span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
</div> </div>
)} )}
{parseFloat(order.discount_total || 0) > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>DISCOUNT:</span>
<span className="font-mono">-{formatPrice(parseFloat(order.discount_total))}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2"> <div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
<span>TOTAL:</span> <span>TOTAL:</span>
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span> <span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
@@ -351,6 +365,7 @@ export default function ThankYou() {
// Render basic style template (default) // Render basic style template (default)
return ( return (
<div style={{ backgroundColor }}> <div style={{ backgroundColor }}>
<SEOHead title="Order Confirmed" description={`Order #${order?.number || orderId} confirmed`} />
<Container> <Container>
<div className="py-12 max-w-3xl mx-auto"> <div className="py-12 max-w-3xl mx-auto">
{/* Success Header */} {/* Success Header */}
@@ -412,6 +427,12 @@ export default function ThankYou() {
<span>{formatPrice(parseFloat(order.tax_total))}</span> <span>{formatPrice(parseFloat(order.tax_total))}</span>
</div> </div>
)} )}
{parseFloat(order.discount_total || 0) > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span>-{formatPrice(parseFloat(order.discount_total))}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t"> <div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
<span>Total</span> <span>Total</span>
<span>{formatPrice(parseFloat(order.total || 0))}</span> <span>{formatPrice(parseFloat(order.total || 0))}</span>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import SEOHead from '@/components/SEOHead';
interface ProductData { interface ProductData {
id: number; id: number;
@@ -106,6 +107,7 @@ export default function Wishlist() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<SEOHead title="Wishlist" description="Your saved products" />
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1> <h1 className="text-3xl font-bold">My Wishlist</h1>

View File

@@ -66,16 +66,26 @@
--font-weight-bold: 700; --font-weight-bold: 700;
/* Font Sizes (8px base scale) */ /* Font Sizes (8px base scale) */
--text-xs: 0.75rem; /* 12px */ --text-xs: 0.75rem;
--text-sm: 0.875rem; /* 14px */ /* 12px */
--text-base: 1rem; /* 16px */ --text-sm: 0.875rem;
--text-lg: 1.125rem; /* 18px */ /* 14px */
--text-xl: 1.25rem; /* 20px */ --text-base: 1rem;
--text-2xl: 1.5rem; /* 24px */ /* 16px */
--text-3xl: 1.875rem; /* 30px */ --text-lg: 1.125rem;
--text-4xl: 2.25rem; /* 36px */ /* 18px */
--text-5xl: 3rem; /* 48px */ --text-xl: 1.25rem;
--text-6xl: 3.75rem; /* 60px */ /* 20px */
--text-2xl: 1.5rem;
/* 24px */
--text-3xl: 1.875rem;
/* 30px */
--text-4xl: 2.25rem;
/* 36px */
--text-5xl: 3rem;
/* 48px */
--text-6xl: 3.75rem;
/* 60px */
/* Line Heights */ /* Line Heights */
--line-height-none: 1; --line-height-none: 1;
@@ -90,29 +100,46 @@
* ======================================== */ * ======================================== */
--space-0: 0; --space-0: 0;
--space-1: 0.5rem; /* 8px */ --space-1: 0.5rem;
--space-2: 1rem; /* 16px */ /* 8px */
--space-3: 1.5rem; /* 24px */ --space-2: 1rem;
--space-4: 2rem; /* 32px */ /* 16px */
--space-5: 2.5rem; /* 40px */ --space-3: 1.5rem;
--space-6: 3rem; /* 48px */ /* 24px */
--space-8: 4rem; /* 64px */ --space-4: 2rem;
--space-10: 5rem; /* 80px */ /* 32px */
--space-12: 6rem; /* 96px */ --space-5: 2.5rem;
--space-16: 8rem; /* 128px */ /* 40px */
--space-20: 10rem; /* 160px */ --space-6: 3rem;
--space-24: 12rem; /* 192px */ /* 48px */
--space-8: 4rem;
/* 64px */
--space-10: 5rem;
/* 80px */
--space-12: 6rem;
/* 96px */
--space-16: 8rem;
/* 128px */
--space-20: 10rem;
/* 160px */
--space-24: 12rem;
/* 192px */
/* ======================================== /* ========================================
* BORDER RADIUS * BORDER RADIUS
* ======================================== */ * ======================================== */
--radius-none: 0; --radius-none: 0;
--radius-sm: 0.25rem; /* 4px */ --radius-sm: 0.25rem;
--radius-md: 0.5rem; /* 8px */ /* 4px */
--radius-lg: 1rem; /* 16px */ --radius-md: 0.5rem;
--radius-xl: 1.5rem; /* 24px */ /* 8px */
--radius-2xl: 2rem; /* 32px */ --radius-lg: 1rem;
/* 16px */
--radius-xl: 1.5rem;
/* 24px */
--radius-2xl: 2rem;
/* 32px */
--radius-full: 9999px; --radius-full: 9999px;
/* ======================================== /* ========================================
@@ -205,7 +232,12 @@ body {
background-color: var(--color-background); background-color: var(--color-background);
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading); font-family: var(--font-heading);
font-weight: var(--font-weight-heading); font-weight: var(--font-weight-heading);
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
@@ -247,7 +279,7 @@ a:hover {
} }
button { button {
font-family: var(--font-heading); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
} }

View File

@@ -72,6 +72,8 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]); ]);
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after'); wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
@@ -195,6 +197,8 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]); ]);
// WordPress REST API settings (for media upload compatibility) // WordPress REST API settings (for media upload compatibility)
@@ -308,4 +312,21 @@ class Assets
// Bump when releasing; in dev we don't cache-bust // Bump when releasing; in dev we don't cache-bust
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0'; return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
} }
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ($spa_page_id) {
$spa_url = get_permalink($spa_page_id);
if ($spa_url) {
return trailingslashit($spa_url);
}
}
// Fallback to /store/ if no SPA page configured
return home_url('/store/');
}
} }

View File

@@ -111,6 +111,26 @@ class Menu {
'title' => __( 'WooNooW Standalone Admin', 'woonoow' ), 'title' => __( 'WooNooW Standalone Admin', 'woonoow' ),
], ],
] ); ] );
// Add Store link if customer SPA is not disabled
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = get_post($spa_page_id);
$customer_spa_enabled = get_option( 'woonoow_customer_spa_enabled', true );
if ( $customer_spa_enabled && $spa_page) {
$spa_slug = $spa_page->post_name;
$store_url = home_url( '/' . $spa_slug );
$wp_admin_bar->add_node( [
'id' => 'woonoow-store',
'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>',
'href' => $store_url,
'meta' => [
'title' => __( 'View Customer Store', 'woonoow' ),
'target' => '_blank',
],
] );
}
} }
} }

View File

@@ -132,7 +132,9 @@ class StandaloneAdmin {
currentUser: <?php echo wp_json_encode( $current_user ); ?>, currentUser: <?php echo wp_json_encode( $current_user ); ?>,
locale: <?php echo wp_json_encode( get_locale() ); ?>, locale: <?php echo wp_json_encode( get_locale() ); ?>,
siteUrl: <?php echo wp_json_encode( home_url() ); ?>, siteUrl: <?php echo wp_json_encode( home_url() ); ?>,
siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?> siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>,
storeUrl: <?php echo wp_json_encode( self::get_spa_url() ); ?>,
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
}; };
// Also set WNW_API for API compatibility // Also set WNW_API for API compatibility
@@ -194,4 +196,21 @@ class StandaloneAdmin {
'currency_pos' => (string) $currency_pos, 'currency_pos' => (string) $currency_pos,
]; ];
} }
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$appearance_settings = get_option( 'woonoow_appearance_settings', [] );
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ( $spa_page_id ) {
$spa_url = get_permalink( $spa_page_id );
if ( $spa_url ) {
return trailingslashit( $spa_url );
}
}
// Fallback to /store/ if no SPA page configured
return home_url( '/store/' );
}
} }

View File

@@ -32,6 +32,12 @@ class CheckoutController {
'callback' => [ new self(), 'get_fields' ], 'callback' => [ new self(), 'get_fields' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ], 'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
]); ]);
// Public countries endpoint for customer checkout form
register_rest_route($namespace, '/countries', [
'methods' => 'GET',
'callback' => [ new self(), 'get_countries' ],
'permission_callback' => '__return_true', // Public - needed for checkout
]);
// Public order view endpoint for thank you page // Public order view endpoint for thank you page
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [ register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
'methods' => 'GET', 'methods' => 'GET',
@@ -44,6 +50,12 @@ class CheckoutController {
], ],
], ],
]); ]);
// Get available shipping rates for given address
register_rest_route($namespace, '/checkout/shipping-rates', [
'methods' => 'POST',
'callback' => [ new self(), 'get_shipping_rates' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
]);
} }
/** /**
@@ -186,18 +198,50 @@ class CheckoutController {
]; ];
} }
// Build shipping lines
$shipping_lines = [];
foreach ($order->get_shipping_methods() as $shipping_item) {
$shipping_lines[] = [
'id' => $shipping_item->get_id(),
'method_title' => $shipping_item->get_method_title(),
'method_id' => $shipping_item->get_method_id(),
'total' => wc_price($shipping_item->get_total()),
];
}
// Get tracking info from order meta (various plugins use different keys)
$tracking_number = $order->get_meta('_tracking_number')
?: $order->get_meta('_wc_shipment_tracking_items')
?: $order->get_meta('_rajaongkir_awb_number')
?: '';
$tracking_url = $order->get_meta('_tracking_url')
?: $order->get_meta('_rajaongkir_tracking_url')
?: '';
// Check for shipment tracking plugin format (array of tracking items)
if (is_array($tracking_number) && !empty($tracking_number)) {
$first_tracking = reset($tracking_number);
$tracking_number = $first_tracking['tracking_number'] ?? '';
$tracking_url = $first_tracking['tracking_url'] ?? $tracking_url;
}
return [ return [
'ok' => true, 'ok' => true,
'id' => $order->get_id(), 'id' => $order->get_id(),
'number' => $order->get_order_number(), 'number' => $order->get_order_number(),
'status' => $order->get_status(), 'status' => $order->get_status(),
'subtotal' => (float) $order->get_subtotal(), 'subtotal' => (float) $order->get_subtotal(),
'discount_total' => (float) $order->get_discount_total(),
'shipping_total' => (float) $order->get_shipping_total(), 'shipping_total' => (float) $order->get_shipping_total(),
'tax_total' => (float) $order->get_total_tax(), 'tax_total' => (float) $order->get_total_tax(),
'total' => (float) $order->get_total(), 'total' => (float) $order->get_total(),
'currency' => $order->get_currency(), 'currency' => $order->get_currency(),
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()), 'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
'payment_method' => $order->get_payment_method_title(), 'payment_method' => $order->get_payment_method_title(),
'needs_shipping' => count($shipping_lines) > 0 || $order->needs_shipping_address(),
'shipping_lines' => $shipping_lines,
'tracking_number' => $tracking_number,
'tracking_url' => $tracking_url,
'billing' => [ 'billing' => [
'first_name' => $order->get_billing_first_name(), 'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(), 'last_name' => $order->get_billing_last_name(),
@@ -382,6 +426,23 @@ class CheckoutController {
'taxes' => $rate->get_taxes(), 'taxes' => $rate->get_taxes(),
]); ]);
$order->add_item($item); $order->add_item($item);
} elseif (!empty($payload['shipping_cost']) && $payload['shipping_cost'] > 0) {
// Fallback: use shipping_cost directly from frontend
// This handles API-based shipping like Rajaongkir where WC zones don't apply
$item = new \WC_Order_Item_Shipping();
// Parse method ID from shipping_method (format: "method_id:instance_id" or "method_id:instance_id:variant")
$parts = explode(':', $payload['shipping_method']);
$method_id = $parts[0] ?? 'shipping';
$instance_id = isset($parts[1]) ? (int)$parts[1] : 0;
$item->set_props([
'method_title' => sanitize_text_field($payload['shipping_title'] ?? 'Shipping'),
'method_id' => sanitize_text_field($method_id),
'instance_id' => $instance_id,
'total' => floatval($payload['shipping_cost']),
]);
$order->add_item($item);
} }
} }
@@ -475,6 +536,14 @@ class CheckoutController {
'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields 'custom' => !in_array($key, $this->get_standard_field_keys()), // Flag custom fields
'autocomplete'=> $field['autocomplete'] ?? '', 'autocomplete'=> $field['autocomplete'] ?? '',
'validate' => $field['validate'] ?? [], 'validate' => $field['validate'] ?? [],
// New fields for dynamic rendering
'input_class' => $field['input_class'] ?? [],
'custom_attributes' => $field['custom_attributes'] ?? [],
'default' => $field['default'] ?? '',
// For searchable_select type
'search_endpoint' => $field['search_endpoint'] ?? null,
'search_param' => $field['search_param'] ?? 'search',
'min_chars' => $field['min_chars'] ?? 2,
]; ];
} }
} }
@@ -493,9 +562,10 @@ class CheckoutController {
/** /**
* Get list of standard WooCommerce field keys * Get list of standard WooCommerce field keys
* Plugins can extend this list via the 'woonoow_standard_checkout_field_keys' filter
*/ */
private function get_standard_field_keys(): array { private function get_standard_field_keys(): array {
return [ $keys = [
'billing_first_name', 'billing_first_name',
'billing_last_name', 'billing_last_name',
'billing_company', 'billing_company',
@@ -518,6 +588,14 @@ class CheckoutController {
'shipping_postcode', 'shipping_postcode',
'order_comments', 'order_comments',
]; ];
/**
* Filter the list of standard checkout field keys.
* Plugins can add their own field keys to be recognized as "standard" (not custom).
*
* @param array $keys List of standard field keys
*/
return apply_filters('woonoow_standard_checkout_field_keys', $keys);
} }
/** ----------------- Helpers ----------------- **/ /** ----------------- Helpers ----------------- **/
@@ -614,6 +692,7 @@ class CheckoutController {
$billing = isset($json['billing']) && is_array($json['billing']) ? $json['billing'] : []; $billing = isset($json['billing']) && is_array($json['billing']) ? $json['billing'] : [];
$shipping = isset($json['shipping']) && is_array($json['shipping']) ? $json['shipping'] : []; $shipping = isset($json['shipping']) && is_array($json['shipping']) ? $json['shipping'] : [];
$coupons = isset($json['coupons']) && is_array($json['coupons']) ? array_map('wc_clean', $json['coupons']) : []; $coupons = isset($json['coupons']) && is_array($json['coupons']) ? array_map('wc_clean', $json['coupons']) : [];
$custom_fields = isset($json['custom_fields']) && is_array($json['custom_fields']) ? $json['custom_fields'] : [];
return [ return [
'items' => array_map(function ($i) { 'items' => array_map(function ($i) {
@@ -629,6 +708,11 @@ class CheckoutController {
'coupons' => $coupons, 'coupons' => $coupons,
'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null, 'shipping_method' => isset($json['shipping_method']) ? wc_clean($json['shipping_method']) : null,
'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null, 'payment_method' => isset($json['payment_method']) ? wc_clean($json['payment_method']) : null,
// NEW: Added missing fields that were causing shipping to not be applied
'shipping_cost' => isset($json['shipping_cost']) ? (float) $json['shipping_cost'] : null,
'shipping_title' => isset($json['shipping_title']) ? sanitize_text_field($json['shipping_title']) : null,
'custom_fields' => $custom_fields,
'customer_note' => isset($json['customer_note']) ? sanitize_textarea_field($json['customer_note']) : '',
]; ];
} }
@@ -721,4 +805,161 @@ class CheckoutController {
} }
return null; return null;
} }
/**
* Get countries and states for checkout form
* Public endpoint - no authentication required
*/
public function get_countries(): array {
$wc_countries = WC()->countries;
// Get allowed selling countries
$allowed = $wc_countries->get_allowed_countries();
// Format for frontend
$countries = [];
foreach ($allowed as $code => $name) {
$countries[] = [
'code' => $code,
'name' => $name,
];
}
// Get states for all allowed countries
$states = [];
foreach (array_keys($allowed) as $country_code) {
$country_states = $wc_countries->get_states($country_code);
if (!empty($country_states) && is_array($country_states)) {
$states[$country_code] = $country_states;
}
}
// Get default country
$default_country = $wc_countries->get_base_country();
return [
'countries' => $countries,
'states' => $states,
'default_country' => $default_country,
];
}
/**
* Get available shipping rates for given address
* POST /checkout/shipping-rates
* Body: { shipping: { country, state, city, postcode, destination_id? }, items: [...] }
*/
public function get_shipping_rates(WP_REST_Request $r): array {
$payload = $r->get_json_params();
$shipping = $payload['shipping'] ?? [];
$items = $payload['items'] ?? [];
$country = wc_clean($shipping['country'] ?? '');
$state = wc_clean($shipping['state'] ?? '');
$city = wc_clean($shipping['city'] ?? '');
$postcode = wc_clean($shipping['postcode'] ?? '');
if (empty($country)) {
return [
'ok' => true,
'rates' => [],
'message' => 'Country is required',
];
}
// Trigger hook for plugins to set session data (e.g., Rajaongkir destination_id)
do_action('woonoow/shipping/before_calculate', $shipping, $items);
// Set customer location for shipping calculation
if (WC()->customer) {
WC()->customer->set_shipping_country($country);
WC()->customer->set_shipping_state($state);
WC()->customer->set_shipping_city($city);
WC()->customer->set_shipping_postcode($postcode);
}
// Build package for shipping calculation
$contents = [];
$contents_cost = 0;
foreach ($items as $item) {
$product = wc_get_product($item['product_id'] ?? 0);
if (!$product) continue;
$qty = max(1, (int)($item['quantity'] ?? $item['qty'] ?? 1));
$price = (float) wc_get_price_to_display($product);
$contents[] = [
'data' => $product,
'quantity' => $qty,
'line_total' => $price * $qty,
];
$contents_cost += $price * $qty;
}
$package = [
'destination' => [
'country' => $country,
'state' => $state,
'city' => $city,
'postcode' => $postcode,
],
'contents' => $contents,
'contents_cost' => $contents_cost,
'applied_coupons' => [],
'user' => ['ID' => get_current_user_id()],
];
// Get matching shipping zone
$zone = WC_Shipping_Zones::get_zone_matching_package($package);
if (!$zone) {
return [
'ok' => true,
'rates' => [],
'message' => 'No shipping zone matches your location',
];
}
// Get enabled shipping methods from zone
$methods = $zone->get_shipping_methods(true);
$rates = [];
foreach ($methods as $method) {
// Check if method has rates (some methods like live rate need to calculate)
if (method_exists($method, 'get_rates_for_package')) {
$method_rates = $method->get_rates_for_package($package);
foreach ($method_rates as $rate) {
$rates[] = [
'id' => $rate->get_id(),
'label' => $rate->get_label(),
'cost' => (float) $rate->get_cost(),
'method_id' => $rate->get_method_id(),
'instance_id' => $rate->get_instance_id(),
];
}
} else {
// Fallback for simple methods
$method_id = $method->id . ':' . $method->get_instance_id();
$cost = 0;
// Try to get cost from method
if (isset($method->cost)) {
$cost = (float) $method->cost;
} elseif (method_exists($method, 'get_option')) {
$cost = (float) $method->get_option('cost', 0);
}
$rates[] = [
'id' => $method_id,
'label' => $method->get_title(),
'cost' => $cost,
'method_id' => $method->id,
'instance_id' => $method->get_instance_id(),
];
}
}
return [
'ok' => true,
'rates' => $rates,
'zone_name' => $zone->get_zone_name(),
];
}
} }

View File

@@ -131,6 +131,21 @@ class CartController extends WP_REST_Controller {
$cart = WC()->cart; $cart = WC()->cart;
// Calculate totals to ensure discounts are computed
$cart->calculate_totals();
// Format coupons with discount amounts
$coupons_with_discounts = [];
foreach ($cart->get_applied_coupons() as $coupon_code) {
$coupon = new \WC_Coupon($coupon_code);
$discount = $cart->get_coupon_discount_amount($coupon_code);
$coupons_with_discounts[] = [
'code' => $coupon_code,
'discount' => (float) $discount,
'type' => $coupon->get_discount_type(),
];
}
return new WP_REST_Response([ return new WP_REST_Response([
'items' => $this->format_cart_items($cart->get_cart()), 'items' => $this->format_cart_items($cart->get_cart()),
'totals' => [ 'totals' => [
@@ -145,7 +160,8 @@ class CartController extends WP_REST_Controller {
'total' => $cart->get_total(''), 'total' => $cart->get_total(''),
'total_tax' => $cart->get_total_tax(), 'total_tax' => $cart->get_total_tax(),
], ],
'coupons' => $cart->get_applied_coupons(), 'coupons' => $coupons_with_discounts,
'discount_total' => (float) $cart->get_discount_total(), // Root level for frontend
'needs_shipping' => $cart->needs_shipping(), 'needs_shipping' => $cart->needs_shipping(),
'needs_payment' => $cart->needs_payment(), 'needs_payment' => $cart->needs_payment(),
'item_count' => $cart->get_cart_contents_count(), 'item_count' => $cart->get_cart_contents_count(),
@@ -365,6 +381,8 @@ class CartController extends WP_REST_Controller {
'total' => $cart_item['line_total'], 'total' => $cart_item['line_total'],
'image' => wp_get_attachment_image_url($product->get_image_id(), 'thumbnail'), 'image' => wp_get_attachment_image_url($product->get_image_id(), 'thumbnail'),
'permalink' => $product->get_permalink(), 'permalink' => $product->get_permalink(),
'virtual' => $product->is_virtual(),
'downloadable' => $product->is_downloadable(),
]; ];
} }

View File

@@ -0,0 +1,291 @@
<?php
/**
* Licenses API Controller
*
* REST API endpoints for license management.
*
* @package WooNooW\Api
*/
namespace WooNooW\Api;
if (!defined('ABSPATH')) exit;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class LicensesController {
/**
* Register REST routes
*/
public static function register_routes() {
// Check if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
// Admin routes
register_rest_route('woonoow/v1', '/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_licenses'],
'permission_callback' => function() {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_license'],
'permission_callback' => function() {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'revoke_license'],
'permission_callback' => function() {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_activations'],
'permission_callback' => function() {
return current_user_can('manage_woocommerce');
},
]);
// Customer routes
register_rest_route('woonoow/v1', '/account/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_customer_licenses'],
'permission_callback' => function() {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_deactivate'],
'permission_callback' => function() {
return is_user_logged_in();
},
]);
// Public API routes (for software validation)
register_rest_route('woonoow/v1', '/licenses/validate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'validate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/activate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'activate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'deactivate_license'],
'permission_callback' => '__return_true',
]);
}
/**
* Get all licenses (admin)
*/
public static function get_licenses(WP_REST_Request $request) {
$args = [
'search' => $request->get_param('search'),
'status' => $request->get_param('status'),
'product_id' => $request->get_param('product_id'),
'user_id' => $request->get_param('user_id'),
'limit' => $request->get_param('per_page') ?: 50,
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 50),
];
$result = LicenseManager::get_all_licenses($args);
// Enrich with product and user info
foreach ($result['licenses'] as &$license) {
$license = self::enrich_license($license);
}
return new WP_REST_Response([
'licenses' => $result['licenses'],
'total' => $result['total'],
'page' => $request->get_param('page') ?: 1,
'per_page' => $args['limit'],
]);
}
/**
* Get single license (admin)
*/
public static function get_license(WP_REST_Request $request) {
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
return new WP_REST_Response($license);
}
/**
* Revoke license (admin)
*/
public static function revoke_license(WP_REST_Request $request) {
$result = LicenseManager::revoke($request->get_param('id'));
if (!$result) {
return new WP_Error('revoke_failed', __('Failed to revoke license', 'woonoow'), ['status' => 500]);
}
return new WP_REST_Response(['success' => true]);
}
/**
* Get activations for license (admin)
*/
public static function get_activations(WP_REST_Request $request) {
$activations = LicenseManager::get_activations($request->get_param('id'));
return new WP_REST_Response($activations);
}
/**
* Get customer's licenses
*/
public static function get_customer_licenses(WP_REST_Request $request) {
$user_id = get_current_user_id();
$licenses = LicenseManager::get_user_licenses($user_id);
// Enrich each license
foreach ($licenses as &$license) {
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
}
return new WP_REST_Response($licenses);
}
/**
* Customer deactivate their own activation
*/
public static function customer_deactivate(WP_REST_Request $request) {
$user_id = get_current_user_id();
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license || $license['user_id'] != $user_id) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$data = $request->get_json_params();
$result = LicenseManager::deactivate(
$license['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Validate license (public API)
*/
public static function validate_license(WP_REST_Request $request) {
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::validate($data['license_key']);
return new WP_REST_Response($result);
}
/**
* Activate license (public API)
*/
public static function activate_license(WP_REST_Request $request) {
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$activation_data = [
'domain' => $data['domain'] ?? null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'machine_id' => $data['machine_id'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
];
$result = LicenseManager::activate($data['license_key'], $activation_data);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Deactivate license (public API)
*/
public static function deactivate_license(WP_REST_Request $request) {
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::deactivate(
$data['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Enrich license with product and user info
*/
private static function enrich_license($license) {
// Add product info
$product = wc_get_product($license['product_id']);
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
// Add user info
$user = get_userdata($license['user_id']);
$license['user_email'] = $user ? $user->user_email : '';
$license['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
// Add computed fields
$license['is_expired'] = $license['expires_at'] && strtotime($license['expires_at']) < time();
$license['activations_remaining'] = $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'])
: -1;
return $license;
}
}

View File

@@ -141,9 +141,9 @@ class ModulesController extends WP_REST_Controller {
// Toggle module // Toggle module
if ($enabled) { if ($enabled) {
ModuleRegistry::enable_module($module_id); ModuleRegistry::enable($module_id);
} else { } else {
ModuleRegistry::disable_module($module_id); ModuleRegistry::disable($module_id);
} }
// Return success response // Return success response

View File

@@ -217,6 +217,15 @@ class NotificationsController {
'permission_callback' => [$this, 'check_permission'], 'permission_callback' => [$this, 'check_permission'],
], ],
]); ]);
// GET /woonoow/v1/notifications/logs
register_rest_route($this->namespace, '/' . $this->rest_base . '/logs', [
[
'methods' => 'GET',
'callback' => [$this, 'get_logs'],
'permission_callback' => [$this, 'check_permission'],
],
]);
} }
/** /**
@@ -872,4 +881,54 @@ class NotificationsController {
), ),
], 200); ], 200);
} }
/**
* Get notification activity logs
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response
*/
public function get_logs(WP_REST_Request $request) {
$page = (int) $request->get_param('page') ?: 1;
$per_page = (int) $request->get_param('per_page') ?: 20;
$channel = $request->get_param('channel');
$status = $request->get_param('status');
$search = $request->get_param('search');
// Get logs from option (in a real app, use a custom table)
$all_logs = get_option('woonoow_notification_logs', []);
// Apply filters
if ($channel && $channel !== 'all') {
$all_logs = array_filter($all_logs, fn($log) => $log['channel'] === $channel);
}
if ($status && $status !== 'all') {
$all_logs = array_filter($all_logs, fn($log) => $log['status'] === $status);
}
if ($search) {
$search_lower = strtolower($search);
$all_logs = array_filter($all_logs, function($log) use ($search_lower) {
return strpos(strtolower($log['recipient'] ?? ''), $search_lower) !== false ||
strpos(strtolower($log['subject'] ?? ''), $search_lower) !== false;
});
}
// Sort by date descending
usort($all_logs, function($a, $b) {
return strtotime($b['created_at'] ?? '') - strtotime($a['created_at'] ?? '');
});
$total = count($all_logs);
$offset = ($page - 1) * $per_page;
$logs = array_slice(array_values($all_logs), $offset, $per_page);
return new WP_REST_Response([
'logs' => $logs,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
], 200);
}
} }

View File

@@ -858,25 +858,49 @@ class OrdersController {
} }
} }
// 3. Billing information is required for a healthy order // 3. Billing information required based on checkout fields configuration
$required_billing_fields = [ // Get checkout field settings to respect hidden/required status from PHP snippets
'first_name' => __( 'Billing first name', 'woonoow' ), $checkout_fields = apply_filters( 'woonoow/checkout/fields', [], $items );
'last_name' => __( 'Billing last name', 'woonoow' ),
'email' => __( 'Billing email', 'woonoow' ),
];
// Address fields only required for physical products // Helper to check if a billing field is required
if ( $has_physical_product ) { $is_field_required = function( $field_key ) use ( $checkout_fields ) {
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' ); foreach ( $checkout_fields as $field ) {
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' ); if ( isset( $field['key'] ) && $field['key'] === $field_key ) {
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' ); // Field is not required if hidden or explicitly not required
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' ); if ( ! empty( $field['hidden'] ) || $field['type'] === 'hidden' ) {
return false;
}
return ! empty( $field['required'] );
}
}
// Default: core fields are required if not found in API
return true;
};
// Core billing fields - check against API configuration
if ( $is_field_required( 'billing_first_name' ) && empty( $billing['first_name'] ) ) {
$validation_errors[] = __( 'Billing first name is required', 'woonoow' );
}
if ( $is_field_required( 'billing_last_name' ) && empty( $billing['last_name'] ) ) {
$validation_errors[] = __( 'Billing last name is required', 'woonoow' );
}
if ( $is_field_required( 'billing_email' ) && empty( $billing['email'] ) ) {
$validation_errors[] = __( 'Billing email is required', 'woonoow' );
} }
foreach ( $required_billing_fields as $field => $label ) { // Address fields only required for physical products AND if not hidden
if ( empty( $billing[ $field ] ) ) { if ( $has_physical_product ) {
/* translators: %s: field label */ if ( $is_field_required( 'billing_address_1' ) && empty( $billing['address_1'] ) ) {
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label ); $validation_errors[] = __( 'Billing address is required', 'woonoow' );
}
if ( $is_field_required( 'billing_city' ) && empty( $billing['city'] ) ) {
$validation_errors[] = __( 'Billing city is required', 'woonoow' );
}
if ( $is_field_required( 'billing_postcode' ) && empty( $billing['postcode'] ) ) {
$validation_errors[] = __( 'Billing postcode is required', 'woonoow' );
}
if ( $is_field_required( 'billing_country' ) && empty( $billing['country'] ) ) {
$validation_errors[] = __( 'Billing country is required', 'woonoow' );
} }
} }
@@ -1244,10 +1268,18 @@ class OrdersController {
$s = sanitize_text_field( $req->get_param('search') ?? '' ); $s = sanitize_text_field( $req->get_param('search') ?? '' );
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) ); $limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
$args = [ 'limit' => $limit, 'status' => 'publish' ]; // Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
if ( $s ) { $args['s'] = $s; } $args = [
'post_type' => [ 'product' ],
'post_status' => 'publish',
'posts_per_page' => $limit,
];
if ( $s ) {
$args['s'] = $s;
}
$prods = wc_get_products( $args ); $query = new \WP_Query( $args );
$prods = array_filter( array_map( 'wc_get_product', $query->posts ) );
$rows = array_map( function( $p ) { $rows = array_map( function( $p ) {
$data = [ $data = [
'id' => $p->get_id(), 'id' => $p->get_id(),
@@ -1295,8 +1327,8 @@ class OrdersController {
$value = $term ? $term->name : $value; $value = $term ? $term->name : $value;
} }
} else { } else {
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name // Custom attribute - WooCommerce stores as 'attribute_' + lowercase sanitized name
$meta_key = 'attribute_' . $attr_name; $meta_key = 'attribute_' . sanitize_title( $attr_name );
$value = get_post_meta( $variation_id, $meta_key, true ); $value = get_post_meta( $variation_id, $meta_key, true );
// Capitalize the attribute name for display // Capitalize the attribute name for display
@@ -1485,16 +1517,18 @@ class OrdersController {
WC()->customer->set_billing_postcode( $postcode ); WC()->customer->set_billing_postcode( $postcode );
WC()->customer->set_billing_city( $city ); WC()->customer->set_billing_city( $city );
// Support for Rajaongkir plugin - set destination in session /**
// Rajaongkir uses session-based destination instead of standard address fields * Allow shipping addons to prepare session/data before shipping calculation.
if ( $country === 'ID' && ! empty( $shipping['destination_id'] ) ) { *
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] ); * This hook allows third-party shipping plugins (like Rajaongkir, Biteship, etc.)
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $city ); * to set any session variables or prepare data they need before WooCommerce
} else { * calculates shipping rates.
// Clear Rajaongkir session data for non-ID countries *
WC()->session->__unset( 'selected_destination_id' ); * @since 1.0.0
WC()->session->__unset( 'selected_destination_label' ); * @param array $shipping The shipping address data from frontend (country, state, city, postcode, address_1, etc.)
} * @param array $items The cart items being shipped
*/
do_action( 'woonoow/shipping/before_calculate', $shipping, $items ?? [] );
} }
// Calculate shipping // Calculate shipping
@@ -1503,14 +1537,14 @@ class OrdersController {
// Get available shipping packages and rates // Get available shipping packages and rates
$packages = WC()->shipping()->get_packages(); $packages = WC()->shipping()->get_packages();
$methods = []; $rates = [];
foreach ( $packages as $package_key => $package ) { foreach ( $packages as $package_key => $package ) {
$rates = $package['rates'] ?? []; $package_rates = $package['rates'] ?? [];
foreach ( $rates as $rate_id => $rate ) { foreach ( $package_rates as $rate_id => $rate ) {
/** @var \WC_Shipping_Rate $rate */ /** @var \WC_Shipping_Rate $rate */
$methods[] = [ $rates[] = [
'id' => $rate_id, 'id' => $rate_id,
'method_id' => $rate->get_method_id(), 'method_id' => $rate->get_method_id(),
'instance_id' => $rate->get_instance_id(), 'instance_id' => $rate->get_instance_id(),
@@ -1522,12 +1556,65 @@ class OrdersController {
} }
} }
// Fallback: If no rates from packages, manually calculate from matching zone
if ( empty( $rates ) && ! empty( $shipping['country'] ) ) {
$package = [
'destination' => [
'country' => $shipping['country'] ?? '',
'state' => $shipping['state'] ?? '',
'postcode' => $shipping['postcode'] ?? '',
'city' => $shipping['city'] ?? '',
],
'contents' => WC()->cart->get_cart(),
'contents_cost' => WC()->cart->get_subtotal(),
'applied_coupons' => WC()->cart->get_applied_coupons(),
'user' => [ 'ID' => get_current_user_id() ],
];
$zone = \WC_Shipping_Zones::get_zone_matching_package( $package );
if ( $zone ) {
foreach ( $zone->get_shipping_methods( true ) as $method ) {
if ( method_exists( $method, 'get_rates_for_package' ) ) {
$method_rates = $method->get_rates_for_package( $package );
foreach ( $method_rates as $rate_id => $rate ) {
$rates[] = [
'id' => $rate_id,
'method_id' => $rate->get_method_id(),
'instance_id' => $rate->get_instance_id(),
'label' => $rate->get_label(),
'cost' => (float) $rate->get_cost(),
'taxes' => $rate->get_taxes(),
'meta_data' => $rate->get_meta_data(),
];
}
} else {
// Fallback for methods without get_rates_for_package (like Free Shipping)
$method_id = $method->id . ':' . $method->instance_id;
$rates[] = [
'id' => $method_id,
'method_id' => $method->id,
'instance_id' => $method->instance_id,
'label' => $method->get_title(),
'cost' => $method->id === 'free_shipping' ? 0 : (float) ($method->cost ?? 0),
'taxes' => [],
'meta_data' => [],
];
}
}
}
}
// Clean up // Clean up
WC()->cart->empty_cart(); WC()->cart->empty_cart();
return new \WP_REST_Response( [ return new \WP_REST_Response( [
'methods' => $methods, 'methods' => $rates, // Keep as 'methods' for frontend compatibility
'has_methods' => ! empty( $methods ), 'has_methods' => ! empty( $rates ),
'debug' => [
'packages_count' => count( $packages ),
'cart_items_count' => count( WC()->cart->get_cart() ),
'address' => $shipping,
],
], 200 ); ], 200 );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {

View File

@@ -413,6 +413,17 @@ class ProductsController {
$product->save(); $product->save();
// Licensing meta
if (isset($data['licensing_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
}
if (isset($data['license_activation_limit'])) {
update_post_meta($product->get_id(), '_woonoow_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
}
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
// Handle variations for variable products // Handle variations for variable products
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) { if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
self::save_product_attributes($product, $data['attributes']); self::save_product_attributes($product, $data['attributes']);
@@ -475,6 +486,29 @@ class ProductsController {
$product->set_featured((bool) $data['featured']); $product->set_featured((bool) $data['featured']);
} }
// Downloadable files
if (isset($data['downloads']) && is_array($data['downloads'])) {
$wc_downloads = [];
foreach ($data['downloads'] as $download) {
if (!empty($download['file'])) {
$wc_downloads[] = [
'id' => $download['id'] ?? md5($download['file']),
'name' => self::sanitize_text($download['name'] ?? ''),
'file' => esc_url_raw($download['file']),
];
}
}
$product->set_downloads($wc_downloads);
}
if (isset($data['download_limit'])) {
$limit = $data['download_limit'] === '' ? -1 : (int) $data['download_limit'];
$product->set_download_limit($limit);
}
if (isset($data['download_expiry'])) {
$expiry = $data['download_expiry'] === '' ? -1 : (int) $data['download_expiry'];
$product->set_download_expiry($expiry);
}
// Categories // Categories
if (isset($data['categories'])) { if (isset($data['categories'])) {
$product->set_category_ids($data['categories']); $product->set_category_ids($data['categories']);
@@ -523,6 +557,17 @@ class ProductsController {
$product->save(); $product->save();
// Licensing meta
if (isset($data['licensing_enabled'])) {
update_post_meta($product->get_id(), '_woonoow_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
}
if (isset($data['license_activation_limit'])) {
update_post_meta($product->get_id(), '_woonoow_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
}
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_woonoow_license_expiry_days', self::sanitize_number($data['license_duration_days']));
}
// Allow plugins to perform additional updates (Level 1 compatibility) // Allow plugins to perform additional updates (Level 1 compatibility)
do_action('woonoow/product_updated', $product, $data, $request); do_action('woonoow/product_updated', $product, $data, $request);
@@ -576,6 +621,7 @@ class ProductsController {
$categories = []; $categories = [];
foreach ($terms as $term) { foreach ($terms as $term) {
$categories[] = [ $categories[] = [
'id' => $term->term_id,
'term_id' => $term->term_id, 'term_id' => $term->term_id,
'name' => $term->name, 'name' => $term->name,
'slug' => $term->slug, 'slug' => $term->slug,
@@ -604,6 +650,7 @@ class ProductsController {
$tags = []; $tags = [];
foreach ($terms as $term) { foreach ($terms as $term) {
$tags[] = [ $tags[] = [
'id' => $term->term_id,
'term_id' => $term->term_id, 'term_id' => $term->term_id,
'name' => $term->name, 'name' => $term->name,
'slug' => $term->slug, 'slug' => $term->slug,
@@ -700,6 +747,26 @@ class ProductsController {
$data['downloadable'] = $product->is_downloadable(); $data['downloadable'] = $product->is_downloadable();
$data['featured'] = $product->is_featured(); $data['featured'] = $product->is_featured();
// Downloadable files
if ($product->is_downloadable()) {
$downloads = [];
foreach ($product->get_downloads() as $download_id => $download) {
$downloads[] = [
'id' => $download_id,
'name' => $download->get_name(),
'file' => $download->get_file(),
];
}
$data['downloads'] = $downloads;
$data['download_limit'] = $product->get_download_limit() !== -1 ? (string) $product->get_download_limit() : '';
$data['download_expiry'] = $product->get_download_expiry() !== -1 ? (string) $product->get_download_expiry() : '';
}
// Licensing fields
$data['licensing_enabled'] = get_post_meta($product->get_id(), '_woonoow_licensing_enabled', true) === 'yes';
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
// Images array (URLs) for frontend - featured + gallery // Images array (URLs) for frontend - featured + gallery
$images = []; $images = [];
$featured_image_id = $product->get_image_id(); $featured_image_id = $product->get_image_id();
@@ -833,6 +900,7 @@ class ProductsController {
'image_id' => $variation->get_image_id(), 'image_id' => $variation->get_image_id(),
'image_url' => $image_url, 'image_url' => $image_url,
'image' => $image_url, // For form compatibility 'image' => $image_url, // For form compatibility
'license_duration_days' => get_post_meta($variation->get_id(), '_license_duration_days', true) ?: '',
]; ];
} }
} }
@@ -929,6 +997,11 @@ class ProductsController {
$saved_id = $variation->save(); $saved_id = $variation->save();
$variations_to_keep[] = $saved_id; $variations_to_keep[] = $saved_id;
// Save variation-level license duration
if (isset($var_data['license_duration_days'])) {
update_post_meta($saved_id, '_license_duration_days', self::sanitize_number($var_data['license_duration_days']));
}
// Manually save attributes using direct database insert // Manually save attributes using direct database insert
if (!empty($wc_attributes)) { if (!empty($wc_attributes)) {
global $wpdb; global $wpdb;

View File

@@ -25,6 +25,7 @@ use WooNooW\Api\ModulesController;
use WooNooW\Api\ModuleSettingsController; use WooNooW\Api\ModuleSettingsController;
use WooNooW\Api\CampaignsController; use WooNooW\Api\CampaignsController;
use WooNooW\Api\DocsController; use WooNooW\Api\DocsController;
use WooNooW\Api\LicensesController;
use WooNooW\Frontend\ShopController; use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController; use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController; use WooNooW\Frontend\AccountController;
@@ -158,6 +159,9 @@ class Routes {
// Campaigns controller // Campaigns controller
CampaignsController::register_routes(); CampaignsController::register_routes();
// Licenses controller (licensing module)
LicensesController::register_routes();
// Modules controller // Modules controller
$modules_controller = new ModulesController(); $modules_controller = new ModulesController();
$modules_controller->register_routes(); $modules_controller->register_routes();

View File

@@ -82,6 +82,7 @@ class ModuleRegistry {
'category' => 'products', 'category' => 'products',
'icon' => 'key', 'icon' => 'key',
'default_enabled' => false, 'default_enabled' => false,
'has_settings' => true,
'features' => [ 'features' => [
__('License key generation', 'woonoow'), __('License key generation', 'woonoow'),
__('Activation management', 'woonoow'), __('Activation management', 'woonoow'),

View File

@@ -87,6 +87,27 @@ class AccountController {
'callback' => [__CLASS__, 'get_downloads'], 'callback' => [__CLASS__, 'get_downloads'],
'permission_callback' => [__CLASS__, 'check_customer_permission'], 'permission_callback' => [__CLASS__, 'check_customer_permission'],
]); ]);
// Avatar upload
register_rest_route($namespace, '/account/avatar', [
[
'methods' => 'POST',
'callback' => [__CLASS__, 'upload_avatar'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
[
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_avatar'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
],
]);
// Get avatar settings (check if custom avatars are enabled)
register_rest_route($namespace, '/account/avatar-settings', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_avatar_settings'],
'permission_callback' => [__CLASS__, 'check_customer_permission'],
]);
} }
/** /**
@@ -209,6 +230,143 @@ class AccountController {
], 200); ], 200);
} }
/**
* Upload customer avatar
*/
public static function upload_avatar(WP_REST_Request $request) {
// Check if custom avatars are enabled (stored as 'yes' or 'no')
$allow_custom_avatar = get_option('woonoow_allow_custom_avatar', 'no') === 'yes';
if (!$allow_custom_avatar) {
return new WP_Error('avatar_disabled', 'Custom avatars are not enabled', ['status' => 403]);
}
$user_id = get_current_user_id();
// Check for file data (base64 or URL)
$avatar_data = $request->get_param('avatar');
$avatar_url = $request->get_param('avatar_url');
if ($avatar_url) {
// Avatar URL provided (from media library)
update_user_meta($user_id, 'woonoow_custom_avatar', esc_url_raw($avatar_url));
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar updated successfully',
'avatar_url' => $avatar_url,
], 200);
}
if (!$avatar_data) {
return new WP_Error('no_avatar', 'No avatar data provided', ['status' => 400]);
}
// Handle base64 image upload
if (strpos($avatar_data, 'data:image') === 0) {
// Extract base64 data
$parts = explode(',', $avatar_data);
if (count($parts) !== 2) {
return new WP_Error('invalid_data', 'Invalid image data format', ['status' => 400]);
}
$image_data = base64_decode($parts[1]);
// Determine file extension from mime type
preg_match('/data:image\/(\w+);/', $parts[0], $matches);
$extension = $matches[1] ?? 'png';
// Validate extension
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array(strtolower($extension), $allowed)) {
return new WP_Error('invalid_type', 'Invalid image type. Allowed: jpg, png, gif, webp', ['status' => 400]);
}
// Create upload directory
$upload_dir = wp_upload_dir();
$avatar_dir = $upload_dir['basedir'] . '/woonoow-avatars';
if (!file_exists($avatar_dir)) {
wp_mkdir_p($avatar_dir);
}
// Generate unique filename
$filename = 'avatar-' . $user_id . '-' . time() . '.' . $extension;
$filepath = $avatar_dir . '/' . $filename;
// Delete old avatar if exists
$old_avatar = get_user_meta($user_id, 'woonoow_custom_avatar', true);
if ($old_avatar) {
$old_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $old_avatar);
if (file_exists($old_path)) {
unlink($old_path);
}
}
// Save new avatar
if (file_put_contents($filepath, $image_data) === false) {
return new WP_Error('upload_failed', 'Failed to save avatar', ['status' => 500]);
}
// Get URL
$avatar_url = $upload_dir['baseurl'] . '/woonoow-avatars/' . $filename;
// Save to user meta
update_user_meta($user_id, 'woonoow_custom_avatar', $avatar_url);
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar uploaded successfully',
'avatar_url' => $avatar_url,
], 200);
}
return new WP_Error('invalid_data', 'Invalid avatar data', ['status' => 400]);
}
/**
* Delete customer avatar
*/
public static function delete_avatar(WP_REST_Request $request) {
$user_id = get_current_user_id();
// Get current avatar
$avatar_url = get_user_meta($user_id, 'woonoow_custom_avatar', true);
if ($avatar_url) {
// Try to delete the file
$upload_dir = wp_upload_dir();
$filepath = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $avatar_url);
if (file_exists($filepath)) {
unlink($filepath);
}
// Remove from user meta
delete_user_meta($user_id, 'woonoow_custom_avatar');
}
return new WP_REST_Response([
'success' => true,
'message' => 'Avatar removed successfully',
], 200);
}
/**
* Get avatar settings
*/
public static function get_avatar_settings(WP_REST_Request $request) {
$user_id = get_current_user_id();
// Use correct option key (stored as 'yes' or 'no')
$allow_custom_avatar = get_option('woonoow_allow_custom_avatar', 'no') === 'yes';
return new WP_REST_Response([
'allow_custom_avatar' => $allow_custom_avatar,
'current_avatar' => get_user_meta($user_id, 'woonoow_custom_avatar', true) ?: null,
'gravatar_url' => get_avatar_url($user_id),
], 200);
}
/** /**
* Update password * Update password
*/ */
@@ -363,6 +521,37 @@ class AccountController {
$data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total()))); $data['shipping_total'] = html_entity_decode(strip_tags(wc_price($order->get_shipping_total())));
$data['tax_total'] = html_entity_decode(strip_tags(wc_price($order->get_total_tax()))); $data['tax_total'] = html_entity_decode(strip_tags(wc_price($order->get_total_tax())));
$data['discount_total'] = html_entity_decode(strip_tags(wc_price($order->get_discount_total()))); $data['discount_total'] = html_entity_decode(strip_tags(wc_price($order->get_discount_total())));
// Shipping lines with method details
$shipping_lines = [];
foreach ($order->get_shipping_methods() as $shipping_item) {
$shipping_lines[] = [
'id' => $shipping_item->get_id(),
'method_title' => $shipping_item->get_method_title(),
'method_id' => $shipping_item->get_method_id(),
'total' => html_entity_decode(strip_tags(wc_price($shipping_item->get_total()))),
];
}
$data['shipping_lines'] = $shipping_lines;
// Tracking info (from various shipping tracking plugins)
$tracking_number = $order->get_meta('_tracking_number')
?: $order->get_meta('_wc_shipment_tracking_items')
?: $order->get_meta('_rajaongkir_awb_number')
?: '';
$tracking_url = $order->get_meta('_tracking_url')
?: $order->get_meta('_rajaongkir_tracking_url')
?: '';
// Handle WooCommerce Shipment Tracking plugin format (array)
if (is_array($tracking_number) && !empty($tracking_number)) {
$first_tracking = reset($tracking_number);
$tracking_number = $first_tracking['tracking_number'] ?? '';
$tracking_url = $first_tracking['tracking_url'] ?? $tracking_url;
}
$data['tracking_number'] = $tracking_number;
$data['tracking_url'] = $tracking_url;
} }
return $data; return $data;

View File

@@ -86,25 +86,39 @@ class AddressController {
// Generate new ID // Generate new ID
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1; $new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
// Prepare address data // Standard address fields
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$reserved_fields = ['id', 'label', 'type', 'is_default'];
// Prepare address data with standard fields
$address = [ $address = [
'id' => $new_id, 'id' => $new_id,
'label' => sanitize_text_field($request->get_param('label')), 'label' => sanitize_text_field($request->get_param('label')),
'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both' 'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both'
'first_name' => sanitize_text_field($request->get_param('first_name')),
'last_name' => sanitize_text_field($request->get_param('last_name')),
'company' => sanitize_text_field($request->get_param('company')),
'address_1' => sanitize_text_field($request->get_param('address_1')),
'address_2' => sanitize_text_field($request->get_param('address_2')),
'city' => sanitize_text_field($request->get_param('city')),
'state' => sanitize_text_field($request->get_param('state')),
'postcode' => sanitize_text_field($request->get_param('postcode')),
'country' => sanitize_text_field($request->get_param('country')),
'email' => sanitize_email($request->get_param('email')),
'phone' => sanitize_text_field($request->get_param('phone')),
'is_default' => (bool) $request->get_param('is_default'), 'is_default' => (bool) $request->get_param('is_default'),
]; ];
// Add standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$address[$field] = sanitize_email($value);
} else {
$address[$field] = sanitize_text_field($value);
}
}
// Add any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store custom field
$address[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type // If this is set as default, unset other defaults of the same type
if ($address['is_default']) { if ($address['is_default']) {
foreach ($addresses as &$addr) { foreach ($addresses as &$addr) {
@@ -138,22 +152,36 @@ class AddressController {
if ($addr['id'] === $address_id) { if ($addr['id'] === $address_id) {
$found = true; $found = true;
// Update fields // Standard address fields
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$reserved_fields = ['id', 'label', 'type', 'is_default'];
// Update standard meta fields
$addr['label'] = sanitize_text_field($request->get_param('label')); $addr['label'] = sanitize_text_field($request->get_param('label'));
$addr['type'] = sanitize_text_field($request->get_param('type')); $addr['type'] = sanitize_text_field($request->get_param('type'));
$addr['first_name'] = sanitize_text_field($request->get_param('first_name'));
$addr['last_name'] = sanitize_text_field($request->get_param('last_name'));
$addr['company'] = sanitize_text_field($request->get_param('company'));
$addr['address_1'] = sanitize_text_field($request->get_param('address_1'));
$addr['address_2'] = sanitize_text_field($request->get_param('address_2'));
$addr['city'] = sanitize_text_field($request->get_param('city'));
$addr['state'] = sanitize_text_field($request->get_param('state'));
$addr['postcode'] = sanitize_text_field($request->get_param('postcode'));
$addr['country'] = sanitize_text_field($request->get_param('country'));
$addr['email'] = sanitize_email($request->get_param('email'));
$addr['phone'] = sanitize_text_field($request->get_param('phone'));
$addr['is_default'] = (bool) $request->get_param('is_default'); $addr['is_default'] = (bool) $request->get_param('is_default');
// Update standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$addr[$field] = sanitize_email($value);
} else {
$addr[$field] = sanitize_text_field($value);
}
}
// Update any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store/update custom field
$addr[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type // If this is set as default, unset other defaults of the same type
if ($addr['is_default']) { if ($addr['is_default']) {
foreach ($addresses as &$other_addr) { foreach ($addresses as &$other_addr) {

View File

@@ -197,7 +197,13 @@ class Assets {
// Determine SPA base path for BrowserRouter // Determine SPA base path for BrowserRouter
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = $spa_page_id ? get_post($spa_page_id) : null; $spa_page = $spa_page_id ? get_post($spa_page_id) : null;
$base_path = $spa_page ? '/' . $spa_page->post_name : '/store';
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
// If SPA is frontpage, base path is /, otherwise use page slug
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// Check if BrowserRouter is enabled (default: true for SEO) // Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true; $use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -249,6 +255,16 @@ class Assets {
private static function should_load_assets() { private static function should_load_assets() {
global $post; global $post;
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
return true;
}
// Check if we're on a frontpage SPA route (by URL detection)
if (self::is_frontpage_spa_route()) {
return true;
}
// First check: Is this a designated SPA page? // First check: Is this a designated SPA page?
if (self::is_spa_page()) { if (self::is_spa_page()) {
return true; return true;
@@ -366,6 +382,51 @@ class Assets {
return false; return false;
} }
/**
* Check if current request is a frontpage SPA route
* Used to detect SPA routes by URL when SPA page is set as frontpage
*/
private static function is_frontpage_spa_route() {
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return false;
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
// Check exact matches
if (in_array($path, $spa_routes)) {
return true;
}
// Check path prefixes
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
return true;
}
}
return false;
}
/** /**
* Dequeue conflicting scripts when SPA is active * Dequeue conflicting scripts when SPA is active
*/ */

View File

@@ -35,6 +35,9 @@ class TemplateOverride
// Redirect WooCommerce pages to SPA routes early (before template loads) // Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5); add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20) // Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does // This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10); add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
@@ -68,7 +71,7 @@ class TemplateOverride
/** /**
* Register rewrite rules for BrowserRouter SEO * Register rewrite rules for BrowserRouter SEO
* Catches all /store/* routes and serves the SPA page * Catches all SPA routes and serves the SPA page
*/ */
public static function register_spa_rewrite_rules() public static function register_spa_rewrite_rules()
{ {
@@ -89,13 +92,82 @@ class TemplateOverride
$spa_slug = $spa_page->post_name; $spa_slug = $spa_page->post_name;
// Rewrite /store/anything to serve the SPA page // Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
if ($is_spa_frontpage) {
// When SPA is frontpage, add root-level routes
// /shop, /shop/* → SPA page
add_rewrite_rule(
'^shop/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop',
'top'
);
add_rewrite_rule(
'^shop/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
'top'
);
// /product/* → SPA page
add_rewrite_rule(
'^product/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
'top'
);
// /cart → SPA page
add_rewrite_rule(
'^cart/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
'top'
);
// /checkout → SPA page
add_rewrite_rule(
'^checkout/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
'top'
);
// /my-account, /my-account/* → SPA page
add_rewrite_rule(
'^my-account/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account',
'top'
);
add_rewrite_rule(
'^my-account/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
'top'
);
// /login, /register, /reset-password → SPA page
add_rewrite_rule(
'^login/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=login',
'top'
);
add_rewrite_rule(
'^register/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=register',
'top'
);
add_rewrite_rule(
'^reset-password/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
} else {
// Rewrite /slug/anything to serve the SPA page
// React Router handles the path after that // React Router handles the path after that
add_rewrite_rule( add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$', '^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]', 'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
'top' 'top'
); );
}
// Register query var for the SPA path // Register query var for the SPA path
add_filter('query_vars', function($vars) { add_filter('query_vars', function($vars) {
@@ -174,6 +246,12 @@ class TemplateOverride
return; // No SPA page configured return; // No SPA page configured
} }
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
return;
}
// Already on SPA page, don't redirect // Already on SPA page, don't redirect
global $post; global $post;
if ($post && $post->ID == $spa_page_id) { if ($post && $post->ID == $spa_page_id) {
@@ -224,6 +302,88 @@ class TemplateOverride
} }
} }
/**
* Serve SPA template directly for frontpage SPA routes
* When SPA page is set as WordPress frontpage, intercept known routes
* and serve the SPA template directly (bypasses WooCommerce templates)
*/
public static function serve_spa_for_frontpage_routes()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return; // SPA is not frontpage, let normal routing handle it
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes that should be intercepted when SPA is frontpage
$spa_routes = [
'/', // Frontpage itself
'/shop', // Shop page
'/cart', // Cart page
'/checkout', // Checkout page
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/reset-password', // Password reset
];
// Check for exact matches or path prefixes
$should_serve_spa = false;
// Check exact matches
if (in_array($path, $spa_routes)) {
$should_serve_spa = true;
}
// Check path prefixes (for sub-routes)
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
break;
}
}
// Not a SPA route
if (!$should_serve_spa) {
return;
}
// Prevent caching for dynamic SPA content
nocache_headers();
// Load the SPA template directly and exit
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
// Set up minimal WordPress environment for the template
status_header(200);
// Define constant to tell Assets to load unconditionally
if (!defined('WOONOOW_SERVE_SPA')) {
define('WOONOOW_SERVE_SPA', true);
}
// Include the SPA template
include $spa_template;
exit;
}
}
/** /**
* Disable canonical redirects for SPA routes * Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs * This prevents WordPress from redirecting /product/slug URLs
@@ -406,17 +566,25 @@ class TemplateOverride
private static function is_spa_page() private static function is_spa_page()
{ {
global $post; global $post;
if (!$post) {
return false;
}
// Get SPA settings from appearance // Get SPA settings from appearance
$appearance_settings = get_option('woonoow_appearance_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0; $spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full'; $spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only return true if spa_mode is 'full' AND we're on the SPA page // Only check if spa_mode is 'full' and SPA page is configured
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) { if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if current page is the SPA page
if ($post && $post->ID == $spa_page_id) {
return true;
}
// Check if SPA page is set as WordPress frontpage and we're on frontpage
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id && is_front_page()) {
return true; return true;
} }

View File

@@ -0,0 +1,544 @@
<?php
/**
* License Manager
*
* Handles license key generation, activation, deactivation, and validation.
*
* @package WooNooW\Modules\Licensing
*/
namespace WooNooW\Modules\Licensing;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class LicenseManager {
private static $table_name = 'woonoow_licenses';
private static $activations_table = 'woonoow_license_activations';
/**
* Initialize
*/
public static function init() {
// Only initialize if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
// Hook into order completion - multiple hooks to catch all scenarios
add_action('woocommerce_order_status_completed', [__CLASS__, 'generate_licenses_for_order']);
add_action('woocommerce_order_status_processing', [__CLASS__, 'generate_licenses_for_order']);
add_action('woocommerce_payment_complete', [__CLASS__, 'generate_licenses_for_order']);
// Also hook into thank you page for COD/pending orders (with lower priority)
add_action('woocommerce_thankyou', [__CLASS__, 'maybe_generate_on_thankyou'], 10);
}
/**
* Maybe generate licenses on thank you page (for COD and pending orders)
*/
public static function maybe_generate_on_thankyou($order_id) {
if (!$order_id) return;
$order = wc_get_order($order_id);
if (!$order) return;
// Only generate for orders that didn't already get licenses via status hooks
// Check if it's a virtual-only order that might skip payment completion
$needs_payment = $order->needs_payment();
$is_virtual = self::is_virtual_order($order);
// Generate if: virtual order OR already paid (processing/completed)
if ($is_virtual || in_array($order->get_status(), ['processing', 'completed'])) {
self::generate_licenses_for_order($order_id);
}
}
/**
* Check if order contains only virtual items
*/
private static function is_virtual_order($order) {
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && !$product->is_virtual()) {
return false;
}
}
return true;
}
/**
* Create database tables
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$licenses_table = $wpdb->prefix . self::$table_name;
$activations_table = $wpdb->prefix . self::$activations_table;
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
// Create licenses table - dbDelta requires each CREATE TABLE to be called separately
$sql_licenses = "CREATE TABLE $licenses_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
license_key varchar(255) NOT NULL,
product_id bigint(20) UNSIGNED NOT NULL,
order_id bigint(20) UNSIGNED NOT NULL,
order_item_id bigint(20) UNSIGNED NOT NULL,
user_id bigint(20) UNSIGNED NOT NULL,
status varchar(20) NOT NULL DEFAULT 'active',
activation_limit int(11) NOT NULL DEFAULT 1,
activation_count int(11) NOT NULL DEFAULT 0,
expires_at datetime DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY license_key (license_key),
KEY product_id (product_id),
KEY order_id (order_id),
KEY user_id (user_id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_licenses);
// Create activations table
$sql_activations = "CREATE TABLE $activations_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
license_id bigint(20) UNSIGNED NOT NULL,
domain varchar(255) DEFAULT NULL,
ip_address varchar(45) DEFAULT NULL,
machine_id varchar(255) DEFAULT NULL,
user_agent text DEFAULT NULL,
status varchar(20) NOT NULL DEFAULT 'active',
activated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
deactivated_at datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY license_id (license_id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_activations);
}
/**
* Generate licenses for completed order
*/
public static function generate_licenses_for_order($order_id) {
$order = wc_get_order($order_id);
if (!$order) return;
foreach ($order->get_items() as $item_id => $item) {
$product_id = $item->get_product_id();
$product = wc_get_product($product_id);
if (!$product) continue;
// Check if product has licensing enabled
$licensing_enabled = get_post_meta($product_id, '_woonoow_licensing_enabled', true);
if ($licensing_enabled !== 'yes') continue;
// Check if license already exists for this order item
if (self::license_exists_for_order_item($item_id)) continue;
// Get activation limit from product or default
$activation_limit = (int) get_post_meta($product_id, '_woonoow_license_activation_limit', true);
if ($activation_limit <= 0) {
$activation_limit = (int) get_option('woonoow_licensing_default_activation_limit', 1);
}
// Get expiry from product or default
$expiry_days = (int) get_post_meta($product_id, '_woonoow_license_expiry_days', true);
if ($expiry_days <= 0 && get_option('woonoow_licensing_license_expiry_enabled', false)) {
$expiry_days = (int) get_option('woonoow_licensing_default_expiry_days', 365);
}
$expires_at = $expiry_days > 0 ? gmdate('Y-m-d H:i:s', strtotime("+$expiry_days days")) : null;
// Generate license for each quantity
$quantity = $item->get_quantity();
for ($i = 0; $i < $quantity; $i++) {
self::create_license([
'product_id' => $product_id,
'order_id' => $order_id,
'order_item_id' => $item_id,
'user_id' => $order->get_user_id(),
'activation_limit' => $activation_limit,
'expires_at' => $expires_at,
]);
}
}
}
/**
* Check if license already exists for order item
*/
public static function license_exists_for_order_item($order_item_id) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE order_item_id = %d",
$order_item_id
));
}
/**
* Create a new license
*/
public static function create_license($data) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$license_key = self::generate_license_key();
$wpdb->insert($table, [
'license_key' => $license_key,
'product_id' => $data['product_id'],
'order_id' => $data['order_id'],
'order_item_id' => $data['order_item_id'],
'user_id' => $data['user_id'],
'activation_limit' => $data['activation_limit'] ?? 1,
'expires_at' => $data['expires_at'] ?? null,
'status' => 'active',
]);
$license_id = $wpdb->insert_id;
do_action('woonoow/license/created', $license_id, $license_key, $data);
return [
'id' => $license_id,
'license_key' => $license_key,
];
}
/**
* Generate license key
*/
public static function generate_license_key() {
$format = get_option('woonoow_licensing_license_key_format', 'serial');
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
switch ($format) {
case 'uuid':
$key = wp_generate_uuid4();
break;
case 'alphanumeric':
$key = strtoupper(wp_generate_password(16, false));
break;
case 'serial':
default:
$key = strtoupper(sprintf(
'%s-%s-%s-%s',
wp_generate_password(4, false),
wp_generate_password(4, false),
wp_generate_password(4, false),
wp_generate_password(4, false)
));
break;
}
return $prefix . $key;
}
/**
* Get license by key
*/
public static function get_license_by_key($license_key) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE license_key = %s",
$license_key
), ARRAY_A);
}
/**
* Get license by ID
*/
public static function get_license($license_id) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE id = %d",
$license_id
), ARRAY_A);
}
/**
* Get licenses for user
*/
public static function get_user_licenses($user_id, $args = []) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$defaults = [
'status' => null,
'limit' => 50,
'offset' => 0,
];
$args = wp_parse_args($args, $defaults);
$where = "user_id = %d";
$params = [$user_id];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = "SELECT * FROM $table WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $args['limit'];
$params[] = $args['offset'];
return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
}
/**
* Activate license
*/
public static function activate($license_key, $activation_data = []) {
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
if ($license['status'] !== 'active') {
return new \WP_Error('license_inactive', __('License is not active', 'woonoow'));
}
// Check expiry
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
$block_expired = get_option('woonoow_licensing_block_expired_activations', true);
if ($block_expired) {
return new \WP_Error('license_expired', __('License has expired', 'woonoow'));
}
}
// Check activation limit
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
}
// Create activation record
$activations_table = $wpdb->prefix . self::$activations_table;
$licenses_table = $wpdb->prefix . self::$table_name;
$wpdb->insert($activations_table, [
'license_id' => $license['id'],
'domain' => $activation_data['domain'] ?? null,
'ip_address' => $activation_data['ip_address'] ?? null,
'machine_id' => $activation_data['machine_id'] ?? null,
'user_agent' => $activation_data['user_agent'] ?? null,
'status' => 'active',
]);
// Increment activation count
$wpdb->query($wpdb->prepare(
"UPDATE $licenses_table SET activation_count = activation_count + 1 WHERE id = %d",
$license['id']
));
do_action('woonoow/license/activated', $license['id'], $activation_data);
return [
'success' => true,
'activation_id' => $wpdb->insert_id,
'activations_remaining' => $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'] - 1)
: -1,
];
}
/**
* Deactivate license
*/
public static function deactivate($license_key, $activation_id = null, $machine_id = null) {
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
// Check if deactivation is allowed
$allow_deactivation = get_option('woonoow_licensing_allow_deactivation', true);
if (!$allow_deactivation) {
return new \WP_Error('deactivation_disabled', __('License deactivation is disabled', 'woonoow'));
}
$activations_table = $wpdb->prefix . self::$activations_table;
$licenses_table = $wpdb->prefix . self::$table_name;
// Find activation to deactivate
$where = "license_id = %d AND status = 'active'";
$params = [$license['id']];
if ($activation_id) {
$where .= " AND id = %d";
$params[] = $activation_id;
} elseif ($machine_id) {
$where .= " AND machine_id = %s";
$params[] = $machine_id;
}
$activation = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $activations_table WHERE $where LIMIT 1",
$params
), ARRAY_A);
if (!$activation) {
return new \WP_Error('no_activation', __('No active activation found', 'woonoow'));
}
// Deactivate
$wpdb->update(
$activations_table,
['status' => 'deactivated', 'deactivated_at' => current_time('mysql')],
['id' => $activation['id']]
);
// Decrement activation count
$wpdb->query($wpdb->prepare(
"UPDATE $licenses_table SET activation_count = GREATEST(0, activation_count - 1) WHERE id = %d",
$license['id']
));
do_action('woonoow/license/deactivated', $license['id'], $activation['id']);
return ['success' => true];
}
/**
* Validate license (check if valid without activating)
*/
public static function validate($license_key) {
$license = self::get_license_by_key($license_key);
if (!$license) {
return [
'valid' => false,
'error' => 'invalid_license',
'message' => __('Invalid license key', 'woonoow'),
];
}
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
return [
'valid' => $license['status'] === 'active' && !$is_expired,
'license_key' => $license['license_key'],
'status' => $license['status'],
'activation_limit' => (int) $license['activation_limit'],
'activation_count' => (int) $license['activation_count'],
'activations_remaining' => $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'])
: -1,
'expires_at' => $license['expires_at'],
'is_expired' => $is_expired,
];
}
/**
* Revoke license
*/
public static function revoke($license_id) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$result = $wpdb->update(
$table,
['status' => 'revoked'],
['id' => $license_id]
);
if ($result !== false) {
do_action('woonoow/license/revoked', $license_id);
return true;
}
return false;
}
/**
* Get all licenses (admin)
*/
public static function get_all_licenses($args = []) {
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$defaults = [
'search' => '',
'status' => null,
'product_id' => null,
'user_id' => null,
'limit' => 50,
'offset' => 0,
'orderby' => 'created_at',
'order' => 'DESC',
];
$args = wp_parse_args($args, $defaults);
$where_clauses = ['1=1'];
$params = [];
if ($args['search']) {
$where_clauses[] = "license_key LIKE %s";
$params[] = '%' . $wpdb->esc_like($args['search']) . '%';
}
if ($args['status']) {
$where_clauses[] = "status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where_clauses[] = "product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where_clauses[] = "user_id = %d";
$params[] = $args['user_id'];
}
$where = implode(' AND ', $where_clauses);
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'created_at DESC';
$sql = "SELECT * FROM $table WHERE $where ORDER BY $orderby LIMIT %d OFFSET %d";
$params[] = $args['limit'];
$params[] = $args['offset'];
$licenses = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
// Get total count
$count_sql = "SELECT COUNT(*) FROM $table WHERE $where";
$total = $wpdb->get_var($wpdb->prepare($count_sql, array_slice($params, 0, -2)));
return [
'licenses' => $licenses,
'total' => (int) $total,
];
}
/**
* Get activations for a license
*/
public static function get_activations($license_id) {
global $wpdb;
$table = $wpdb->prefix . self::$activations_table;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE license_id = %d ORDER BY activated_at DESC",
$license_id
), ARRAY_A);
}
}

View File

@@ -0,0 +1,128 @@
<?php
/**
* Licensing Module Bootstrap
*
* @package WooNooW\Modules\Licensing
*/
namespace WooNooW\Modules\Licensing;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\LicensingSettings;
class LicensingModule {
/**
* Initialize the licensing module
*/
public static function init() {
// Register settings schema
LicensingSettings::init();
// Initialize license manager immediately since we're already in plugins_loaded
// Note: This is called from woonoow.php inside plugins_loaded action,
// so we can call maybe_init_manager directly instead of scheduling another hook
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_licensing_fields']);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager() {
if (ModuleRegistry::is_enabled('licensing')) {
// Ensure tables exist
self::ensure_tables();
LicenseManager::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables() {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_licenses';
// Check if table exists
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
LicenseManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id) {
if ($module_id === 'licensing') {
LicenseManager::create_tables();
}
}
/**
* Add licensing fields to product edit page
*/
public static function add_product_licensing_fields() {
global $post;
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
echo '<div class="options_group show_if_simple show_if_downloadable">';
woocommerce_wp_checkbox([
'id' => '_woonoow_licensing_enabled',
'label' => __('Enable Licensing', 'woonoow'),
'description' => __('Generate license keys for this product on purchase', 'woonoow'),
]);
woocommerce_wp_text_input([
'id' => '_woonoow_license_activation_limit',
'label' => __('Activation Limit', 'woonoow'),
'description' => __('Max activations per license (0 = use default, leave empty for unlimited)', 'woonoow'),
'type' => 'number',
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_license_expiry_days',
'label' => __('License Expiry (Days)', 'woonoow'),
'description' => __('Days until license expires (0 = never expires)', 'woonoow'),
'type' => 'number',
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
echo '</div>';
}
/**
* Save licensing fields
*/
public static function save_product_licensing_fields($post_id) {
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
if (isset($_POST['_woonoow_license_activation_limit'])) {
update_post_meta($post_id, '_woonoow_license_activation_limit', absint($_POST['_woonoow_license_activation_limit']));
}
if (isset($_POST['_woonoow_license_expiry_days'])) {
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days']));
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* Licensing Module Settings
*
* @package WooNooW\Modules
*/
namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
class LicensingSettings {
/**
* Initialize the settings
*/
public static function init() {
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
}
/**
* Register licensing settings schema
*/
public static function register_schema($schemas) {
$schemas['licensing'] = [
'license_key_format' => [
'type' => 'select',
'label' => __('License Key Format', 'woonoow'),
'description' => __('Format for generated license keys', 'woonoow'),
'options' => [
'uuid' => 'UUID (e.g., a1b2c3d4-e5f6-7890-abcd-ef1234567890)',
'serial' => 'Serial (e.g., XXXX-XXXX-XXXX-XXXX)',
'alphanumeric' => 'Alphanumeric (e.g., ABC123DEF456)',
],
'default' => 'serial',
],
'license_key_prefix' => [
'type' => 'text',
'label' => __('License Key Prefix', 'woonoow'),
'description' => __('Optional prefix for license keys (e.g., PRO-, ENT-)', 'woonoow'),
'placeholder' => 'e.g., PRO-',
'default' => '',
],
'default_activation_limit' => [
'type' => 'number',
'label' => __('Default Activation Limit', 'woonoow'),
'description' => __('Default max activations per license (0 = unlimited). Can be overridden per product.', 'woonoow'),
'default' => 1,
'min' => 0,
'max' => 100,
],
'allow_deactivation' => [
'type' => 'toggle',
'label' => __('Allow Deactivation', 'woonoow'),
'description' => __('Allow customers to deactivate their licenses to free up activation slots', 'woonoow'),
'default' => true,
],
'license_expiry_enabled' => [
'type' => 'toggle',
'label' => __('Enable License Expiry', 'woonoow'),
'description' => __('Licenses expire after a set period (for subscription-based products)', 'woonoow'),
'default' => false,
],
'default_expiry_days' => [
'type' => 'number',
'label' => __('Default Expiry (Days)', 'woonoow'),
'description' => __('Default license validity period in days (0 = never expires)', 'woonoow'),
'default' => 365,
'min' => 0,
],
'block_expired_activations' => [
'type' => 'toggle',
'label' => __('Block Expired Activations', 'woonoow'),
'description' => __('Prevent new activations for expired licenses (deactivations still allowed)', 'woonoow'),
'default' => true,
],
'send_expiry_reminder' => [
'type' => 'toggle',
'label' => __('Send Expiry Reminders', 'woonoow'),
'description' => __('Send email reminders before license expires', 'woonoow'),
'default' => true,
],
'expiry_reminder_days' => [
'type' => 'number',
'label' => __('Reminder Days Before Expiry', 'woonoow'),
'description' => __('Send reminder this many days before expiry', 'woonoow'),
'default' => 7,
'min' => 1,
'max' => 30,
],
];
return $schemas;
}
}

View File

@@ -40,6 +40,7 @@ add_action('plugins_loaded', function () {
// Initialize module settings // Initialize module settings
WooNooW\Modules\NewsletterSettings::init(); WooNooW\Modules\NewsletterSettings::init();
WooNooW\Modules\WishlistSettings::init(); WooNooW\Modules\WishlistSettings::init();
WooNooW\Modules\Licensing\LicensingModule::init();
}); });
// Activation/Deactivation hooks // Activation/Deactivation hooks