Compare commits

...

65 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
Dwindi Ramadhana
0f542ad452 feat: Multiple fixes and features
1. Add allow_custom_avatar toggle to Customer Settings
2. Implement coupon apply/remove in Cart and Checkout pages
3. Update Cart interface with coupons array and discount_total
4. Implement Downloads page to fetch from /account/downloads API
2026-01-04 20:03:33 +07:00
Dwindi Ramadhana
befacf9d29 fix: Remove old Newsletter.tsx (conflicting with Newsletter/index.tsx)
The old file was being resolved by Vite instead of the new
Newsletter/index.tsx folder, preventing the tabs from appearing.
2026-01-04 19:11:28 +07:00
Dwindi Ramadhana
d9878c8b20 feat: Refactor Newsletter with horizontal tabs (Subscribers | Campaigns)
- Created Newsletter/index.tsx as tabs container
- Extracted Newsletter/Subscribers.tsx (from old Newsletter.tsx)
- Moved Campaigns to Newsletter/Campaigns.tsx
- Updated App.tsx routes (campaigns now under newsletter)
- Removed separate Campaigns card from Marketing index
- Follows Customer Notifications tab pattern for consistency
2026-01-04 19:06:18 +07:00
Dwindi Ramadhana
d65259db8a fix: Simplify Help page layout (remove sticky)
- Sticky not possible when page is inside overflow-auto container
- Using standard flexbox layout where sidebar and content scroll together
- Separate mobile (fixed overlay) and desktop (inline) sidebars
- Clean, simple layout matching typical documentation patterns
2026-01-04 12:37:40 +07:00
Dwindi Ramadhana
54a1ec1c88 fix: Separate mobile/desktop sidebar components
- Mobile: fixed overlay sidebar with proper z-index
- Desktop: sticky sidebar with correct top offset
- Extracted SidebarContent component to avoid duplication
- Matches App.tsx submenu bar positioning logic
2026-01-04 12:33:46 +07:00
Dwindi Ramadhana
3a8c436839 fix: Sidebar positioning - remove inset-y-0 conflict
- Fixed sidebar to not use inset-y-0 (was overriding top offset)
- Mobile: fixed positioning with sidebarTopClass
- Desktop: lg:sticky for proper sticky behavior
2026-01-04 12:30:46 +07:00
Dwindi Ramadhana
bfb961ccbe fix: Help page scroll and sidebar positioning
- Remove internal overflow (use wp-admin page scroll)
- Sidebar sticky under topbar with correct positioning
- Standalone mode: top-16 (below 64px header)
- WP Admin mode: top-[calc(7rem+32px)] (header+topnav+wp-admin bar)
- Uses useApp() to detect mode
2026-01-04 12:27:51 +07:00
Dwindi Ramadhana
f49dde9484 feat: Add Help to main navigation (no submenu bar)
- Added Help item to NavigationRegistry::get_base_tree
- Empty children array means no submenu bar displayed
- Incremented NAV_VERSION to 1.0.9 to trigger cache rebuild
- Help icon: help-circle
2026-01-04 12:01:18 +07:00
Dwindi Ramadhana
b64a979a61 fix: Use correct WOONOOW_PATH constant in DocsController
WOONOOW_PLUGIN_DIR was undefined, causing 500 errors.
The actual constant defined in woonoow.php is WOONOOW_PATH.
2026-01-04 11:55:15 +07:00
Dwindi Ramadhana
0e38b0eb5f fix: Documentation API authentication and build script
- Added X-WP-Nonce header to docs API fetch calls in Help page
- Fixed build-production.sh to include docs/ folder (changed --exclude='*.md' to --exclude='/*.md')
- This allows root-level docs like README.md to be excluded while keeping docs/ folder
2026-01-04 11:53:33 +07:00
Dwindi Ramadhana
68c3423f50 feat: Add in-app documentation system
Phase 1: Core Documentation
- Created docs/ folder with 8 markdown documentation files
- Getting Started, Installation, Troubleshooting, FAQ
- Configuration docs (Appearance, SPA Mode)
- Feature docs (Shop, Checkout)
- PHP registry with filter hook for addon extensibility

Phase 2: Documentation Viewer
- DocsController.php with REST API endpoints
- GET /woonoow/v1/docs - List all docs (with addon hook)
- GET /woonoow/v1/docs/{slug} - Get document content
- Admin SPA /help route with sidebar navigation
- Markdown rendering with react-markdown
- Added Help & Docs to More page for mobile access

Filter Hook: woonoow_docs_registry
Addons can register their own documentation sections.
2026-01-04 11:43:32 +07:00
Dwindi Ramadhana
1206117df1 fix: plugin activation no longer modifies WooCommerce pages
- Removed shortcode replacement for Cart, Checkout, My Account pages
- WooCommerce pages now keep their original [woocommerce_*] shortcodes
- Plugin only creates dedicated SPA page (/store) with [woonoow_spa]
- Auto-sets spa_page in appearance settings

This aligns with template override approach - WC pages render normally
when SPA is disabled, and redirect to SPA when mode is 'full'.
2026-01-04 11:15:52 +07:00
Dwindi Ramadhana
7c2f21f7a2 fix: SPA disabled mode now returns original template immediately
- Added spa_mode check at the BEGINNING of use_spa_template()
- When spa_mode = 'disabled', returns original template immediately
- Removed legacy woonoow_customer_spa_settings checks
- Simplified template override logic
2026-01-04 11:08:10 +07:00
Dwindi Ramadhana
7c15850c8f fix: SPA disabled mode now renders WooCommerce templates properly
- Updated should_use_spa() to check correct setting (woonoow_appearance_settings['general']['spa_mode'])
- Updated is_spa_page() to also check spa_mode
- Updated should_remove_theme_elements() to use appearance settings
- When spa_mode = 'disabled', WooCommerce templates render normally
2026-01-04 10:57:14 +07:00
Dwindi Ramadhana
670bd7d351 fix: PHP errors and clean up error_log statements
- Fixed redirect_wc_pages_to_spa: added spa_mode check (only redirect when 'full')
- Fixed PHP fatal error: use get_queried_object() instead of global $product
- Removed all error_log debug statements from codebase
- Fixed broken syntax in PaymentGatewaysProvider.php after error_log removal
2026-01-04 10:49:47 +07:00
Dwindi Ramadhana
75a82cf16c feat: add dynamic meta tags for social sharing (Phase 4-5)
Phase 4: Dynamic Meta Tags
- Added react-helmet-async dependency
- Created SEOHead component with Open Graph and Twitter Card support
- Added HelmetProvider wrapper to App.tsx
- Integrated SEOHead in Product page (title, description, image, product info)
- Integrated SEOHead in Shop page (basic meta tags)

Phase 5: Auto-Flush Permalinks
- Enhanced settings change handler to only flush when spa_mode,
  spa_page, or use_browser_router changes
- Plugin already flushes on activation (Installer.php)

This enables proper link previews when sharing product URLs
on Facebook, Twitter, Slack, etc.
2026-01-04 10:40:10 +07:00
Dwindi Ramadhana
45fcbf9d29 feat: migrate from HashRouter to BrowserRouter for SEO
Phase 1: WordPress Rewrite Rules
- Add rewrite rule for /store/* to serve SPA page
- Add use_browser_router setting toggle (default: true)
- Flush rewrite rules on settings change

Phase 2: React Router Migration
- Add BrowserRouter with basename from WordPress config
- Pass basePath and useBrowserRouter to frontend
- Conditional router based on setting

Phase 3: Hash Route Migration
- Update EmailManager.php reset password URL
- Update EmailRenderer.php login URL
- Update TemplateOverride.php WC redirects
- All routes now use path format by default

This enables proper SEO indexing as search engines
can now crawl individual product/page URLs.
2026-01-03 20:01:32 +07:00
114 changed files with 12698 additions and 1999 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

File diff suppressed because it is too large Load Diff

View File

@@ -49,8 +49,10 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"recharts": "^3.3.0", "recharts": "^3.3.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",

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';
@@ -257,10 +262,10 @@ import AppearanceCheckout from '@/routes/Appearance/Checkout';
import AppearanceThankYou from '@/routes/Appearance/ThankYou'; import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account'; import AppearanceAccount from '@/routes/Appearance/Account';
import MarketingIndex from '@/routes/Marketing'; import MarketingIndex from '@/routes/Marketing';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter'; import Newsletter from '@/routes/Marketing/Newsletter';
import CampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import MorePage from '@/routes/More'; import MorePage from '@/routes/More';
import Help from '@/routes/Help';
// Addon Route Component - Dynamically loads addon components // Addon Route Component - Dynamically loads addon components
function AddonRoute({ config }: { config: any }) { function AddonRoute({ config }: { config: any }) {
@@ -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 />} />
@@ -579,9 +611,11 @@ function AppRoutes() {
{/* Marketing */} {/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} /> <Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} /> <Route path="/marketing/newsletter" element={<Newsletter />} />
<Route path="/marketing/campaigns" element={<CampaignsList />} /> <Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
{/* Help - Main menu route with no submenu */}
<Route path="/help" element={<Help />} />
{/* Dynamic Addon Routes */} {/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => ( {addonRoutes.map((route: any) => (

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-4x6 { } .print-letter {}
.print-4x6 {}
@media print { @media print {
.print-a4 { }
.print-letter { } /* 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 {}
/* 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

@@ -0,0 +1,167 @@
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import type { DocContent as DocContentType } from './types';
interface DocContentProps {
slug: string;
}
export default function DocContent({ slug }: DocContentProps) {
const [doc, setDoc] = useState<DocContentType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDoc = async () => {
setLoading(true);
setError(null);
try {
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
const response = await fetch(`/wp-json/woonoow/v1/docs/${slug}`, {
credentials: 'include',
headers: {
'X-WP-Nonce': nonce,
},
});
if (!response.ok) {
throw new Error('Document not found');
}
const data = await response.json();
if (data.success) {
setDoc(data.doc);
} else {
throw new Error(data.message || 'Failed to load document');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load document');
setDoc(null);
} finally {
setLoading(false);
}
};
fetchDoc();
}, [slug]);
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full mt-6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
</Alert>
);
}
if (!doc) {
return null;
}
return (
<article className="prose prose-slate dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom heading with anchor links
h1: ({ children }) => (
<h1 className="text-3xl font-bold mb-6 pb-4 border-b">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold mt-10 mb-4">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-medium mt-8 mb-3">{children}</h3>
),
// Styled tables
table: ({ children }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full border-collapse border border-border rounded-lg">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-4 py-2">{children}</td>
),
// Styled code blocks
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-muted p-4 rounded-lg overflow-x-auto my-4">
{children}
</pre>
),
// Styled blockquotes for notes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary bg-primary/5 pl-4 py-2 my-4 italic">
{children}
</blockquote>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc pl-6 my-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-6 my-4 space-y-2">{children}</ol>
),
// Horizontal rule
hr: () => <hr className="my-8 border-border" />,
}}
>
{doc.content}
</ReactMarkdown>
</article>
);
}

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Book, ChevronRight, FileText, Settings, Layers, Puzzle, Menu, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import DocContent from './DocContent';
import type { DocSection } from './types';
const iconMap: Record<string, React.ReactNode> = {
'book-open': <Book className="w-4 h-4" />,
'file-text': <FileText className="w-4 h-4" />,
'settings': <Settings className="w-4 h-4" />,
'layers': <Layers className="w-4 h-4" />,
'puzzle': <Puzzle className="w-4 h-4" />,
};
export default function Help() {
const [searchParams, setSearchParams] = useSearchParams();
const [sections, setSections] = useState<DocSection[]>([]);
const [loading, setLoading] = useState(true);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [sidebarOpen, setSidebarOpen] = useState(false);
const currentSlug = searchParams.get('doc') || 'getting-started';
// Fetch documentation registry
useEffect(() => {
const fetchDocs = async () => {
try {
const nonce = (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '';
const response = await fetch('/wp-json/woonoow/v1/docs', {
credentials: 'include',
headers: {
'X-WP-Nonce': nonce,
},
});
const data = await response.json();
if (data.success) {
setSections(data.sections);
const expanded: Record<string, boolean> = {};
data.sections.forEach((section: DocSection) => {
expanded[section.key] = true;
});
setExpandedSections(expanded);
}
} catch (error) {
console.error('Failed to fetch docs:', error);
} finally {
setLoading(false);
}
};
fetchDocs();
}, []);
const toggleSection = (key: string) => {
setExpandedSections(prev => ({
...prev,
[key]: !prev[key],
}));
};
const selectDoc = (slug: string) => {
setSearchParams({ doc: slug });
setSidebarOpen(false);
};
const isActive = (slug: string) => slug === currentSlug;
return (
<>
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="lg:hidden fixed bottom-20 right-4 z-50 bg-primary text-primary-foreground shadow-lg rounded-full w-12 h-12"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
{/* Backdrop for mobile sidebar */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile sidebar - fixed overlay */}
<aside
className={cn(
"lg:hidden fixed left-0 top-0 bottom-0 z-40 w-72 bg-background border-r overflow-y-auto",
sidebarOpen ? "block" : "hidden"
)}
>
<SidebarContent
loading={loading}
sections={sections}
expandedSections={expandedSections}
toggleSection={toggleSection}
selectDoc={selectDoc}
isActive={isActive}
/>
</aside>
{/* Desktop layout - simple flexbox, no sticky */}
<div className="hidden lg:flex gap-0">
{/* Desktop sidebar - flex-shrink-0 keeps it visible */}
<aside className="w-72 flex-shrink-0 border-r bg-muted/30 min-h-[600px]">
<SidebarContent
loading={loading}
sections={sections}
expandedSections={expandedSections}
toggleSection={toggleSection}
selectDoc={selectDoc}
isActive={isActive}
/>
</aside>
{/* Desktop content */}
<main className="flex-1 min-w-0">
<div className="max-w-4xl mx-auto py-6 px-10">
<DocContent slug={currentSlug} />
</div>
</main>
</div>
{/* Mobile content - shown when sidebar is hidden */}
<div className="lg:hidden">
<div className="max-w-4xl mx-auto py-6 px-6">
<DocContent slug={currentSlug} />
</div>
</div>
</>
);
}
// Extracted sidebar content to avoid duplication
function SidebarContent({
loading,
sections,
expandedSections,
toggleSection,
selectDoc,
isActive,
}: {
loading: boolean;
sections: DocSection[];
expandedSections: Record<string, boolean>;
toggleSection: (key: string) => void;
selectDoc: (slug: string) => void;
isActive: (slug: string) => boolean;
}) {
return (
<>
<div className="p-4 border-b bg-muted/30">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Book className="w-5 h-5" />
Documentation
</h2>
<p className="text-sm text-muted-foreground">Help & Guides</p>
</div>
<nav className="p-2">
{loading ? (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
) : sections.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No documentation available</div>
) : (
sections.map((section) => (
<div key={section.key} className="mb-2">
<button
onClick={() => toggleSection(section.key)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-foreground hover:bg-muted rounded-md"
>
{iconMap[section.icon] || <FileText className="w-4 h-4" />}
<span className="flex-1 text-left">{section.label}</span>
<ChevronRight
className={cn(
"w-4 h-4 transition-transform",
expandedSections[section.key] && "rotate-90"
)}
/>
</button>
{expandedSections[section.key] && (
<div className="ml-4 mt-1 space-y-1">
{section.items.map((item) => (
<button
key={item.slug}
onClick={() => selectDoc(item.slug)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors",
isActive(item.slug)
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{item.title}
</button>
))}
</div>
)}
</div>
))
)}
</nav>
</>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Documentation Types
*/
export interface DocItem {
slug: string;
title: string;
}
export interface DocSection {
key: string;
label: string;
icon: string;
items: DocItem[];
}
export interface DocContent {
slug: string;
title: string;
content: string;
}
export interface DocsRegistryResponse {
success: boolean;
sections: DocSection[];
}
export interface DocContentResponse {
success: boolean;
doc: DocContent;
}

View File

@@ -1,225 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, Trash2, Mail, Search } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import { useModules } from '@/hooks/useModules';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export default function NewsletterSubscribers() {
const [searchQuery, setSearchQuery] = useState('');
const queryClient = useQueryClient();
const navigate = useNavigate();
const { isEnabled } = useModules();
// Always call ALL hooks before any conditional returns
const { data: subscribersData, isLoading } = useQuery({
queryKey: ['newsletter-subscribers'],
queryFn: async () => {
const response = await api.get('/newsletter/subscribers');
return response.data;
},
enabled: isEnabled('newsletter'), // Only fetch when module is enabled
});
const deleteSubscriber = useMutation({
mutationFn: async (email: string) => {
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
toast.success('Subscriber removed successfully');
},
onError: () => {
toast.error('Failed to remove subscriber');
},
});
const exportSubscribers = () => {
if (!subscribersData?.subscribers) return;
const csv = ['Email,Subscribed Date'].concat(
subscribersData.subscribers.map((sub: any) =>
`${sub.email},${sub.subscribed_at || 'N/A'}`
)
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
const subscribers = subscribersData?.subscribers || [];
const filteredSubscribers = subscribers.filter((sub: any) =>
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
);
if (!isEnabled('newsletter')) {
return (
<SettingsLayout
title="Newsletter Subscribers"
description="Newsletter module is disabled"
>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
<p className="text-sm text-muted-foreground mb-4">
The newsletter module is currently disabled. Enable it in Settings &gt; Modules to use this feature.
</p>
<Button onClick={() => navigate('/settings/modules')}>
Go to Module Settings
</Button>
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout
title="Newsletter Subscribers"
description="Manage your newsletter subscribers and send campaigns"
>
<SettingsCard
title="Subscribers List"
description={`Total subscribers: ${subscribersData?.count || 0}`}
>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Filter subscribers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<div className="flex gap-2">
<Button onClick={exportSubscribers} variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
<Button variant="outline" size="sm">
<Mail className="mr-2 h-4 w-4" />
Send Campaign
</Button>
</div>
</div>
{/* Subscribers Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading subscribers...
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? 'No subscribers found matching your search' : 'No subscribers yet'}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Subscribed Date</TableHead>
<TableHead>WP User</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
{subscriber.status || 'Active'}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">Yes (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => deleteSubscriber.mutate(subscriber.email)}
disabled={deleteSubscriber.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</SettingsCard>
{/* Email Template Settings */}
<SettingsCard
title="Email Templates"
description="Customize newsletter email templates using the email builder"
>
<div className="space-y-4">
<div className="p-4 border rounded-lg bg-muted/50">
<h4 className="font-medium mb-2">Newsletter Welcome Email</h4>
<p className="text-sm text-muted-foreground mb-4">
Welcome email sent when someone subscribes to your newsletter
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
>
Edit Template
</Button>
</div>
<div className="p-4 border rounded-lg bg-muted/50">
<h4 className="font-medium mb-2">New Subscriber Notification (Admin)</h4>
<p className="text-sm text-muted-foreground mb-4">
Admin notification when someone subscribes to newsletter
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
>
Edit Template
</Button>
</div>
</div>
</SettingsCard>
</SettingsLayout>
);
}

View File

@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Plus,
Search,
Send,
Clock,
CheckCircle2,
AlertCircle,
Trash2,
Edit,
MoreHorizontal,
Copy
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Campaign {
id: number;
title: string;
subject: string;
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
recipient_count: number;
sent_count: number;
failed_count: number;
scheduled_at: string | null;
sent_at: string | null;
created_at: string;
}
const statusConfig = {
draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' },
scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
};
export default function Campaigns() {
const [searchQuery, setSearchQuery] = useState('');
const [deleteId, setDeleteId] = useState<number | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['campaigns'],
queryFn: async () => {
const response = await api.get('/campaigns');
return response.data as Campaign[];
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.del(`/campaigns/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(__('Campaign deleted'));
setDeleteId(null);
},
onError: () => {
toast.error(__('Failed to delete campaign'));
},
});
const duplicateMutation = useMutation({
mutationFn: async (campaign: Campaign) => {
const response = await api.post('/campaigns', {
title: `${campaign.title} (Copy)`,
subject: campaign.subject,
content: '',
status: 'draft',
});
return response;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(__('Campaign duplicated'));
},
onError: () => {
toast.error(__('Failed to duplicate campaign'));
},
});
const campaigns = data || [];
const filteredCampaigns = campaigns.filter((c) =>
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.subject?.toLowerCase().includes(searchQuery.toLowerCase())
);
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<SettingsCard
title={__('All Campaigns')}
description={`${campaigns.length} ${__('campaigns total')}`}
>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Search campaigns...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('New Campaign')}
</Button>
</div>
{/* Campaigns Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading campaigns...')}
</div>
) : filteredCampaigns.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{searchQuery ? __('No campaigns found matching your search') : (
<div className="space-y-4">
<Send className="h-12 w-12 mx-auto opacity-50" />
<p>{__('No campaigns yet')}</p>
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
<Plus className="mr-2 h-4 w-4" />
{__('Create your first campaign')}
</Button>
</div>
)}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Title')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCampaigns.map((campaign) => {
const status = statusConfig[campaign.status] || statusConfig.draft;
const StatusIcon = status.icon;
return (
<TableRow key={campaign.id}>
<TableCell>
<div>
<div className="font-medium">{campaign.title}</div>
{campaign.subject && (
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
{campaign.subject}
</div>
)}
</div>
</TableCell>
<TableCell>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
<StatusIcon className="h-3 w-3" />
{__(status.label)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell">
{campaign.status === 'sent' ? (
<span>
{campaign.sent_count}/{campaign.recipient_count}
{campaign.failed_count > 0 && (
<span className="text-red-500 ml-1">
({campaign.failed_count} {__('failed')})
</span>
)}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{campaign.sent_at
? formatDate(campaign.sent_at)
: campaign.scheduled_at
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
: formatDate(campaign.created_at)
}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
<Edit className="mr-2 h-4 w-4" />
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
<Copy className="mr-2 h-4 w-4" />
{__('Duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteId(campaign.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</SettingsCard>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Delete Campaign')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to delete this campaign? This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
className="bg-red-600 hover:bg-red-700"
>
{__('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, Trash2, Search } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
export default function Subscribers() {
const [searchQuery, setSearchQuery] = useState('');
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: subscribersData, isLoading } = useQuery({
queryKey: ['newsletter-subscribers'],
queryFn: async () => {
const response = await api.get('/newsletter/subscribers');
return response.data;
},
});
const deleteSubscriber = useMutation({
mutationFn: async (email: string) => {
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
toast.success(__('Subscriber removed successfully'));
},
onError: () => {
toast.error(__('Failed to remove subscriber'));
},
});
const exportSubscribers = () => {
if (!subscribersData?.subscribers) return;
const csv = ['Email,Subscribed Date'].concat(
subscribersData.subscribers.map((sub: any) =>
`${sub.email},${sub.subscribed_at || 'N/A'}`
)
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
const subscribers = subscribersData?.subscribers || [];
const filteredSubscribers = subscribers.filter((sub: any) =>
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6">
<SettingsCard
title={__('Subscribers List')}
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
>
<div className="space-y-4">
{/* Actions Bar */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder={__('Filter subscribers...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="!pl-9"
/>
</div>
<Button onClick={exportSubscribers} variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
{__('Export CSV')}
</Button>
</div>
{/* Subscribers Table */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
{__('Loading subscribers...')}
</div>
) : filteredSubscribers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Email')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Subscribed Date')}</TableHead>
<TableHead>{__('WP User')}</TableHead>
<TableHead className="text-right">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubscribers.map((subscriber: any) => (
<TableRow key={subscriber.email}>
<TableCell className="font-medium">{subscriber.email}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
{subscriber.status || __('Active')}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{subscriber.subscribed_at
? new Date(subscriber.subscribed_at).toLocaleDateString()
: 'N/A'
}
</TableCell>
<TableCell>
{subscriber.user_id ? (
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
) : (
<span className="text-xs text-muted-foreground">{__('No')}</span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => deleteSubscriber.mutate(subscriber.email)}
disabled={deleteSubscriber.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</SettingsCard>
{/* Email Template Settings */}
<SettingsCard
title={__('Email Templates')}
description={__('Customize newsletter email templates using the email builder')}
>
<div className="space-y-4">
<div className="p-4 border rounded-lg bg-muted/50">
<h4 className="font-medium mb-2">{__('Newsletter Welcome Email')}</h4>
<p className="text-sm text-muted-foreground mb-4">
{__('Welcome email sent when someone subscribes to your newsletter')}
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
>
{__('Edit Template')}
</Button>
</div>
<div className="p-4 border rounded-lg bg-muted/50">
<h4 className="font-medium mb-2">{__('New Subscriber Notification (Admin)')}</h4>
<p className="text-sm text-muted-foreground mb-4">
{__('Admin notification when someone subscribes to newsletter')}
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
>
{__('Edit Template')}
</Button>
</div>
</div>
</SettingsCard>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Mail } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { useModules } from '@/hooks/useModules';
import Subscribers from './Subscribers';
import Campaigns from './Campaigns';
export default function Newsletter() {
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('subscribers');
const navigate = useNavigate();
const { isEnabled } = useModules();
// Check for tab query param
useEffect(() => {
const tabParam = searchParams.get('tab');
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
setActiveTab(tabParam);
}
}, [searchParams]);
// Update URL when tab changes
const handleTabChange = (value: string) => {
setActiveTab(value);
setSearchParams({ tab: value });
};
// Show disabled state if newsletter module is off
if (!isEnabled('newsletter')) {
return (
<SettingsLayout
title={__('Newsletter')}
description={__('Newsletter module is disabled')}
>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{__('The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.')}
</p>
<Button onClick={() => navigate('/settings/modules')}>
{__('Go to Module Settings')}
</Button>
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout
title={__('Newsletter')}
description={__('Manage subscribers and send email campaigns')}
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
</TabsList>
<TabsContent value="subscribers" className="space-y-4 mt-6">
<Subscribers />
</TabsContent>
<TabsContent value="campaigns" className="space-y-4 mt-6">
<Campaigns />
</TabsContent>
</Tabs>
</SettingsLayout>
);
}

View File

@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout'; import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { Mail, Send, Tag } from 'lucide-react'; import { Mail, Tag } from 'lucide-react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
interface MarketingCard { interface MarketingCard {
@@ -13,16 +13,10 @@ interface MarketingCard {
const cards: MarketingCard[] = [ const cards: MarketingCard[] = [
{ {
title: __('Newsletter'), title: __('Newsletter'),
description: __('Manage subscribers and email templates'), description: __('Manage subscribers and send email campaigns'),
icon: Mail, icon: Mail,
to: '/marketing/newsletter', to: '/marketing/newsletter',
}, },
{
title: __('Campaigns'),
description: __('Create and send email campaigns'),
icon: Send,
to: '/marketing/campaigns',
},
{ {
title: __('Coupons'), title: __('Coupons'),
description: __('Discounts, promotions, and coupon codes'), description: __('Discounts, promotions, and coupon codes'),

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react'; import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone, HelpCircle } from 'lucide-react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { usePageHeader } from '@/contexts/PageHeaderContext'; import { usePageHeader } from '@/contexts/PageHeaderContext';
import { useApp } from '@/contexts/AppContext'; import { useApp } from '@/contexts/AppContext';
@@ -32,6 +32,12 @@ const menuItems: MenuItem[] = [
label: __('Settings'), label: __('Settings'),
description: __('Configure your store settings'), description: __('Configure your store settings'),
to: '/settings' to: '/settings'
},
{
icon: <HelpCircle className="w-5 h-5" />,
label: __('Help & Docs'),
description: __('Documentation and guides'),
to: '/help'
} }
]; ];

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 = {
@@ -113,7 +123,7 @@ type Props = {
hideSubmitButton?: boolean; hideSubmitButton?: boolean;
}; };
const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed']; const STATUS_LIST = ['pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed'];
// --- Component -------------------------------------------------------- // --- Component --------------------------------------------------------
export default function OrderForm({ export default function OrderForm({
@@ -167,11 +177,11 @@ export default function OrderForm({
const only = countries[0]?.code || ''; const only = countries[0]?.code || '';
if (shipDiff) { if (shipDiff) {
if (only && shippingData.country !== only) { if (only && shippingData.country !== only) {
setShippingData({...shippingData, country: only}); setShippingData({ ...shippingData, country: only });
} }
} else { } else {
// keep shipping synced to billing when not different // keep shipping synced to billing when not different
setShippingData({...shippingData, country: bCountry}); setShippingData({ ...shippingData, country: bCountry });
} }
} }
}, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]); }, [oneCountryOnly, countries, shipDiff, bCountry, shippingData.country]);
@@ -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 })),
@@ -408,7 +454,7 @@ export default function OrderForm({
// Keep shipping country synced to billing when unchecked // Keep shipping country synced to billing when unchecked
React.useEffect(() => { React.useEffect(() => {
if (!shipDiff) setShippingData({...shippingData, country: bCountry}); if (!shipDiff) setShippingData({ ...shippingData, country: bCountry });
}, [shipDiff, bCountry]); }, [shipDiff, bCountry]);
// Clamp states when country changes // Clamp states when country changes
@@ -417,13 +463,50 @@ export default function OrderForm({
}, [bCountry]); }, [bCountry]);
React.useEffect(() => { React.useEffect(() => {
if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) { if (shippingData.state && !states[shippingData.country]?.[shippingData.state]) {
setShippingData({...shippingData, state: ''}); setShippingData({ ...shippingData, state: '' });
} }
}, [shippingData.country]); }, [shippingData.country]);
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 && (
@@ -1137,7 +1325,7 @@ export default function OrderForm({
{hasPhysicalProduct && ( {hasPhysicalProduct && (
<div className="pt-2 mt-4"> <div className="pt-2 mt-4">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v)=> setShipDiff(Boolean(v))} /> <Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v) => setShipDiff(Boolean(v))} />
<Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label> <Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label>
</div> </div>
</div> </div>
@@ -1149,22 +1337,38 @@ 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 })}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder={field.placeholder || field.label} /> <SelectValue placeholder={field.placeholder || field.label} />
@@ -1179,14 +1383,34 @@ export default function OrderForm({
<SearchableSelect <SearchableSelect
options={countryOptions} options={countryOptions}
value={shippingData.country || ''} value={shippingData.country || ''}
onChange={(v) => setShippingData({...shippingData, country: v})} onChange={(v) => setShippingData({ ...shippingData, country: v })}
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}
/> />
@@ -1194,7 +1418,7 @@ export default function OrderForm({
<Input <Input
type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'} type={field.type === 'email' ? 'email' : field.type === 'tel' ? 'tel' : 'text'}
value={shippingData[fieldKey] || ''} value={shippingData[fieldKey] || ''}
onChange={(e) => setShippingData({...shippingData, [fieldKey]: e.target.value})} onChange={(e) => setShippingData({ ...shippingData, [fieldKey]: 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>
)} )}
@@ -1281,7 +1502,7 @@ export default function OrderForm({
<div className="rounded border p-4 space-y-2"> <div className="rounded border p-4 space-y-2">
<Label>{__('Customer note (optional)')}</Label> <Label>{__('Customer note (optional)')}</Label>
<Textarea value={note} onChange={e=>setNote(e.target.value)} placeholder={__('Write a note for this order…')} /> <Textarea value={note} onChange={e => setNote(e.target.value)} placeholder={__('Write a note for this order…')} />
</div> </div>
{!hideSubmitButton && ( {!hideSubmitButton && (
@@ -1297,6 +1518,6 @@ export default function OrderForm({
function isEmptyAddress(a: any) { function isEmptyAddress(a: any) {
if (!a) return true; if (!a) return true;
const keys = ['first_name','last_name','address_1','city','state','postcode','country']; const keys = ['first_name', 'last_name', 'address_1', 'city', 'state', 'postcode', 'country'];
return keys.every(k => !a[k]); return keys.every(k => !a[k]);
} }

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

@@ -13,6 +13,7 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
interface CustomerSettings { interface CustomerSettings {
auto_register_members: boolean; auto_register_members: boolean;
multiple_addresses_enabled: boolean; multiple_addresses_enabled: boolean;
allow_custom_avatar: boolean;
vip_min_spent: number; vip_min_spent: number;
vip_min_orders: number; vip_min_orders: number;
vip_timeframe: 'all' | '30' | '90' | '365'; vip_timeframe: 'all' | '30' | '90' | '365';
@@ -24,6 +25,7 @@ export default function CustomersSettings() {
const [settings, setSettings] = useState<CustomerSettings>({ const [settings, setSettings] = useState<CustomerSettings>({
auto_register_members: false, auto_register_members: false,
multiple_addresses_enabled: true, multiple_addresses_enabled: true,
allow_custom_avatar: false,
vip_min_spent: 1000, vip_min_spent: 1000,
vip_min_orders: 10, vip_min_orders: 10,
vip_timeframe: 'all', vip_timeframe: 'all',
@@ -138,6 +140,14 @@ export default function CustomersSettings() {
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })} onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
/> />
<ToggleField
id="allow_custom_avatar"
label={__('Allow custom profile photo')}
description={__('Allow customers to upload their own profile photo. When disabled, customer avatars will use Gravatar or default initials.')}
checked={settings.allow_custom_avatar}
onCheckedChange={(checked) => setSettings({ ...settings, allow_custom_avatar: checked })}
/>
</div> </div>
</SettingsCard> </SettingsCard>

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

@@ -34,7 +34,7 @@ export default function CustomerNotifications() {
} }
> >
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger> <TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
<TabsTrigger value="events">{__('Events')}</TabsTrigger> <TabsTrigger value="events">{__('Events')}</TabsTrigger>
</TabsList> </TabsList>

View File

@@ -22,7 +22,7 @@ export default function EmailConfiguration() {
} }
> >
<Tabs defaultValue="template" className="space-y-6"> <Tabs defaultValue="template" className="space-y-6">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger> <TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger> <TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
</TabsList> </TabsList>

View File

@@ -25,7 +25,7 @@ export default function PushConfiguration() {
} }
> >
<Tabs defaultValue="template" className="space-y-6"> <Tabs defaultValue="template" className="space-y-6">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="template">{__('Template Settings')}</TabsTrigger> <TabsTrigger value="template">{__('Template Settings')}</TabsTrigger>
<TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger> <TabsTrigger value="connection">{__('Connection Settings')}</TabsTrigger>
</TabsList> </TabsList>

View File

@@ -34,7 +34,7 @@ export default function StaffNotifications() {
} }
> >
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="channels">{__('Channels')}</TabsTrigger> <TabsTrigger value="channels">{__('Channels')}</TabsTrigger>
<TabsTrigger value="events">{__('Events')}</TabsTrigger> <TabsTrigger value="events">{__('Events')}</TabsTrigger>
</TabsList> </TabsList>

View File

@@ -206,7 +206,7 @@ export default function TemplateEditor({
{/* Body - Scrollable */} {/* Body - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="editor" className="flex items-center gap-2"> <TabsTrigger value="editor" className="flex items-center gap-2">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
{__('Editor')} {__('Editor')}

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 {
@@ -52,4 +56,4 @@ declare global {
} }
} }
export {}; export { };

View File

@@ -40,7 +40,7 @@ rsync -av --progress \
--exclude='admin-spa' \ --exclude='admin-spa' \
--exclude='examples' \ --exclude='examples' \
--exclude='*.sh' \ --exclude='*.sh' \
--exclude='*.md' \ --exclude='/*.md' \
--exclude='archive' \ --exclude='archive' \
--exclude='test-*.php' \ --exclude='test-*.php' \
--exclude='check-*.php' \ --exclude='check-*.php' \

View File

@@ -25,9 +25,11 @@
"@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",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -3596,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",
@@ -4927,6 +4945,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6214,6 +6241,26 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-helmet-async": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
"license": "Apache-2.0",
"dependencies": {
"invariant": "^2.2.4",
"react-fast-compare": "^3.2.2",
"shallowequal": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.66.1", "version": "7.66.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
@@ -6658,6 +6705,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -27,9 +27,11 @@
"@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",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'; import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { HelmetProvider } from 'react-helmet-async';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
// Theme // Theme
@@ -58,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>
@@ -102,22 +99,44 @@ function AppRoutes() {
); );
} }
// Get router config from WordPress
const getRouterConfig = () => {
const config = (window as any).woonoowCustomer;
return {
useBrowserRouter: config?.useBrowserRouter ?? true,
basePath: config?.basePath || '/store',
};
};
// Router wrapper that conditionally uses BrowserRouter or HashRouter
function RouterProvider({ children }: { children: React.ReactNode }) {
const { useBrowserRouter, basePath } = getRouterConfig();
if (useBrowserRouter) {
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
}
return <HashRouter>{children}</HashRouter>;
}
function App() { function App() {
const themeConfig = getThemeConfig(); const themeConfig = getThemeConfig();
const appearanceSettings = getAppearanceSettings(); const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any; const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
return ( return (
<HelmetProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider config={themeConfig}> <ThemeProvider config={themeConfig}>
<HashRouter> <RouterProvider>
<AppRoutes /> <AppRoutes />
</HashRouter> </RouterProvider>
{/* Toast notifications - position from settings */} {/* Toast notifications - position from settings */}
<Toaster position={toastPosition} richColors /> <Toaster position={toastPosition} richColors />
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</HelmetProvider>
); );
} }

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,68 @@
import { Helmet } from 'react-helmet-async';
interface SEOHeadProps {
title?: string;
description?: string;
image?: string;
url?: string;
type?: 'website' | 'product' | 'article';
product?: {
price?: string;
currency?: string;
availability?: 'in stock' | 'out of stock';
};
}
/**
* SEOHead Component
* Adds dynamic meta tags for social media sharing (Open Graph, Twitter Cards)
* Used for link previews on Facebook, Twitter, Slack, etc.
*/
export function SEOHead({
title,
description,
image,
url,
type = 'website',
product,
}: SEOHeadProps) {
const config = (window as any).woonoowCustomer;
const siteName = config?.siteName || 'Store';
const siteUrl = config?.siteUrl || '';
const fullTitle = title ? `${title} | ${siteName}` : siteName;
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
return (
<Helmet>
{/* Basic Meta Tags */}
<title>{fullTitle}</title>
{description && <meta name="description" content={description} />}
{/* Open Graph (Facebook, LinkedIn, etc.) */}
<meta property="og:site_name" content={siteName} />
<meta property="og:title" content={title || siteName} />
{description && <meta property="og:description" content={description} />}
<meta property="og:type" content={type} />
<meta property="og:url" content={fullUrl} />
{image && <meta property="og:image" content={image} />}
{/* Twitter Card */}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={title || siteName} />
{description && <meta name="twitter:description" content={description} />}
{image && <meta name="twitter:image" content={image} />}
{/* Product-specific meta tags */}
{type === 'product' && product && (
<>
<meta property="product:price:amount" content={product.price} />
<meta property="product:price:currency" content={product.currency} />
<meta property="product:availability" content={product.availability} />
</>
)}
</Helmet>
);
}
export default SEOHead;

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

@@ -109,3 +109,57 @@ export async function fetchCart(): Promise<Cart> {
const data = await response.json(); const data = await response.json();
return data; return data;
} }
/**
* Apply coupon to cart via API
*/
export async function applyCoupon(couponCode: string): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/apply-coupon`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
coupon_code: couponCode,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to apply coupon');
}
const data = await response.json();
return data.cart;
}
/**
* Remove coupon from cart via API
*/
export async function removeCoupon(couponCode: string): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/remove-coupon`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
coupon_code: couponCode,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to remove coupon');
}
const data = await response.json();
return data.cart;
}

View File

@@ -20,10 +20,18 @@ 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;
}; };
coupons?: {
code: string;
discount: number;
type?: string;
}[];
discount_total?: number;
shipping_total?: number;
} }
interface CartStore { interface CartStore {

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

@@ -1,15 +1,160 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Download } from 'lucide-react'; import { Download, Loader2, FileText, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
import { formatPrice } from '@/lib/currency';
import SEOHead from '@/components/SEOHead';
interface DownloadItem {
download_id: string;
download_url: string;
product_id: number;
product_name: string;
product_url: string;
download_name: string;
order_id: number;
order_key: string;
downloads_remaining: string;
access_expires: string | null;
file: {
name: string;
file: string;
};
}
export default function Downloads() { export default function Downloads() {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDownloads = async () => {
try {
setIsLoading(true);
const data = await api.get<DownloadItem[]>('/account/downloads');
setDownloads(data);
} catch (err: any) {
console.error('Failed to fetch downloads:', err);
setError(err.message || 'Failed to load downloads');
} finally {
setIsLoading(false);
}
};
fetchDownloads();
}, []);
const handleDownload = (downloadUrl: string, fileName: string) => {
// Open download in new tab
window.open(downloadUrl, '_blank');
toast.success(`Downloading ${fileName}`);
};
if (isLoading) {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6">Downloads</h1> <h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="text-center py-12"> <div className="text-center py-12">
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Loader2 className="w-12 h-12 text-gray-400 mx-auto mb-4 animate-spin" />
<p className="text-gray-600">No downloads available</p> <p className="text-gray-600">Loading your downloads...</p>
</div> </div>
</div> </div>
); );
}
if (error) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="text-center py-12">
<p className="text-red-600">{error}</p>
<Button
onClick={() => window.location.reload()}
variant="outline"
className="mt-4"
>
Try Again
</Button>
</div>
</div>
);
}
if (downloads.length === 0) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="text-center py-12">
<Download className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600 mb-2">No downloads available</p>
<p className="text-sm text-gray-500">
Downloads will appear here after you purchase downloadable products.
</p>
</div>
</div>
);
}
return (
<div>
<SEOHead title="Downloads" description="Your purchased downloads" />
<h1 className="text-2xl font-bold mb-6">Downloads</h1>
<div className="space-y-4">
{downloads.map((download) => (
<div
key={`${download.download_id}-${download.order_id}`}
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-gray-900 truncate">
{download.product_name}
</h3>
<p className="text-sm text-gray-600 truncate">
{download.download_name || download.file?.name || 'Download'}
</p>
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
<span className="bg-gray-100 px-2 py-1 rounded">
Order #{download.order_id}
</span>
{download.downloads_remaining && download.downloads_remaining !== 'unlimited' && (
<span className="bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
{download.downloads_remaining} downloads left
</span>
)}
{download.access_expires && (
<span className="bg-orange-100 text-orange-700 px-2 py-1 rounded">
Expires: {new Date(download.access_expires).toLocaleDateString()}
</span>
)}
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleDownload(download.download_url, download.download_name || 'file')}
className="flex-shrink-0"
>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
</div>
))}
</div>
{downloads.length > 0 && (
<p className="text-sm text-gray-500 mt-6 text-center">
{downloads.length} {downloads.length === 1 ? 'download' : 'downloads'} available
</p>
)}
</div>
);
} }

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

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useCartStore, type CartItem } from '@/lib/cart/store'; import { useCartStore, type CartItem } from '@/lib/cart/store';
import { useCartSettings } from '@/hooks/useAppearanceSettings'; import { useCartSettings } from '@/hooks/useAppearanceSettings';
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api'; import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart, applyCoupon, removeCoupon } from '@/lib/cart/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -13,8 +13,9 @@ 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 } from 'lucide-react'; import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2, X, Tag } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Cart() { export default function Cart() {
@@ -24,6 +25,8 @@ export default function Cart() {
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [couponCode, setCouponCode] = useState('');
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
// Fetch cart from server on mount to sync with WooCommerce // Fetch cart from server on mount to sync with WooCommerce
useEffect(() => { useEffect(() => {
@@ -92,6 +95,37 @@ export default function Cart() {
} }
}; };
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return;
setIsApplyingCoupon(true);
try {
const updatedCart = await applyCoupon(couponCode.trim());
setCart(updatedCart);
setCouponCode('');
toast.success('Coupon applied successfully');
} catch (error: any) {
console.error('Failed to apply coupon:', error);
toast.error(error.message || 'Failed to apply coupon');
} finally {
setIsApplyingCoupon(false);
}
};
const handleRemoveCoupon = async (code: string) => {
setIsUpdating(true);
try {
const updatedCart = await removeCoupon(code);
setCart(updatedCart);
toast.success('Coupon removed');
} catch (error: any) {
console.error('Failed to remove coupon:', error);
toast.error(error.message || 'Failed to remove coupon');
} finally {
setIsUpdating(false);
}
};
// Show loading state while fetching cart // Show loading state while fetching cart
if (isLoading) { if (isLoading) {
return ( return (
@@ -122,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">
@@ -237,10 +272,43 @@ export default function Cart() {
<input <input
type="text" type="text"
placeholder="Enter coupon code" placeholder="Enter coupon code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleApplyCoupon()}
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isApplyingCoupon}
/> />
<Button variant="outline" size="sm">Apply</Button> <Button
variant="outline"
size="sm"
onClick={handleApplyCoupon}
disabled={isApplyingCoupon || !couponCode.trim()}
>
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
</div> </div>
{/* Applied Coupons */}
{(cart as any).coupons?.length > 0 && (
<div className="mt-3 space-y-2">
{(cart as any).coupons.map((coupon: { code: string; discount: number }) => (
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
</div>
<button
onClick={() => handleRemoveCoupon(coupon.code)}
className="text-green-600 hover:text-green-800 p-1"
disabled={isUpdating}
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div> </div>
)} )}
@@ -262,15 +330,22 @@ export default function Cart() {
<div className="space-y-3 mb-6"> <div className="space-y-3 mb-6">
<div className="flex justify-between text-gray-600"> <div className="flex justify-between text-gray-600">
<span>Subtotal</span> <span>Subtotal</span>
<span>{formatPrice(total)}</span> <span>{formatPrice((cart as any).subtotal || total)}</span>
</div> </div>
{/* Show discount if coupons applied */}
{(cart as any).discount_total > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span>-{formatPrice((cart as any).discount_total)}</span>
</div>
)}
<div className="flex justify-between text-gray-600"> <div className="flex justify-between text-gray-600">
<span>Shipping</span> <span>Shipping</span>
<span>Calculated at checkout</span> <span>{(cart as any).shipping_total > 0 ? formatPrice((cart as any).shipping_total) : 'Calculated at checkout'}</span>
</div> </div>
<div className="border-t pt-3 flex justify-between text-lg font-bold"> <div className="border-t pt-3 flex justify-between text-lg font-bold">
<span>Total</span> <span>Total</span>
<span>{formatPrice(total)}</span> <span>{formatPrice((cart as any).total || total)}</span>
</div> </div>
</div> </div>

View File

@@ -3,13 +3,17 @@ 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 } from 'lucide-react'; import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2, Loader2, X, Tag } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { api } from '@/lib/api/client'; import { api } from '@/lib/api/client';
import { AddressSelector } from '@/components/AddressSelector'; import { AddressSelector } from '@/components/AddressSelector';
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
interface SavedAddress { interface SavedAddress {
id: number; id: number;
@@ -34,19 +38,28 @@ export default function Checkout() {
const { cart } = useCartStore(); const { cart } = useCartStore();
const { layout, elements } = useCheckoutSettings(); const { layout, elements } = useCheckoutSettings();
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [couponCode, setCouponCode] = useState('');
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
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({
@@ -85,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) {
@@ -189,6 +460,57 @@ export default function Checkout() {
} }
}, [user]); }, [user]);
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return;
setIsApplyingCoupon(true);
try {
const updatedCart = await applyCoupon(couponCode.trim());
if (updatedCart.coupons) {
setAppliedCoupons(updatedCart.coupons);
setDiscountTotal(updatedCart.discount_total || 0);
}
setCouponCode('');
toast.success('Coupon applied successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to apply coupon');
} finally {
setIsApplyingCoupon(false);
}
};
const handleRemoveCoupon = async (code: string) => {
setIsApplyingCoupon(true);
try {
const updatedCart = await removeCoupon(code);
if (updatedCart.coupons) {
setAppliedCoupons(updatedCart.coupons);
setDiscountTotal(updatedCart.discount_total || 0);
}
toast.success('Coupon removed');
} catch (error: any) {
toast.error(error.message || 'Failed to remove coupon');
} finally {
setIsApplyingCoupon(false);
}
};
// Load cart data including coupons on mount
useEffect(() => {
const loadCartData = async () => {
try {
const cartData = await fetchCart();
if (cartData.coupons) {
setAppliedCoupons(cartData.coupons);
setDiscountTotal(cartData.discount_total || 0);
}
} catch (error) {
console.error('Failed to load cart data:', error);
}
};
loadCartData();
}, []);
const handlePlaceOrder = async (e: React.FormEvent) => { const handlePlaceOrder = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsProcessing(true); setIsProcessing(true);
@@ -215,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,
@@ -225,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
@@ -242,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');
} }
@@ -258,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">
@@ -277,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">
@@ -363,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>
@@ -548,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>
)} )}
</> </>
@@ -633,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>
)} )}
@@ -652,10 +1076,45 @@ export default function Checkout() {
<input <input
type="text" type="text"
placeholder="Enter coupon code" placeholder="Enter coupon code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleApplyCoupon())}
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isApplyingCoupon}
/> />
<Button type="button" variant="outline" size="sm">Apply</Button> <Button
type="button"
variant="outline"
size="sm"
onClick={handleApplyCoupon}
disabled={isApplyingCoupon || !couponCode.trim()}
>
{isApplyingCoupon ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
</Button>
</div> </div>
{/* Applied Coupons */}
{appliedCoupons.length > 0 && (
<div className="mt-3 space-y-2">
{appliedCoupons.map((coupon) => (
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800">{coupon.code}</span>
<span className="text-sm text-green-600">-{formatPrice(coupon.discount)}</span>
</div>
<button
type="button"
onClick={() => handleRemoveCoupon(coupon.code)}
className="text-green-600 hover:text-green-800 p-1"
disabled={isApplyingCoupon}
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div> </div>
)} )}
@@ -674,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>
)} )}
@@ -702,10 +1182,19 @@ export default function Checkout() {
<span>Subtotal</span> <span>Subtotal</span>
<span>{formatPrice(subtotal)}</span> <span>{formatPrice(subtotal)}</span>
</div> </div>
{/* Show discount if coupons applied */}
{discountTotal > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{formatPrice(discountTotal)}</span>
</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>
@@ -714,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)}</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

@@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react'; import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
import type { Product as ProductType, ProductsResponse } from '@/types/product'; import type { Product as ProductType, ProductsResponse } from '@/types/product';
export default function Product() { export default function Product() {
@@ -257,6 +258,18 @@ export default function Product() {
return ( return (
<Container> <Container>
{/* SEO Meta Tags for Social Sharing */}
<SEOHead
title={product.name}
description={product.short_description?.replace(/<[^>]+>/g, '').slice(0, 160) || product.description?.replace(/<[^>]+>/g, '').slice(0, 160)}
image={product.image || product.images?.[0]}
type="product"
product={{
price: currentPrice,
currency: (window as any).woonoowCustomer?.currency?.code || 'USD',
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
}}
/>
<div className="max-w-6xl mx-auto py-8"> <div className="max-w-6xl mx-auto py-8">
{/* Breadcrumb */} {/* Breadcrumb */}
{elements.breadcrumbs && ( {elements.breadcrumbs && (
@@ -306,8 +319,7 @@ export default function Product() {
<button <button
key={index} key={index}
onClick={() => setSelectedImage(img)} onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${ className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
selectedImage === img
? 'bg-primary w-6' ? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400' : 'bg-gray-300 hover:bg-gray-400'
}`} }`}
@@ -341,8 +353,7 @@ export default function Product() {
<button <button
key={index} key={index}
onClick={() => setSelectedImage(img)} onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${ className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
selectedImage === img
? 'border-primary ring-4 ring-primary ring-offset-2' ? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
}`} }`}
@@ -434,8 +445,7 @@ export default function Product() {
<button <button
key={optIndex} key={optIndex}
onClick={() => handleAttributeChange(attr.name, option)} onClick={() => handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${ className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
isSelected
? 'bg-gray-900 text-white border-gray-900 shadow-lg' ? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md' : 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`} }`}
@@ -492,14 +502,12 @@ export default function Product() {
{isModuleEnabled('wishlist') && wishlistEnabled && ( {isModuleEnabled('wishlist') && wishlistEnabled && (
<button <button
onClick={() => product && toggleWishlist(product.id)} onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${ className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400' ? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400' : 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`} }`}
> >
<Heart className={`h-5 w-5 ${ <Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
product && isInWishlist(product.id) ? 'fill-red-500' : ''
}`} /> }`} />
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'} {product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
</button> </button>
@@ -576,7 +584,7 @@ export default function Product() {
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors" className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
title="Share on Facebook" title="Share on Facebook"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -587,7 +595,7 @@ export default function Product() {
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors" className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
title="Share on Twitter" title="Share on Twitter"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg> <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -598,7 +606,7 @@ export default function Product() {
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors" className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
title="Share on WhatsApp" title="Share on WhatsApp"
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg> <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTheme, useLayout } from '@/contexts/ThemeContext'; import { useTheme, useLayout } from '@/contexts/ThemeContext';
import { useShopSettings } from '@/hooks/useAppearanceSettings'; import { useShopSettings } from '@/hooks/useAppearanceSettings';
import SEOHead from '@/components/SEOHead';
import type { ProductsResponse, ProductCategory, Product } from '@/types/product'; import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
export default function Shop() { export default function Shop() {
@@ -126,6 +127,11 @@ export default function Shop() {
return ( return (
<Container> <Container>
{/* SEO Meta Tags for Social Sharing */}
<SEOHead
title="Shop"
description="Browse our collection of products"
/>
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-bold mb-2">Shop</h1> <h1 className="text-4xl font-bold mb-2">Shop</h1>

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;
} }

132
docs/_registry.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
/**
* WooNooW Documentation Registry
*
* This file registers all core documentation.
* Addons can extend using the 'woonoow_docs_registry' filter.
*/
namespace WooNooW\Docs;
/**
* Get all registered documentation
*
* @return array Documentation registry
*/
function get_docs_registry() {
$docs_dir = dirname(__FILE__);
// Core WooNooW documentation
$docs = [
'core' => [
'label' => 'WooNooW',
'icon' => 'book-open',
'items' => [
[
'slug' => 'getting-started',
'title' => 'Getting Started',
'file' => $docs_dir . '/getting-started.md',
],
[
'slug' => 'installation',
'title' => 'Installation',
'file' => $docs_dir . '/installation.md',
],
[
'slug' => 'troubleshooting',
'title' => 'Troubleshooting',
'file' => $docs_dir . '/troubleshooting.md',
],
[
'slug' => 'faq',
'title' => 'FAQ',
'file' => $docs_dir . '/faq.md',
],
],
],
'configuration' => [
'label' => 'Configuration',
'icon' => 'settings',
'items' => [
[
'slug' => 'configuration/appearance',
'title' => 'Appearance Settings',
'file' => $docs_dir . '/configuration/appearance.md',
],
[
'slug' => 'configuration/spa-mode',
'title' => 'SPA Mode',
'file' => $docs_dir . '/configuration/spa-mode.md',
],
],
],
'features' => [
'label' => 'Features',
'icon' => 'layers',
'items' => [
[
'slug' => 'features/shop',
'title' => 'Shop Page',
'file' => $docs_dir . '/features/shop.md',
],
[
'slug' => 'features/checkout',
'title' => 'Checkout',
'file' => $docs_dir . '/features/checkout.md',
],
],
],
];
/**
* Filter: woonoow_docs_registry
*
* Allows addons to register their own documentation.
*
* @param array $docs Current documentation registry
* @return array Modified documentation registry
*
* @example
* add_filter('woonoow_docs_registry', function($docs) {
* $docs['my-addon'] = [
* 'label' => 'My Addon',
* 'icon' => 'puzzle',
* 'items' => [
* [
* 'slug' => 'my-addon/getting-started',
* 'title' => 'Getting Started',
* 'file' => __DIR__ . '/docs/getting-started.md',
* ],
* ],
* ];
* return $docs;
* });
*/
return apply_filters('woonoow_docs_registry', $docs);
}
/**
* Get a single documentation item by slug
*
* @param string $slug Document slug
* @return array|null Document data with content, or null if not found
*/
function get_doc_by_slug($slug) {
$registry = get_docs_registry();
foreach ($registry as $section) {
foreach ($section['items'] as $item) {
if ($item['slug'] === $slug) {
// Read file content
if (file_exists($item['file'])) {
$item['content'] = file_get_contents($item['file']);
} else {
$item['content'] = '# Document Not Found\n\nThis document is coming soon.';
}
return $item;
}
}
}
return null;
}

View File

@@ -0,0 +1,133 @@
# Appearance Settings
Customize the look and feel of your WooNooW store.
## Accessing Appearance Settings
Go to **WooNooW → Appearance** in the WordPress admin.
---
## General Settings
### Logo
Upload your store logo for display in the header.
- **Recommended size**: 200x60 pixels (width x height)
- **Formats**: PNG (transparent background recommended), SVG, JPG
- **Mobile**: Automatically resized for smaller screens
### SPA Page
Select which page hosts the WooNooW SPA. Default is "Store".
> **Note**: This page should contain the `[woonoow_spa]` shortcode.
### SPA Mode
Choose how WooNooW handles your store pages:
| Mode | Description |
|------|-------------|
| **Full** | All WooCommerce pages redirect to SPA |
| **Disabled** | Native WooCommerce templates are used |
---
## Colors
### Primary Color
The main brand color used for:
- Buttons
- Links
- Active states
- Primary actions
**Default**: `#6366f1` (Indigo)
### Secondary Color
Secondary UI elements:
- Less prominent buttons
- Borders
- Subtle backgrounds
**Default**: `#64748b` (Slate)
### Accent Color
Highlight color for:
- Sale badges
- Notifications
- Call-to-action elements
**Default**: `#f59e0b` (Amber)
---
## Typography
### Body Font
Font used for general text content.
**Options**: System fonts and Google Fonts
- Inter
- Open Sans
- Roboto
- Lato
- Poppins
- And more...
### Heading Font
Font used for titles and headings.
**Options**: Same as body fonts, plus:
- Cormorant Garamond (Serif option)
- Playfair Display
- Merriweather
### Font Sizes
Font sizes are responsive and adjust automatically based on screen size.
---
## Layout
### Container Width
Maximum width of the content area.
| Option | Width |
|--------|-------|
| Narrow | 1024px |
| Default | 1280px |
| Wide | 1536px |
| Full | 100% |
### Header Style
Configure the header appearance:
- **Fixed**: Stays at top when scrolling
- **Static**: Scrolls with page
### Product Grid
Columns in the shop page grid:
- Mobile: 1-2 columns
- Tablet: 2-3 columns
- Desktop: 3-4 columns
---
## Saving Changes
1. Make your changes
2. Click **Save Changes** button
3. Refresh your store page to see updates
> **Tip**: Open your store in another tab to preview changes quickly.

View File

@@ -0,0 +1,139 @@
# SPA Mode
Understanding and configuring WooNooW's SPA (Single Page Application) mode.
## What is SPA Mode?
SPA Mode controls how WooNooW handles your WooCommerce pages. It determines whether visitors experience the modern SPA interface or traditional WooCommerce templates.
---
## Available Modes
### Full Mode (Recommended)
**All WooCommerce pages redirect to the SPA.**
When a visitor navigates to:
- `/shop` → Redirects to `/store/shop`
- `/product/example` → Redirects to `/store/product/example`
- `/cart` → Redirects to `/store/cart`
- `/checkout` → Redirects to `/store/checkout`
- `/my-account` → Redirects to `/store/my-account`
**Benefits**:
- Instant page transitions
- Modern, consistent UI
- Better mobile experience
- Smooth animations
**Best for**:
- New stores
- Stores wanting a modern look
- Mobile-focused businesses
### Disabled Mode
**WooCommerce uses its native templates.**
WooCommerce pages work normally with your theme's templates. WooNooW admin features still work, but the customer-facing SPA is turned off.
**Benefits**:
- Keep existing theme customizations
- Compatibility with WooCommerce template overrides
- Traditional page-by-page navigation
**Best for**:
- Stores with heavy theme customizations
- Testing before full rollout
- Troubleshooting issues
---
## Switching Modes
### How to Switch
1. Go to **WooNooW → Appearance → General**
2. Find **SPA Mode** setting
3. Select your preferred mode
4. Click **Save Changes**
### What Happens When Switching
**Switching to Full**:
- WooCommerce pages start redirecting
- SPA loads for shop experience
- No data is changed
**Switching to Disabled**:
- Redirects stop immediately
- WooCommerce templates take over
- No data is changed
> **Note**: All your products, orders, and settings remain unchanged when switching modes.
---
## URL Structure
### Full Mode URLs
```
https://yourstore.com/store/ → Home/Shop
https://yourstore.com/store/shop → Shop page
https://yourstore.com/store/product/slug → Product page
https://yourstore.com/store/cart → Cart
https://yourstore.com/store/checkout → Checkout
https://yourstore.com/store/my-account → Account
```
### Disabled Mode URLs
Standard WooCommerce URLs:
```
https://yourstore.com/shop/ → Shop page
https://yourstore.com/product/slug → Product page
https://yourstore.com/cart/ → Cart
https://yourstore.com/checkout/ → Checkout
https://yourstore.com/my-account/ → Account
```
---
## SEO Considerations
### Full Mode SEO
- WooCommerce URLs (`/product/slug`) remain in sitemaps
- When users click from search results, they're redirected to SPA
- Meta tags are generated dynamically for social sharing
- 302 (temporary) redirects preserve link equity
### Disabled Mode SEO
- Standard WooCommerce SEO applies
- No redirects needed
- Works with Yoast SEO, RankMath, etc.
---
## Troubleshooting
### Redirects Not Working
1. **Flush Permalinks**: Go to Settings → Permalinks → Save Changes
2. **Check Store Page**: Ensure the Store page exists and has `[woonoow_spa]`
3. **Clear Cache**: Purge all caching layers
### Blank Pages After Enabling
1. Verify SPA Mode is set to "Full"
2. Clear browser cache
3. Check for JavaScript errors in browser console
### Want to Test Before Enabling
1. Keep mode as "Disabled"
2. Visit `/store/` directly to preview SPA
3. Switch to "Full" when satisfied

149
docs/faq.md Normal file
View File

@@ -0,0 +1,149 @@
# Frequently Asked Questions
Quick answers to common questions about WooNooW.
---
## General
### What is WooNooW?
WooNooW is a WooCommerce plugin that transforms your store into a modern Single Page Application (SPA). It provides instant page loads, a beautiful UI, and seamless shopping experience.
### Do I need WooCommerce?
Yes. WooNooW is an enhancement layer for WooCommerce. You need WooCommerce installed and activated.
### Will WooNooW affect my existing products?
No. WooNooW reads from WooCommerce. Your products, orders, and settings remain untouched.
---
## SPA Mode
### What's the difference between Full and Disabled mode?
| Mode | Behavior |
|------|----------|
| **Full** | All WooCommerce pages redirect to SPA. Modern, fast experience. |
| **Disabled** | WooCommerce pages use native templates. WooNooW admin still works. |
### Can I switch modes anytime?
Yes. Go to **WooNooW → Appearance → General** and change the SPA Mode. Changes take effect immediately.
### Which mode should I use?
- **Full**: For the best customer experience with instant loads
- **Disabled**: If you have theme customizations you want to keep
---
## Compatibility
### Does WooNooW work with my theme?
WooNooW's SPA is independent of your WordPress theme. In Full mode, the SPA uses its own styling. Your theme affects the rest of your site normally.
### Does WooNooW work with page builders?
The SPA pages are self-contained. Page builders work on other pages of your site.
### Which payment gateways are supported?
WooNooW supports all WooCommerce-compatible payment gateways:
- PayPal
- Stripe
- Bank Transfer (BACS)
- Cash on Delivery
- And more...
---
## SEO
### Is WooNooW SEO-friendly?
Yes. WooNooW uses:
- Clean URLs (`/store/product/product-name`)
- Dynamic meta tags for social sharing
- Proper redirects (302) from WooCommerce URLs
### What about my existing SEO?
WooCommerce URLs remain the indexed source. WooNooW redirects users to the SPA but preserves SEO value.
### Will my product pages be indexed?
Yes. Search engines index the WooCommerce URLs. When users click from search results, they're redirected to the fast SPA experience.
---
## Performance
### Is WooNooW faster than regular WooCommerce?
Yes, for navigation. After the initial load, page transitions are instant because the SPA doesn't reload the entire page.
### Will WooNooW slow down my site?
The initial load is similar to regular WooCommerce. Subsequent navigation is much faster.
### Does WooNooW work with caching?
Yes. Use page caching and object caching for best results.
---
## Customization
### Can I customize colors and fonts?
Yes. Go to **WooNooW → Appearance** to customize:
- Primary, secondary, and accent colors
- Body and heading fonts
- Logo and layout options
### Can I add custom CSS?
Currently, use your theme's Additional CSS feature. A custom CSS field may be added in future versions.
### Can I modify the SPA templates?
The SPA is built with React. Advanced customizations require development knowledge.
---
## Addons
### What are WooNooW addons?
Addons extend WooNooW with additional features like loyalty points, advanced analytics, etc.
### How do I install addons?
Addons are installed as separate WordPress plugins. They integrate automatically with WooNooW.
### Do addons work when SPA is disabled?
Most addon features are for the SPA. When disabled, addon functionality may be limited.
---
## Troubleshooting
### I see a blank page. What do I do?
1. Check SPA Mode is set to "Full"
2. Flush permalinks (**Settings → Permalinks → Save**)
3. Clear all caches
4. See [Troubleshooting](troubleshooting) for more
### How do I report a bug?
Contact support with:
- Steps to reproduce the issue
- WordPress/WooCommerce/WooNooW versions
- Any error messages
- Screenshots if applicable

145
docs/features/checkout.md Normal file
View File

@@ -0,0 +1,145 @@
# Checkout
The WooNooW checkout provides a streamlined purchasing experience.
## Overview
The checkout process includes:
1. **Cart Review** - Verify items before checkout
2. **Customer Information** - Billing and shipping details
3. **Payment Method** - Select how to pay
4. **Order Confirmation** - Complete the purchase
---
## Checkout Flow
### Step 1: Cart
Before checkout, customers review their cart:
- Product list with images
- Quantity adjustments
- Remove items
- Apply coupon codes
- See subtotal, shipping, and total
### Step 2: Customer Details
Customers provide:
- **Email address**
- **Billing information**
- Name
- Address
- Phone
- **Shipping address** (if different from billing)
> **Note**: Logged-in customers have their details pre-filled.
### Step 3: Shipping Method
If physical products are in the cart:
- Available shipping methods are shown
- Shipping cost is calculated
- Customer selects preferred method
### Step 4: Payment
Customers choose their payment method:
- Credit/Debit Card (Stripe, PayPal, etc.)
- Bank Transfer
- Cash on Delivery
- Other configured gateways
### Step 5: Place Order
After reviewing everything:
- Click "Place Order"
- Payment is processed
- Confirmation page is shown
- Email receipt is sent
---
## Features
### Guest Checkout
Allow customers to checkout without creating an account.
Configure in **WooCommerce → Settings → Accounts & Privacy**.
### Coupon Codes
Customers can apply discount codes:
1. Enter code in the coupon field
2. Click "Apply"
3. Discount is reflected in total
### Order Notes
Optional field for customers to add special instructions.
---
## Payment Gateways
### Supported Gateways
WooNooW supports all WooCommerce payment gateways:
| Gateway | Type |
|---------|------|
| Bank Transfer (BACS) | Manual |
| Check Payments | Manual |
| Cash on Delivery | Manual |
| PayPal | Card / PayPal |
| Stripe | Card |
| Square | Card |
### Configuring Gateways
1. Go to **WooNooW → Settings → Payments**
2. Enable desired payment methods
3. Configure API keys and settings
4. Test with sandbox/test mode first
---
## After Checkout
### Order Confirmation Page
Shows:
- Order number
- Order summary
- Next steps
### Confirmation Email
Automatically sent to customer with:
- Order details
- Payment confirmation
- Shipping information (if applicable)
---
## Troubleshooting
### "Place Order" Button Not Working
1. Check all required fields are filled
2. Verify payment gateway is properly configured
3. Check browser console for JavaScript errors
### Payment Declined
1. Customer should verify card details
2. Check payment gateway dashboard for error details
3. Ensure correct API keys are configured
### Shipping Not Showing
1. Verify shipping zones are configured in WooCommerce
2. Check if products have weight/dimensions set
3. Confirm customer's address is in a configured zone

96
docs/features/shop.md Normal file
View File

@@ -0,0 +1,96 @@
# Shop Page
The shop page displays your product catalog with browsing and filtering options.
## Overview
The WooNooW shop page provides:
- **Product Grid** - Visual display of products
- **Search** - Find products by name
- **Filters** - Category and sorting options
- **Pagination** - Navigate through products
---
## Features
### Product Cards
Each product displays:
- Product image
- Product name
- Price (with sale price if applicable)
- Add to Cart button
- Wishlist button (if enabled)
### Search
Type in the search box to filter products by name. Search is instant and updates the grid as you type.
### Category Filter
Filter products by category using the dropdown. Shows:
- All Categories
- Individual categories with product count
### Sorting
Sort products by:
- Default sorting
- Popularity
- Average rating
- Latest
- Price: Low to High
- Price: High to Low
---
## Customization
### Grid Layout
Configure the product grid in **WooNooW → Appearance**:
| Device | Options |
|--------|---------|
| Mobile | 1-2 columns |
| Tablet | 2-4 columns |
| Desktop | 2-6 columns |
### Product Card Style
Product cards can display:
- **Image** - Product featured image
- **Title** - Product name
- **Price** - Current price and sale price
- **Rating** - Star rating (if reviews enabled)
- **Add to Cart** - Quick add button
---
## Navigation
### Clicking a Product
Clicking a product card navigates to the full product page where customers can:
- View all images
- Select variations
- Read description
- Add to cart
### Back to Shop
From any product page, use the breadcrumb or browser back button to return to the shop.
---
## Performance
### Lazy Loading
Product images load as they come into view, improving initial page load time.
### Infinite Scroll vs Pagination
Currently uses pagination. Infinite scroll may be added in future versions.

54
docs/getting-started.md Normal file
View File

@@ -0,0 +1,54 @@
# Getting Started with WooNooW
Welcome to WooNooW! This guide will help you get up and running quickly.
## What is WooNooW?
WooNooW transforms your WooCommerce store into a modern, fast Single Page Application (SPA). It provides:
-**Instant Page Loads** - No page refreshes between navigation
- 🎨 **Modern UI** - Beautiful, responsive design out of the box
- 🛠 **Easy Customization** - Configure colors, fonts, and layout from admin
- 📱 **Mobile-First** - Optimized for all devices
## Quick Setup (3 Steps)
### Step 1: Activate the Plugin
After installing WooNooW, activate it from **Plugins → Installed Plugins**.
The plugin will automatically:
- Create a "Store" page for the SPA
- Configure basic settings
### Step 2: Access Admin Dashboard
Go to **WooNooW** in your WordPress admin menu.
You'll see the admin dashboard with:
- Orders management
- Settings configuration
- Appearance customization
### Step 3: Configure Your Store
Navigate to **Appearance** settings to:
1. **Upload your logo**
2. **Set brand colors** (primary, secondary, accent)
3. **Choose fonts** for headings and body text
4. **Configure SPA mode** (Full or Disabled)
## Next Steps
- [Installation Guide](installation) - Detailed installation instructions
- [Appearance Settings](configuration/appearance) - Customize your store's look
- [SPA Mode](configuration/spa-mode) - Understand Full vs Disabled mode
- [Troubleshooting](troubleshooting) - Common issues and solutions
## Need Help?
If you encounter any issues:
1. Check the [Troubleshooting](troubleshooting) guide
2. Review the [FAQ](faq)
3. Contact support with your WordPress and WooCommerce versions

92
docs/installation.md Normal file
View File

@@ -0,0 +1,92 @@
# Installation Guide
This guide covers installing WooNooW on your WordPress site.
## Requirements
Before installing, ensure your site meets these requirements:
| Requirement | Minimum | Recommended |
|-------------|---------|-------------|
| WordPress | 6.0+ | Latest |
| WooCommerce | 7.0+ | Latest |
| PHP | 7.4+ | 8.1+ |
| MySQL | 5.7+ | 8.0+ |
## Installation Methods
### Method 1: WordPress Admin (Recommended)
1. Go to **Plugins → Add New**
2. Click **Upload Plugin**
3. Select the `woonoow.zip` file
4. Click **Install Now**
5. Click **Activate**
### Method 2: FTP Upload
1. Extract `woonoow.zip` to get the `woonoow` folder
2. Upload to `/wp-content/plugins/`
3. Go to **Plugins → Installed Plugins**
4. Find WooNooW and click **Activate**
## Post-Installation
After activation, WooNooW automatically:
### 1. Creates Store Page
A new "Store" page is created with the SPA shortcode. This is your main storefront.
### 2. Registers Rewrite Rules
URL routes like `/store/shop` and `/store/product/...` are registered.
> **Note**: If you see 404 errors, go to **Settings → Permalinks** and click **Save Changes** to flush rewrite rules.
### 3. Sets Default Configuration
Basic appearance settings are configured with sensible defaults.
## Verification Checklist
After installation, verify everything works:
- [ ] Plugin activated without errors
- [ ] WooNooW menu appears in admin sidebar
- [ ] Store page exists (check **Pages**)
- [ ] `/store` URL loads the SPA
- [ ] Products display on shop page
## WooCommerce Compatibility
WooNooW works alongside WooCommerce:
| WooCommerce Page | WooNooW Behavior (Full Mode) |
|------------------|------------------------------|
| `/shop` | Redirects to `/store/shop` |
| `/product/...` | Redirects to `/store/product/...` |
| `/cart` | Redirects to `/store/cart` |
| `/checkout` | Redirects to `/store/checkout` |
| `/my-account` | Redirects to `/store/my-account` |
When SPA Mode is **Disabled**, WooCommerce pages work normally.
## Updating
To update WooNooW:
1. Download the latest version
2. Go to **Plugins → Installed Plugins**
3. Deactivate WooNooW (optional but recommended)
4. Delete the old version
5. Install and activate the new version
Your settings are preserved in the database.
## Uninstalling
To completely remove WooNooW:
1. Deactivate the plugin (restores WooCommerce page content)
2. Delete the plugin
3. (Optional) Delete WooNooW options from database
> **Note**: Deactivating restores original WooCommerce shortcodes to Cart, Checkout, and My Account pages.

173
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,173 @@
# Troubleshooting
Common issues and their solutions.
## Blank Pages
### Symptom
WooCommerce pages (shop, cart, checkout) show blank content.
### Solutions
**1. Check SPA Mode Setting**
- Go to **WooNooW → Appearance → General**
- Ensure **SPA Mode** is set to "Full"
- If you want native WooCommerce, set to "Disabled"
**2. Flush Permalinks**
- Go to **Settings → Permalinks**
- Click **Save Changes** (no changes needed)
- This refreshes rewrite rules
**3. Clear Cache**
If using a caching plugin:
- Clear page cache
- Clear object cache
- Purge CDN cache (if applicable)
---
## 404 Errors on SPA Routes
### Symptom
Visiting `/store/shop` or `/store/product/...` shows a 404 error.
### Solutions
**1. Flush Permalinks**
- Go to **Settings → Permalinks**
- Click **Save Changes**
**2. Check Store Page Exists**
- Go to **Pages**
- Verify "Store" page exists and is published
- The page should contain `[woonoow_spa]` shortcode
**3. Check SPA Page Setting**
- Go to **WooNooW → Appearance → General**
- Ensure **SPA Page** is set to the Store page
---
## Product Images Not Loading
### Symptom
Products show placeholder images instead of actual images.
### Solutions
**1. Regenerate Thumbnails**
- Install "Regenerate Thumbnails" plugin
- Run regeneration for all images
**2. Check Image URLs**
- Ensure images have valid URLs
- Check for mixed content (HTTP vs HTTPS)
---
## Slow Performance
### Symptom
SPA feels slow or laggy.
### Solutions
**1. Enable Caching**
- Install a caching plugin (WP Super Cache, W3 Total Cache)
- Enable object caching (Redis/Memcached)
**2. Optimize Images**
- Use WebP format
- Compress images before upload
- Use lazy loading
**3. Check Server Resources**
- Upgrade hosting if on shared hosting
- Consider VPS or managed WordPress hosting
---
## Checkout Not Working
### Symptom
Checkout page won't load or payment fails.
### Solutions
**1. Check Payment Gateway**
- Go to **WooCommerce → Settings → Payments**
- Verify payment method is enabled
- Check API credentials
**2. Check SSL Certificate**
- Checkout requires HTTPS
- Verify SSL is properly installed
**3. Check for JavaScript Errors**
- Open browser Developer Tools (F12)
- Check Console for errors
- Look for blocked scripts
---
## Emails Not Sending
### Symptom
Order confirmation emails not being received.
### Solutions
**1. Check Email Settings**
- Go to **WooNooW → Settings → Notifications**
- Verify email types are enabled
**2. Check WordPress Email**
- Test with a plugin like "Check & Log Email"
- Consider using SMTP plugin (WP Mail SMTP)
**3. Check Spam Folder**
- Emails may be in recipient's spam folder
- Add sender to whitelist
---
## Plugin Conflicts
### Symptom
WooNooW doesn't work after installing another plugin.
### Steps to Diagnose
1. **Deactivate other plugins** one by one
2. **Switch to default theme** (Twenty Twenty-Three)
3. **Check error logs** in `wp-content/debug.log`
### Common Conflicting Plugins
- Other WooCommerce template overrides
- Page builder plugins (sometimes)
- Heavy caching plugins (misconfigured)
---
## Getting More Help
If you can't resolve the issue:
1. **Collect Information**
- WordPress version
- WooCommerce version
- WooNooW version
- PHP version
- Error messages (from debug.log)
2. **Enable Debug Mode**
Add to `wp-config.php`:
```php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
```
3. **Contact Support**
Provide the collected information for faster resolution.

View File

@@ -17,7 +17,6 @@ class Assets
{ {
// Debug logging // Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) { if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[WooNooW Assets] Hook: ' . $hook);
} }
if ($hook !== 'toplevel_page_woonoow') { if ($hook !== 'toplevel_page_woonoow') {
@@ -32,7 +31,6 @@ class Assets
// Debug logging // Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) { if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[WooNooW Assets] Dev mode: ' . ($is_dev ? 'true' : 'false'));
} }
if ($is_dev) { if ($is_dev) {
@@ -74,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');
@@ -155,11 +155,6 @@ class Assets
// Debug logging // Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) { if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[WooNooW Assets] Dist dir: ' . $dist_dir);
error_log('[WooNooW Assets] CSS exists: ' . (file_exists($dist_dir . $css) ? 'yes' : 'no'));
error_log('[WooNooW Assets] JS exists: ' . (file_exists($dist_dir . $js) ? 'yes' : 'no'));
error_log('[WooNooW Assets] CSS URL: ' . $base_url . $css);
error_log('[WooNooW Assets] JS URL: ' . $base_url . $js);
} }
if (file_exists($dist_dir . $css)) { if (file_exists($dist_dir . $css)) {
@@ -202,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)
@@ -286,7 +283,6 @@ class Assets
// Debug logging (only if WP_DEBUG is enabled) // Debug logging (only if WP_DEBUG is enabled)
if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) { if (defined('WP_DEBUG') && WP_DEBUG && $filtered !== $const_dev) {
error_log('[WooNooW Assets] Dev mode changed by filter: ' . ($filtered ? 'true' : 'false'));
} }
return (bool) $filtered; return (bool) $filtered;
@@ -316,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

@@ -53,9 +53,6 @@ class StandaloneAdmin {
// Debug logging (only in WP_DEBUG mode) // Debug logging (only in WP_DEBUG mode)
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[StandaloneAdmin] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
error_log( '[StandaloneAdmin] has manage_woocommerce: ' . ( $has_permission ? 'true' : 'false' ) );
error_log( '[StandaloneAdmin] is_authenticated: ' . ( $is_authenticated ? 'true' : 'false' ) );
} }
// Get nonce for REST API // Get nonce for REST API
@@ -135,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
@@ -197,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

@@ -60,9 +60,6 @@ class AuthController {
// Debug logging // Debug logging
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[AuthController::login] Login successful for user ID: ' . $user->ID );
error_log( '[AuthController::login] Current user ID: ' . get_current_user_id() );
error_log( '[AuthController::login] Cookies set: ' . ( headers_sent() ? 'Headers already sent!' : 'OK' ) );
} }
// Return user data and new nonce // Return user data and new nonce
@@ -154,8 +151,6 @@ class AuthController {
// Debug logging // Debug logging
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[AuthController::check] is_user_logged_in: ' . ( $is_logged_in ? 'true' : 'false' ) );
error_log( '[AuthController::check] Cookies: ' . print_r( $_COOKIE, true ) );
} }
if ( ! $is_logged_in ) { if ( ! $is_logged_in ) {

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,129 @@
<?php
/**
* Documentation API Controller
*
* Serves documentation content to the Admin SPA.
*/
namespace WooNooW\Api;
use WP_REST_Controller;
use WP_REST_Response;
use WP_REST_Request;
use WP_Error;
class DocsController extends WP_REST_Controller {
/**
* Namespace for REST routes
*/
protected $namespace = 'woonoow/v1';
/**
* Base route
*/
protected $rest_base = 'docs';
/**
* Register routes
*/
public function register_routes() {
// GET /woonoow/v1/docs - List all documentation
register_rest_route($this->namespace, '/' . $this->rest_base, [
[
'methods' => 'GET',
'callback' => [$this, 'get_docs_registry'],
'permission_callback' => [$this, 'check_permissions'],
],
]);
// GET /woonoow/v1/docs/{slug} - Get single document
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<slug>.+)', [
[
'methods' => 'GET',
'callback' => [$this, 'get_doc'],
'permission_callback' => [$this, 'check_permissions'],
'args' => [
'slug' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
],
]);
}
/**
* Check permissions - any logged in admin user
*/
public function check_permissions($request) {
return current_user_can('manage_options');
}
/**
* Get documentation registry
*
* @return WP_REST_Response
*/
public function get_docs_registry($request) {
require_once WOONOOW_PATH . 'docs/_registry.php';
$registry = \WooNooW\Docs\get_docs_registry();
// Transform to frontend format (without file paths)
$result = [];
foreach ($registry as $section_key => $section) {
$items = [];
foreach ($section['items'] as $item) {
$items[] = [
'slug' => $item['slug'],
'title' => $item['title'],
];
}
$result[] = [
'key' => $section_key,
'label' => $section['label'],
'icon' => $section['icon'] ?? 'file-text',
'items' => $items,
];
}
return new WP_REST_Response([
'success' => true,
'sections' => $result,
], 200);
}
/**
* Get single document content
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function get_doc($request) {
$slug = $request->get_param('slug');
require_once WOONOOW_PATH . 'docs/_registry.php';
$doc = \WooNooW\Docs\get_doc_by_slug($slug);
if (!$doc) {
return new WP_Error(
'doc_not_found',
'Documentation not found',
['status' => 404]
);
}
return new WP_REST_Response([
'success' => true,
'doc' => [
'slug' => $doc['slug'],
'title' => $doc['title'],
'content' => $doc['content'],
],
], 200);
}
}

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

@@ -770,16 +770,13 @@ class OrdersController {
if ( null !== $status && $status !== '' ) { if ( null !== $status && $status !== '' ) {
$order_id = $order->get_id(); $order_id = $order->get_id();
add_action( 'shutdown', function() use ( $order_id, $status ) { add_action( 'shutdown', function() use ( $order_id, $status ) {
error_log('[WooNooW] Shutdown hook firing - scheduling email for order #' . $order_id);
self::schedule_order_email( $order_id, $status ); self::schedule_order_email( $order_id, $status );
error_log('[WooNooW] Email scheduled successfully for order #' . $order_id);
}, 999 ); }, 999 );
} }
return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 ); return new \WP_REST_Response( [ 'ok' => true, 'id' => $order->get_id() ], 200 );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
// Log the actual error for debugging // Log the actual error for debugging
error_log('[WooNooW] Order update failed: ' . $e->getMessage());
// Return user-friendly error message // Return user-friendly error message
return new \WP_REST_Response( [ return new \WP_REST_Response( [
@@ -797,13 +794,11 @@ class OrdersController {
public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) { public static function on_order_status_changed( $order_id, $status_from, $status_to, $order ) {
// Skip if we're in an API request (we schedule manually there) // Skip if we're in an API request (we schedule manually there)
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
error_log('[WooNooW] Skipping auto-schedule during API request for order #' . $order_id);
return; return;
} }
// Schedule email notification with 15s delay // Schedule email notification with 15s delay
self::schedule_order_email( $order_id, $status_to ); self::schedule_order_email( $order_id, $status_to );
error_log('[WooNooW] Order #' . $order_id . ' status changed: ' . $status_from . ' → ' . $status_to . ', email scheduled');
} }
/** /**
@@ -863,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' );
} }
} }
@@ -1042,7 +1061,6 @@ class OrdersController {
$order->apply_coupon( $coupon ); $order->apply_coupon( $coupon );
} }
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
error_log( '[WooNooW] Coupon error: ' . $e->getMessage() );
} }
} }
@@ -1234,7 +1252,6 @@ class OrdersController {
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
// Log the actual error for debugging // Log the actual error for debugging
error_log('[WooNooW] Order creation failed: ' . $e->getMessage());
// Return user-friendly error message // Return user-friendly error message
return new \WP_REST_Response( [ return new \WP_REST_Response( [
@@ -1251,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(),
@@ -1302,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
@@ -1492,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
@@ -1510,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(),
@@ -1529,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 ) {
@@ -2025,7 +2105,6 @@ class OrdersController {
// Check if gateway exists // Check if gateway exists
if ( ! isset( $gateways[ $gateway_id ] ) ) { if ( ! isset( $gateways[ $gateway_id ] ) ) {
error_log( '[WooNooW] Payment gateway not found: ' . $gateway_id );
return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) ); return new \WP_Error( 'gateway_not_found', sprintf( __( 'Payment gateway not found: %s', 'woonoow' ), $gateway_id ) );
} }
@@ -2033,7 +2112,6 @@ class OrdersController {
// Check if gateway has process_payment method // Check if gateway has process_payment method
if ( ! method_exists( $gateway, 'process_payment' ) ) { if ( ! method_exists( $gateway, 'process_payment' ) ) {
error_log( '[WooNooW] Gateway does not have process_payment method: ' . $gateway_id );
return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) ); return new \WP_Error( 'no_process_method', sprintf( __( 'Gateway does not support payment processing: %s', 'woonoow' ), $gateway_id ) );
} }
@@ -2045,7 +2123,6 @@ class OrdersController {
// Set flag for gateways to detect admin context // Set flag for gateways to detect admin context
add_filter( 'woonoow/is_admin_order', '__return_true' ); add_filter( 'woonoow/is_admin_order', '__return_true' );
error_log( '[WooNooW] Processing payment for order #' . $order->get_id() . ' with gateway: ' . $gateway_id );
// Call gateway's process_payment method // Call gateway's process_payment method
$result = $gateway->process_payment( $order->get_id() ); $result = $gateway->process_payment( $order->get_id() );
@@ -2061,11 +2138,9 @@ class OrdersController {
if ( isset( $result['result'] ) && $result['result'] === 'success' ) { if ( isset( $result['result'] ) && $result['result'] === 'success' ) {
$order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) ); $order->add_order_note( __( 'Payment gateway processing completed via WooNooW', 'woonoow' ) );
error_log( '[WooNooW] Payment processing succeeded for order #' . $order->get_id() );
} elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) { } elseif ( isset( $result['result'] ) && $result['result'] === 'failure' ) {
$message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' ); $message = isset( $result['message'] ) ? $result['message'] : __( 'Payment processing failed', 'woonoow' );
$order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) ); $order->add_order_note( sprintf( __( 'Payment gateway error: %s', 'woonoow' ), $message ) );
error_log( '[WooNooW] Payment processing failed for order #' . $order->get_id() . ': ' . $message );
} }
$order->save(); $order->save();
@@ -2074,7 +2149,6 @@ class OrdersController {
return $result; return $result;
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
error_log( '[WooNooW] Payment processing exception for order #' . $order->get_id() . ': ' . $e->getMessage() );
$order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) ); $order->add_order_note( sprintf( __( 'Payment gateway exception: %s', 'woonoow' ), $e->getMessage() ) );
$order->save(); $order->save();

View File

@@ -212,12 +212,10 @@ class PaymentsController extends WP_REST_Controller {
try { try {
// Debug: Log what we're saving // Debug: Log what we're saving
error_log(sprintf('[WooNooW] Saving gateway %s settings: %s', $gateway_id, json_encode($settings)));
$result = PaymentGatewaysProvider::save_gateway_settings($gateway_id, $settings); $result = PaymentGatewaysProvider::save_gateway_settings($gateway_id, $settings);
if (is_wp_error($result)) { if (is_wp_error($result)) {
error_log(sprintf('[WooNooW] Save failed: %s', $result->get_error_message()));
return $result; return $result;
} }
@@ -228,7 +226,6 @@ class PaymentsController extends WP_REST_Controller {
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id); $gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
// Debug: Log success // Debug: Log success
error_log(sprintf('[WooNooW] Gateway %s settings saved successfully', $gateway_id));
return rest_ensure_response([ return rest_ensure_response([
'success' => true, 'success' => true,
@@ -236,7 +233,6 @@ class PaymentsController extends WP_REST_Controller {
'gateway' => $gateway, 'gateway' => $gateway,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
error_log(sprintf('[WooNooW] Save exception: %s', $e->getMessage()));
return new WP_Error( return new WP_Error(
'save_gateway_failed', 'save_gateway_failed',
$e->getMessage(), $e->getMessage(),
@@ -268,12 +264,10 @@ class PaymentsController extends WP_REST_Controller {
try { try {
// Debug: Log what we're trying to do // Debug: Log what we're trying to do
error_log(sprintf('[WooNooW] Toggling gateway %s to %s', $gateway_id, $enabled ? 'enabled' : 'disabled'));
$result = PaymentGatewaysProvider::toggle_gateway($gateway_id, $enabled); $result = PaymentGatewaysProvider::toggle_gateway($gateway_id, $enabled);
if (is_wp_error($result)) { if (is_wp_error($result)) {
error_log(sprintf('[WooNooW] Toggle failed: %s', $result->get_error_message()));
return $result; return $result;
} }
@@ -284,7 +278,6 @@ class PaymentsController extends WP_REST_Controller {
$gateway = PaymentGatewaysProvider::get_gateway($gateway_id); $gateway = PaymentGatewaysProvider::get_gateway($gateway_id);
// Debug: Log what we got back // Debug: Log what we got back
error_log(sprintf('[WooNooW] Gateway %s after toggle: enabled=%s', $gateway_id, $gateway['enabled'] ? 'true' : 'false'));
return rest_ensure_response([ return rest_ensure_response([
'success' => true, 'success' => true,
@@ -292,7 +285,6 @@ class PaymentsController extends WP_REST_Controller {
'gateway' => $gateway, 'gateway' => $gateway,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
error_log(sprintf('[WooNooW] Toggle exception: %s', $e->getMessage()));
return new WP_Error( return new WP_Error(
'toggle_gateway_failed', 'toggle_gateway_failed',
$e->getMessage(), $e->getMessage(),
@@ -333,7 +325,6 @@ class PaymentsController extends WP_REST_Controller {
$option_key = 'woonoow_payment_gateway_order_' . $category; $option_key = 'woonoow_payment_gateway_order_' . $category;
update_option($option_key, $order, false); update_option($option_key, $order, false);
error_log(sprintf('[WooNooW] Saved %s gateway order: %s', $category, implode(', ', $order)));
return rest_ensure_response([ return rest_ensure_response([
'success' => true, 'success' => true,

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

@@ -24,6 +24,8 @@ use WooNooW\Api\NewsletterController;
use WooNooW\Api\ModulesController; 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\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;
@@ -157,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();
@@ -165,6 +170,10 @@ class Routes {
$module_settings_controller = new ModuleSettingsController(); $module_settings_controller = new ModuleSettingsController();
$module_settings_controller->register_routes(); $module_settings_controller->register_routes();
// Documentation controller
$docs_controller = new DocsController();
$docs_controller->register_routes();
// Frontend controllers (customer-facing) // Frontend controllers (customer-facing)
ShopController::register_routes(); ShopController::register_routes();
FrontendCartController::register_routes(); FrontendCartController::register_routes();

View File

@@ -454,7 +454,6 @@ class ShippingController extends WP_REST_Controller {
); );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
error_log( sprintf( '[WooNooW] Toggle exception: %s', $e->getMessage() ) );
return new WP_REST_Response( return new WP_REST_Response(
array( array(
'error' => 'toggle_failed', 'error' => 'toggle_failed',

View File

@@ -21,6 +21,7 @@ class CustomerSettingsProvider {
// General // General
'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes', 'auto_register_members' => get_option('woonoow_auto_register_members', 'no') === 'yes',
'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes', 'multiple_addresses_enabled' => get_option('woonoow_multiple_addresses_enabled', 'yes') === 'yes',
'allow_custom_avatar' => get_option('woonoow_allow_custom_avatar', 'no') === 'yes',
// VIP Customer Qualification // VIP Customer Qualification
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)), 'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
@@ -49,8 +50,10 @@ class CustomerSettingsProvider {
update_option('woonoow_multiple_addresses_enabled', $value); update_option('woonoow_multiple_addresses_enabled', $value);
} }
if (array_key_exists('allow_custom_avatar', $settings)) {
$value = !empty($settings['allow_custom_avatar']) ? 'yes' : 'no';
update_option('woonoow_allow_custom_avatar', $value);
}
// VIP settings // VIP settings
if (isset($settings['vip_min_spent'])) { if (isset($settings['vip_min_spent'])) {
update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent'])); update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));

View File

@@ -13,7 +13,7 @@ if ( ! defined('ABSPATH') ) exit;
*/ */
class NavigationRegistry { class NavigationRegistry {
const NAV_OPTION = 'wnw_nav_tree'; const NAV_OPTION = 'wnw_nav_tree';
const NAV_VERSION = '1.0.8'; // Added Modules to Settings menu const NAV_VERSION = '1.0.9'; // Added Help menu
/** /**
* Initialize hooks * Initialize hooks
@@ -186,6 +186,13 @@ class NavigationRegistry {
'icon' => 'settings', 'icon' => 'settings',
'children' => self::get_settings_children(), 'children' => self::get_settings_children(),
], ],
[
'key' => 'help',
'label' => __('Help', 'woonoow'),
'path' => '/help',
'icon' => 'help-circle',
'children' => [], // Empty array = no submenu bar
],
]; ];
return $tree; return $tree;

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