Compare commits

..

61 Commits

Author SHA1 Message Date
Dwindi Ramadhana
0421e5010f fix: use SPA page (store) for reset password URL
Changed from /my-account to /store page URL:
- Now reads spa_page from woonoow_appearance_settings
- Uses get_permalink() on the configured SPA page ID
- Fallback to home_url if SPA not configured
- Reset URL format: /store/#/reset-password?key=...&login=...
2026-01-03 17:45:51 +07:00
Dwindi Ramadhana
da6255dd0c fix: remove emoji from TipTap button, add subtle background
- Removed 🔘 emoji prefix from button text
- Button now shows text with subtle purple background pill
- Added padding and border-radius to differentiate from regular links
- Hover tooltip still shows 'Button: text → url' for clarity
2026-01-03 17:40:44 +07:00
Dwindi Ramadhana
91ae4956e0 chore: update build scripts for both SPAs
- build:admin: builds admin-spa
- build:customer: builds customer-spa
- build: builds both admin and customer SPAs
- dev:customer: added dev server for customer-spa
2026-01-03 17:36:50 +07:00
Dwindi Ramadhana
b010a88619 feat: simplify TipTap button styling + add click-to-edit
Button Styling:
- Buttons now render as simple links with 🔘 prefix in editor
- No more styled button appearance in TipTap (was inconsistent)
- Actual button styling still happens in email (EmailRenderer.php)

Click-to-Edit:
- Click any button in the editor to open edit dialog
- Edit button text, link URL, and style (solid/outline)
- Delete button option in edit mode
- Updates button in-place instead of requiring recreation

Dialog improvements:
- Shows 'Edit Button' title in edit mode
- Shows 'Update Button' vs 'Insert Button' based on mode
- Delete button (red) appears only in edit mode
2026-01-03 17:22:34 +07:00
Dwindi Ramadhana
a98217897c fix: use customer-spa for password reset page
Changed reset link URL from admin SPA to customer-spa:
- Old: /wp-admin/admin.php?page=woonoow#/reset-password?key=...
- New: /my-account#/reset-password?key=...

This fixes the login redirect issue - the customer-spa is publicly
accessible so users can reset their password without logging in first.

Added:
- customer-spa/src/pages/ResetPassword/index.tsx
- Route /reset-password in customer-spa App.tsx

EmailManager.php now:
- Uses wc_get_page_id('myaccount') to get my-account page URL
- Falls back to home_url if my-account page not found
2026-01-03 17:09:00 +07:00
Dwindi Ramadhana
316fcbf2f0 feat: SPA-based password reset page
- Created ResetPassword.tsx with:
  - Password reset form with strength indicator
  - Key validation on load
  - Show/hide password toggle
  - Success/error states
  - Redirect to login on success

- Updated EmailManager.php:
  - Changed reset_link from wp-login.php to SPA route
  - Format: /wp-admin/admin.php?page=woonoow#/reset-password?key=KEY&login=LOGIN

- Added AuthController API methods:
  - validate_reset_key: Validates reset key before showing form
  - reset_password: Performs actual password reset

- Registered new REST routes in Routes.php:
  - POST /auth/validate-reset-key
  - POST /auth/reset-password

Password reset emails now link to the SPA instead of native WordPress.
2026-01-03 16:59:05 +07:00
Dwindi Ramadhana
3f8d15de61 fix: remove left borders from cards - use background color only
Per user request, removed border-left from success/info/warning cards.
Cards now distinguished by background color only, preserving border-radius.

Updated in:
- EmailRenderer.php: removed border-left from inline styles
- EditTemplate.tsx: removed border-left from CSS classes
- TemplateEditor.tsx: removed border-left from CSS classes

Card styling now:
- Success: #f0fdf4 (light green)
- Info: #f0f7ff (light blue)
- Warning: #fff8e1 (light yellow/cream)
- Hero: gradient background
2026-01-02 00:04:30 +07:00
Dwindi Ramadhana
930e525421 fix: card ordering - process cards in document order
OLD BEHAVIOR (broken):
parse_cards processed ALL [card:type] syntax FIRST, then [card type=...]
This caused cards to render out of order when syntaxes were mixed.

NEW BEHAVIOR (fixed):
Using a unified regex that matches BOTH syntaxes simultaneously:
/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s

Each match includes:
- Group 1: Card type from new syntax [card:type]
- Group 2: Attributes from old syntax [card type='...']
- Group 3: Card content

Cards now render in exact document order regardless of syntax used.
2026-01-01 23:57:12 +07:00
Dwindi Ramadhana
802b64db9f fix: card CSS consistency between preview and email
Updated card CSS in EditTemplate.tsx and TemplateEditor.tsx to exactly
match backend EmailRenderer inline styles:

BEFORE (inconsistent):
- Preview: border: 1px solid (all sides, rounded corners)
- Email: border-left: 4px solid (left side only)

AFTER (consistent):
- Success: background-color: #f0fdf4; border-left: 4px solid #22c55e
- Info: background-color: #f0f7ff; border-left: 4px solid #0071e3
- Warning: background-color: #fff8e1; border-left: 4px solid #ff9800

Hero/Highlight cards still use gradient backgrounds.
2026-01-01 23:55:52 +07:00
Dwindi Ramadhana
8959af8270 fix: remove hardcoded center alignment from button preview
parseCardsForPreview was forcing text-align: center on all buttons
regardless of user alignment choice. Removed the hardcoded style
so buttons follow natural document flow alignment.
2026-01-01 23:49:33 +07:00
Dwindi Ramadhana
1ce99e2bb6 fix: TipTap button style extraction when parsing HTML
Added getAttrs functions to parseHTML in tiptap-button-extension.ts.
Now properly extracts text/href/style from DOM elements:
- data-button: extracts from data-text, data-href, data-style
- a.button: extracts text/href, defaults to solid style
- a.button-outline: extracts text/href, defaults to outline style

This fixes the issue where buttons appeared unstyled (outline
instead of solid) when editing a card that contained buttons.
2026-01-01 23:48:06 +07:00
Dwindi Ramadhana
0a33ba0401 fix: button preservation when loading card for editing - add TipTap data attrs to parseMarkdownBasics 2026-01-01 23:41:15 +07:00
Dwindi Ramadhana
2ce7c0b263 fix: button detection with text alignment
Added data-button attribute selector to TipTap button parseHTML.
This ensures buttons are properly detected when text alignment is
applied, as alignment may affect CSS class detection.

Priority order:
1. a[data-button] - most reliable
2. a.button
3. a.button-outline
2026-01-01 23:34:41 +07:00
Dwindi Ramadhana
47f6370ce0 fix: TipTap button conversion in card save flow
ROOT CAUSE:
When saving card edit in EmailBuilder, htmlToMarkdown() was called.
The old code at line 26 converted ALL <a> tags to markdown links:
  <a href="url">text</a> → [text](url)

This lost TipTap button data-button attributes, converting buttons
to plain text instead of [button:style](url)Text[/button] shortcode.

FIX:
Added TipTap button detection BEFORE generic link conversion in
html-to-markdown.ts:
- Detects <a data-button...> elements
- Extracts style from data-style or class attribute
- Extracts URL from data-href or href attribute
- Converts to [button:style](url)Text[/button] format

FLOW NOW WORKS:
1. User adds button via TipTap toolbar
2. TipTap renders <a data-button data-style="solid"...>
3. User clicks Save Changes
4. htmlToMarkdown detects data-button → [button:solid](url)Text[/button]
5. Card content saved with proper button shortcode
6. On re-edit, button shortcode converted back to TipTap button
2026-01-01 23:31:54 +07:00
Dwindi Ramadhana
47a1e78eb7 fix: backend email rendering for new button/card syntax
ROOT CAUSE:
Frontend blocksToMarkdown outputs NEW syntax:
- [card:type]...[/card]
- [button:style](url)Text[/button]

But backend EmailRenderer.php only had regex for OLD syntax:
- [card type="..."]...[/card]
- [button url="..."]Text[/button]

FIXES:
1. parse_cards() now handles BOTH syntaxes:
   - NEW [card:type] regex first (extracts type from :type)
   - OLD [card type="..."] regex for backward compatibility

2. render_card() now handles BOTH button syntaxes:
   - NEW [button:style](url)Text[/button] regex
   - OLD [button url="..."] regex for backward compatibility

3. Card types properly styled with inline CSS:
   - hero: gradient background
   - success: green background + border
   - info: blue background + border
   - warning: yellow background + orange border

4. Buttons rendered with full inline styles + table wrapper
   for Gmail/email client compatibility
2026-01-01 22:27:20 +07:00
Dwindi Ramadhana
1af1add5d4 fix: show reset_link in button URL variable suggestions
Button modals in both RichTextEditor and EmailBuilder filtered
for _url variables only, excluding reset_link. Updated filter to
include both _url and _link patterns.

Files changed:
- rich-text-editor.tsx line 415
- EmailBuilder.tsx line 359
2026-01-01 22:12:26 +07:00
Dwindi Ramadhana
6bd50c1659 fix: button href broken by variable highlighting HTML spans
ROOT CAUSE (from screenshot DevTools):
href="<span style=...>[login_url]</span>" - HTML span inside href attribute!

Flow causing the bug:
1. parseCardsForPreview converts [button url="{login_url}"] to <a href="{login_url}">
2. sampleData replacement runs but login_url NOT in sampleData
3. Variable highlighting injects <span>[login_url]</span> INTO href="..."
4. HTML is completely broken

FIXES APPLIED:
1. Added missing URL variables to sampleData:
   - login_url, reset_link, reset_key
   - user_login, user_email, user_temp_password
   - customer_first_name, customer_last_name

2. Changed variable highlighting from HTML spans to plain text [variable]
   - Prevents breaking HTML attributes if variable is inside href, src, etc.
2026-01-01 22:04:20 +07:00
Dwindi Ramadhana
5a831ddf9d fix: button/card syntax mismatch between blocksToMarkdown and markdownToBlocks
ROOT CAUSE: Complete flow trace revealed syntax mismatch:
- blocksToMarkdown outputs NEW syntax: [card:type], [button:style](url)Text[/button]
- markdownToBlocks ONLY parsed OLD syntax: [card type="..."], [button url="..."]

This caused buttons/cards to be lost when:
1. User adds button in Visual mode
2. blocksToMarkdown converts to [button:solid]({url})Text[/button]
3. handleBlocksChange stores this in markdownContent
4. When switching tabs/previewing, markdownToBlocks runs
5. It FAILED to parse new syntax, buttons disappear!

FIX: Added handlers for NEW syntax in markdownToBlocks (converter.ts):
- [card:type]...[/card] pattern (before old syntax)
- [button:style](url)Text[/button] pattern (before old syntax)

Now both syntaxes work correctly in round-trip conversion.
2026-01-01 21:57:58 +07:00
Dwindi Ramadhana
70006beeb9 fix: button rendering consistency between visual and preview
Root cause: parseCardsForPreview was called TWICE in generatePreviewHTML:
1. Line 179 - correctly parses markdown to HTML including buttons
2. Line 283 - redundantly called AGAIN after variable highlighting

After first call, variable highlighting (lines 275-280) replaced unknown
variables like {login_url} with <span>[login_url]</span>. When the second
parseCardsForPreview ran, the [login_url] text was misinterpreted as
shortcode syntax, corrupting button HTML output.

Fix: Remove the redundant second call to parseCardsForPreview at line 283.
The function is already called at line 179 before any variable replacement.
2026-01-01 21:51:39 +07:00
Dwindi Ramadhana
e84fa969bb fix: button rendering from RichEditor to markdown to HTML
- Added multiple htmlToMarkdown patterns for TipTap button output:
  1. data-button with data-href/data-style attributes
  2. Alternate attribute order (data-style before data-href)
  3. Simple data-button fallback with href and class
  4. Buttons wrapped in p tags (from preview HTML)
  5. Direct button links without p wrapper

- Button shortcodes now correctly roundtrip:
  RichEditor -> HTML -> [button url=... style=...] -> Preview/Email

- All patterns now explicitly include style=solid for consistency
2026-01-01 21:37:55 +07:00
Dwindi Ramadhana
ccdd88a629 fix: template save API + contextual variables per event
1. API Route Fix (NotificationsController.php):
   - Changed PUT to POST for /templates/:eventId/:channelId
   - Frontend was using api.post() but backend only accepted PUT
   - Templates can now be saved

2. Contextual Variables (EventRegistry.php):
   - Added get_variables_for_event() method
   - Returns category-based variables (order, customer, product, etc.)
   - Merges event-specific variables from event definition
   - Sorted alphabetically for easy browsing

3. API Response (NotificationsController.php):
   - Template API now returns available_variables for the event
   - Frontend can show only relevant variables

4. Frontend (EditTemplate.tsx):
   - Removed hardcoded 50+ variable list
   - Now uses template.available_variables from API
   - Variables update based on selected event type
2026-01-01 21:31:10 +07:00
Dwindi Ramadhana
b8f179a984 feat: password reset email event with WooNooW template 2026-01-01 20:54:27 +07:00
Dwindi Ramadhana
78d7bc1161 fix: auto-login after checkout, ThankYou guest buttons, forgot password page
1. Auto-login after checkout:
   - Added wp_set_auth_cookie() and wp_set_current_user() in CheckoutController
   - Auto-registered users are now logged in when thank-you page loads

2. ThankYou page guest buttons:
   - Added 'Login / Create Account' button for guests
   - Shows for both receipt and basic templates
   - No more dead-end after placing order as guest

3. Forgot password flow:
   - Created ForgotPassword page component (/forgot-password route)
   - Added forgot_password API endpoint in AuthController
   - Uses WordPress retrieve_password() for reset email
   - Replaced wp-login.php link in Login page
2026-01-01 17:36:40 +07:00
Dwindi Ramadhana
62f25b624b feat: auto-login after checkout via page reload
Changed Checkout page order success handling:
- Before: SPA navigate() to thank-you page (cookies not refreshed)
- After: window.location.href + reload (cookies refreshed)

This ensures guests who are auto-registered during checkout
get their auth cookies properly set after order placement.
2026-01-01 17:25:19 +07:00
Dwindi Ramadhana
10b3c0e47f feat: Go-to-Account button + wishlist merge on login
1. ThankYou page - Go to Account button:
   - Added for logged-in users (next to Continue Shopping)
   - Shows in both receipt and basic templates
   - Uses outline variant with User icon

2. Wishlist merge on login:
   - Reads guest wishlist from localStorage (woonoow_guest_wishlist)
   - POSTs each product to /account/wishlist API
   - Handles duplicates gracefully (skips on error)
   - Clears localStorage after successful merge
2026-01-01 17:17:12 +07:00
Dwindi Ramadhana
508ec682a7 fix: login page reload + custom logout dialog
1. Login fix:
   - Added window.location.reload() after setting hash URL
   - Forces full page reload to refresh cookies from server
   - Resolves 'Cookie check failed' error after login

2. Logout UX improvement:
   - Created alert-dialog.tsx component (Radix UI AlertDialog)
   - Replaced window.confirm() with custom AlertDialog
   - Dialog shows: title, description, Cancel/Log Out buttons
   - Red 'Log Out' action button for clear intent
2026-01-01 17:08:34 +07:00
Dwindi Ramadhana
c83ea78911 feat: improve login/logout flow in customer SPA
1. Logout flow:
   - Added confirmation dialog (window.confirm)
   - Changed to API-based logout (/auth/logout)
   - Full page reload after logout to clear cookies
   - Added loading state during logout

2. Login flow (already correct):
   - Uses window.location.href for full page redirect
   - Redirects to /store/#/my-account after login
2026-01-01 17:00:11 +07:00
Dwindi Ramadhana
58681e272e feat: temp password in emails + WC page redirects to SPA
1. Temp password for auto-registered users:
   - Store password in _woonoow_temp_password user meta (CheckoutController)
   - Add {user_temp_password} and {login_url} variables (EmailRenderer)
   - Update new_customer email template to show credentials

2. WC page redirects to SPA routes:
   - Added redirect_wc_pages_to_spa() in TemplateOverride
   - Maps: /shop → /store/#/, /cart → /store/#/cart, etc.
   - /checkout → /store/#/checkout, /my-account → /store/#/account
   - Single products → /store/#/products/{slug}

3. Removed shortcode system:
   - Commented out Shortcodes::init() in Bootstrap
   - WC pages now redirect to SPA instead
2026-01-01 16:45:24 +07:00
Dwindi Ramadhana
38a7a4ee23 fix: email variable replacement + icon URL path
1. Added missing base variables in get_variables():
   - site_name, site_title, store_name
   - shop_url, my_account_url
   - support_email, current_year

2. Fixed social icon URL path calculation:
   - Was using 3x dirname which pointed to 'includes/' not plugin root
   - Now uses WOONOOW_URL constant or correct 4x dirname

3. Added px-6 padding to EmailBuilder dialog body

4. Added portal container to Select component for CSS scoping
2026-01-01 02:12:09 +07:00
Dwindi Ramadhana
875ab7af34 fix: dialog portal scope + UX improvements
1. Dialog Portal: Render inside #woonoow-admin-app container instead
   of document.body to fix Tailwind CSS scoping in WordPress admin

2. Variables Panel: Redesigned from flat list to collapsible accordion
   - Collapsed by default (less visual noise)
   - Categorized: Order (blue), Customer (green), Shipping (orange), Store (purple)
   - Color-coded pills for quick recognition
   - Shows count of available variables

3. StarterKit: Disable built-in Link to prevent duplicate extension warning
2026-01-01 01:53:22 +07:00
Dwindi Ramadhana
861c45638b fix: resolve dialog freeze caused by infinite loop in RichTextEditor
The RichTextEditor useEffect was comparing raw content with editor HTML,
but they differed due to whitespace normalization (e.g., '\n\n' vs '').
This caused continuous setContent calls, freezing the edit dialog.

Fixed by normalizing whitespace in both strings before comparison.
2026-01-01 01:19:55 +07:00
Dwindi Ramadhana
8bd2713385 fix: resolve tiptap duplicate Link extension warning
StarterKit 3.10+ now includes Link by default. Our code was adding
Link.configure() separately, causing duplicate extension warning and
breaking the email builder visual editor modal.

Fixed by configuring StarterKit with { link: false } so our custom
Link.configure() with specific options is the only Link extension.
2026-01-01 01:16:47 +07:00
Dwindi Ramadhana
9671c7255a fix: visual editor dialog and password reset flow
1. EmailBuilder: Fixed dialog handlers to not block all interactions
   - Previously dialog prevented all outside clicks
   - Now only blocks when WP media modal is open
   - Dialog can be properly closed via escape or outside click

2. DefaultTemplates: Updated new_customer email
   - Added note about using 'Forgot Password?' if link expires
   - Clear instructions for users
2026-01-01 01:12:08 +07:00
Dwindi Ramadhana
52cea87078 fix: email buttons now render with inline styles for Gmail
1. EmailRenderer: Added button parsing with full inline styles
   - Buttons now use table-based layout for email client compatibility
   - Solid and outline button styles with custom colors from settings

2. DefaultTemplates: Updated new_customer template
   - Added 'Set Your Password' button for auto-registered users
   - Uses {set_password_url} variable for password reset link

3. EmailRenderer: Added set_password_url variable
   - Generates secure password reset link for new customers
   - Also added my_account_url and shop_url to customer variables
2026-01-01 01:06:18 +07:00
Dwindi Ramadhana
e9e54f52a7 fix: update all header login links to use SPA login
- BaseLayout.tsx: Updated 4 guest account links
- Wishlist.tsx: Updated guest wishlist login link
- All now use Link to /login instead of href to /wp-login.php
2025-12-31 22:50:58 +07:00
Dwindi Ramadhana
4fcc69bfcd chore: add input and label UI components for customer-spa 2025-12-31 22:44:35 +07:00
Dwindi Ramadhana
56042d4b8e feat: add customer login page in SPA
- Created Login/index.tsx with styled form
- Added /auth/customer-login API endpoint (no admin perms required)
- Registered route in Routes.php
- Added /login route in customer-spa App.tsx
- Account page now redirects to SPA login instead of wp-login.php
- Login supports redirect param for post-login navigation
2025-12-31 22:43:13 +07:00
Dwindi Ramadhana
3d7eb5bf48 fix: multiple checkout and settings fixes
1. Remove wishlist setting from customer settings (now in module toggle)
   - Removed from CustomerSettingsProvider.php
   - Removed from Customers.tsx

2. Remove auto-login from REST API (causes cookie issues)
   - Auto-login in REST context doesn't properly set browser cookies
   - Removed wp_set_current_user/wp_set_auth_cookie calls

3. Fix cart not clearing after order
   - Added WC()->cart->empty_cart() after successful order
   - Server-side cart was not being cleared, causing re-population
   - Frontend clears local store but Cart page syncs with server
2025-12-31 22:29:59 +07:00
Dwindi Ramadhana
f97cca8061 fix: properly clear cart after order placement
- Use clearCart() from store instead of iterating removeItem()
- Iteration could fail as items are removed during loop
- clearCart() resets cart to initial state atomically
2025-12-31 22:18:06 +07:00
Dwindi Ramadhana
f79938c5be feat: auto-login newly registered customers after checkout
- After creating new user account, immediately log them in
- Uses wp_set_current_user() and wp_set_auth_cookie()
- Provides smoother UX - customer is logged in after placing order
2025-12-31 22:06:57 +07:00
Dwindi Ramadhana
0dd7c7af70 fix: make module settings GET endpoint public
- Shop page and other customer pages need to read module settings
- Settings are non-sensitive configuration values (e.g. wishlist display)
- POST endpoint remains admin-only for security
- Fixes 401 errors on shop page for /modules/wishlist/settings
2025-12-31 22:01:06 +07:00
Dwindi Ramadhana
285589937a feat: add auto-register to CheckoutController for guest checkout
- When 'Auto-register customers as site members' is enabled
- Creates WP user account with 'customer' role for guest checkouts
- Links order to existing user if email already registered
- Sets WooCommerce customer billing data on new account
- Triggers woocommerce_created_customer action for email notification
2025-12-31 21:55:18 +07:00
Dwindi Ramadhana
a87357d890 fix: thank you page 401 error
- Add public /checkout/order/{id} endpoint with order_key validation
- Update checkout redirect to include order_key parameter
- Update ThankYou page to use new public endpoint with key
- Support both guest (via key) and logged-in (via customer_id) access
2025-12-31 21:42:40 +07:00
Dwindi Ramadhana
d7505252ac feat: complete Newsletter Campaigns Phase 1
- Add default campaign email template to DefaultTemplates.php
- Add toggle settings (campaign_scheduling, subscriber_limit_enabled)
- Add public unsubscribe endpoint with secure token verification
- Update CampaignManager to use NewsletterController unsubscribe URLs
- Add generate_unsubscribe_url() helper for email templates
2025-12-31 21:17:59 +07:00
Dwindi Ramadhana
3d5191aab3 feat: add Newsletter Campaigns frontend UI
- Add Campaigns list page with table, status badges, search, actions
- Add Campaign editor with title, subject, content fields
- Add preview modal, test email dialog, send confirmation
- Update Marketing index to show hub with Newsletter, Campaigns, Coupons cards
- Add routes in App.tsx
2025-12-31 18:59:49 +07:00
Dwindi Ramadhana
65dd847a66 feat: add Newsletter Campaigns backend infrastructure
- Add CampaignManager.php with CPT registration, CRUD, batch sending
- Add CampaignsController.php with 8 REST endpoints (list, create, get, update, delete, send, test, preview)
- Register newsletter_campaign event in EventRegistry for email template
- Initialize CampaignManager in Bootstrap.php
- Register routes in Routes.php
2025-12-31 14:58:57 +07:00
Dwindi Ramadhana
2dbc43a4eb fix: simplify More page - Marketing as simple button without submenu
- Remove inline submenu expansion for Marketing
- Keep it consistent with Appearance and Settings (simple buttons)
- Description provides enough context about what's inside
2025-12-31 14:27:06 +07:00
Dwindi Ramadhana
771c48e4bb fix: align mobile bottom bar with desktop nav structure
- Add Marketing section to More page with Newsletter and Coupons submenu
- Remove standalone Coupons entry (now under Marketing)
- Add submenu rendering support for items with children
- Use Megaphone icon for Marketing section
2025-12-31 14:19:08 +07:00
Dwindi Ramadhana
4104c6d6ba docs: update Feature Roadmap with accurate module statuses
- Module 1 (Module Management): Changed from Planning to Built
- Added Module Management to 'Already Built' section
- Marked Product Reviews as not yet implemented
- Updated last modified date
2025-12-31 14:09:38 +07:00
Dwindi Ramadhana
82399d4ddf fix: WP-Admin CSS conflicts and add-to-cart redirect
- Fix CSS conflicts between WP-Admin and SPA (radio buttons, chart text)
- Add Tailwind important selector scoped to #woonoow-admin-app
- Remove overly aggressive inline SVG styles from Assets.php
- Add targeted WordPress admin CSS overrides in index.css
- Fix add-to-cart redirect to use woocommerce_add_to_cart_redirect filter
- Let WooCommerce handle cart operations natively for proper session management
- Remove duplicate tailwind.config.cjs
2025-12-31 14:06:04 +07:00
Dwindi Ramadhana
93523a74ac feat: Add-to-cart from URL parameters
Implements direct-to-cart functionality for landing page CTAs.

Features:
- Parse URL parameters: ?add-to-cart=123
- Support simple products: ?add-to-cart=123
- Support variable products: ?add-to-cart=123&variation_id=456
- Support quantity: ?add-to-cart=123&quantity=2
- Auto-navigate to cart after adding
- Clean URL after adding (remove parameters)
- Toast notification on success/error

Usage examples:
1. Simple product:
   https://site.com/store?add-to-cart=332

2. Variable product:
   https://site.com/store?add-to-cart=332&variation_id=456

3. With quantity:
   https://site.com/store?add-to-cart=332&quantity=3

Flow:
- User clicks CTA on landing page
- Redirects to SPA with add-to-cart parameter
- SPA loads, hook detects parameter
- Adds product to cart via API
- Navigates to cart page
- Shows success toast

Works with both SPA modes:
- Full SPA: loads shop, adds to cart, navigates to cart
- Checkout Only: loads cart, adds to cart, stays on cart
2025-12-30 20:54:54 +07:00
Dwindi Ramadhana
2c4050451c feat: Customer SPA reads initial route from data attribute
Changes:
- App.tsx reads data-initial-route attribute from #woonoow-customer-app
- Root route (/) redirects to initial route based on SPA mode
- Fallback route (*) also redirects to initial route
- Full SPA mode: initial route = /shop
- Checkout Only mode: initial route = /cart

Flow:
1. User visits /store page (SPA entry page)
2. PHP template sets data-initial-route based on spa_mode setting
3. React reads attribute and navigates to correct initial route
4. HashRouter handles rest: /#/product/123, /#/checkout, etc.

Example:
- Full SPA: /store loads → redirects to /#/shop
- Checkout Only: /store loads → redirects to /#/cart
- User can navigate: /#/product/abc, /#/checkout, etc.

Next: Add direct-to-cart functionality with product parameter
2025-12-30 20:34:40 +07:00
Dwindi Ramadhana
fe98e6233d refactor: Simplify to single SPA entry page architecture
User feedback: 'SPA means Single Page, why 4 pages?'

Correct architecture:
- 1 SPA entry page (e.g., /store)
- SPA Mode determines initial route:
  * Full SPA → starts at shop page
  * Checkout Only → starts at cart page
  * Disabled → never loads
- React Router handles rest via /#/ routing

Changes:
- Admin UI: Changed from 4 page selectors to 1 SPA entry page
- Backend: spa_pages array → spa_page integer
- Template: Initial route based on spa_mode setting
- Simplified is_spa_page() checks (single ID comparison)

Benefits:
- User can set /store as homepage (Settings → Reading)
- Landing page → CTA → direct to cart/checkout
- Clean single entry point
- Mode controls behavior, not multiple pages

Example flow:
- Visit https://site.com/store
- Full SPA: loads shop, navigate via /#/product/123
- Checkout Only: loads cart, navigate via /#/checkout
- Homepage: set /store as homepage, SPA loads on site root

Next: Add direct-to-cart CTA with product parameter
2025-12-30 20:33:15 +07:00
Dwindi Ramadhana
f054a78c5d feat: Add SPA page selection UI in admin
Complete WooCommerce-style page architecture implementation:

Backend (already committed):
- API endpoint to fetch WordPress pages
- spa_pages field in appearance settings
- is_spa_page() checks in TemplateOverride and Assets

Frontend (this commit):
- Added page selector UI in Appearance > General
- Dropdowns for Shop, Cart, Checkout, Account pages
- Loads available WordPress pages from API
- Saves selected page IDs to settings
- Info alert explaining full-body rendering

UI Features:
- Clean page selection interface
- Shows all published WordPress pages
- '— None —' option to disable
- Integrated into existing General settings tab
- Follows existing design patterns

How it works:
1. Admin selects pages in Appearance > General
2. Page IDs saved to woonoow_appearance_settings
3. Frontend checks if current page matches selected pages
4. If match, renders full SPA to body (no theme interference)
5. Works with ANY theme consistently

Next: Test page selection and verify clean SPA rendering
2025-12-30 20:19:46 +07:00
Dwindi Ramadhana
012effd11d feat: Add dedicated SPA page selection (WooCommerce-style)
Problem: Shortcode 'island' architecture is fragile and theme-dependent
- SPA div buried deep in theme structure (body > div.wp-site-blocks > main > div#app)
- Theme and plugins can intervene at any level
- Different themes have different structures
- Breaks easily with theme changes

Solution: Dedicated page-based SPA system (like WooCommerce)
- Add page selection in Appearance > General settings
- Store page IDs for Shop, Cart, Checkout, Account
- Full-body SPA rendering on designated pages
- No theme interference

Changes:
- AppearanceController.php:
  * Added spa_pages field to general settings
  * Stores page IDs for each SPA type (shop/cart/checkout/account)

- TemplateOverride.php:
  * Added is_spa_page() method to check designated pages
  * Use blank template for designated pages (priority over legacy)
  * Remove theme elements for designated pages

- Assets.php:
  * Added is_spa_page() check before mode/shortcode checks
  * Load assets on designated pages regardless of mode

Architecture:
- Designated pages render directly to <body>
- No theme wrapper/structure interference
- Clean full-page SPA experience
- Works with ANY theme consistently

Next: Add UI in admin-spa General tab for page selection
2025-12-30 19:42:16 +07:00
Dwindi Ramadhana
48a5a5593b fix: Include fonts in production build and strengthen theme override
Problem 1: Fonts not loading (404 errors)
Root Cause: Build script only copied app.js and app.css, not fonts folder
Solution: Include fonts directory in production build

Problem 2: Theme header/footer still showing on some themes
Root Cause: Header/footer removal only worked in 'full' mode, not for shortcode pages
Solution:
- Use blank template (spa-full-page.php) for ANY page with WooNooW shortcodes
- Remove theme elements for shortcode pages even in 'disabled' mode
- Stronger detection for Shop page (archive) shortcode check

Changes:
- build-production.sh: Copy fonts folder if exists
- TemplateOverride.php:
  * use_spa_template() now checks for shortcodes in disabled mode
  * should_remove_theme_elements() removes for shortcode pages
  * Added Shop page archive check for shortcode detection

Result:
 Fonts now included in production build (~500KB added)
 Theme header/footer removed on ALL shortcode pages
 Works with any theme (Astra, Twenty Twenty-Three, etc.)
 Clean SPA experience regardless of SPA mode setting
 Package size: 2.1M (was 1.6M, +500KB for fonts)
2025-12-30 19:34:39 +07:00
Dwindi Ramadhana
e0777c708b fix: Remove theme header and footer in Full SPA mode
Problem: Duplicate headers and footers showing (theme + SPA)
Root Cause: Theme's header and footer still rendering when Full SPA mode is active

Solution: Remove theme header/footer elements when on WooCommerce pages in Full SPA mode
- Hook into get_header and get_footer actions
- Remove all theme header/footer actions
- Keep only essential WordPress head/footer scripts
- Only applies when mode='full' and on WooCommerce pages

Changes:
- Added remove_theme_header() method
- Added remove_theme_footer() method
- Added should_remove_theme_elements() check
- Hooks into get_header and get_footer

Result:
 Clean SPA experience without theme header/footer
 Essential WordPress scripts still load
 Only affects Full SPA mode on WooCommerce pages
 Other pages keep theme header/footer
2025-12-30 18:10:29 +07:00
Dwindi Ramadhana
b2ac2996f9 fix: Detect shortcode on WooCommerce Shop page correctly
Problem: Customer SPA not loading on Shop page despite having [woonoow_shop] shortcode
Root Cause: WooCommerce Shop page is an archive page - when visiting /shop/, WordPress sets $post to the first product in the loop, not the Shop page itself. So shortcode check was checking product content instead of Shop page content.

Solution: Add special handling for is_shop() - get Shop page content directly using woocommerce_shop_page_id option and check for shortcode there.

Changes:
- Check is_shop() first before checking $post content
- Get Shop page via get_option('woocommerce_shop_page_id')
- Check shortcode on actual Shop page content
- Falls back to regular $post check for other pages

Result:
 Shop page shortcode detection now works correctly
 Customer SPA will load on Shop page with [woonoow_shop] shortcode
 Other WooCommerce pages (Cart, Checkout, Account) still work
2025-12-30 18:02:48 +07:00
Dwindi Ramadhana
c8ce892d15 debug: Add Shop page diagnostic script for live site troubleshooting 2025-12-30 17:59:49 +07:00
Dwindi Ramadhana
b6a0a66000 fix: Add SPA mounting point for full mode
Problem: Customer SPA not loading in 'full' mode
Root Cause: In full mode, SPA loads on WooCommerce pages without shortcodes, so there's no #woonoow-customer-app div for React to mount to

Solution: Inject mounting point div when in full mode via woocommerce_before_main_content hook

Changes:
- Added inject_spa_mount_point() method
- Hooks into woocommerce_before_main_content when in full mode
- Only injects if mount point doesn't exist from shortcode

Result:
 Full mode now has mounting point on WooCommerce pages
 Shortcode mode still works with shortcode-provided divs
 Customer SPA can now initialize properly
2025-12-30 17:54:12 +07:00
Dwindi Ramadhana
3260c8c112 debug: Add detailed logging to customer SPA asset loading
Added comprehensive logging to track:
- should_load_assets() decision flow
- SPA mode setting
- Post ID and content
- Shortcode detection
- Asset enqueue URLs
- Dev vs production mode

This will help identify why customer SPA is not loading.
2025-12-30 17:51:04 +07:00
71 changed files with 6992 additions and 1808 deletions

View File

@@ -1,7 +1,7 @@
# WooNooW Feature Roadmap - 2025
**Last Updated**: December 26, 2025
**Status**: Planning Phase
**Last Updated**: December 31, 2025
**Status**: Active Development
This document outlines the comprehensive feature roadmap for WooNooW, building upon existing infrastructure.
@@ -22,11 +22,12 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
- ✅ Newsletter Subscribers Management
- ✅ Coupon System
- ✅ Customer Wishlist (basic)
-Product Reviews & Ratings
-Module Management System (enable/disable features)
- ✅ Admin SPA with modern UI
- ✅ Customer SPA with theme system
- ✅ REST API infrastructure
- ✅ Addon bridge pattern
- 🔲 Product Reviews & Ratings (not yet implemented)
---
@@ -35,7 +36,7 @@ This document outlines the comprehensive feature roadmap for WooNooW, building u
### Overview
Central control panel for enabling/disabling features to improve performance and reduce clutter.
### Status: **Planning** 🔵
### Status: **Built**
### Implementation
@@ -94,8 +95,8 @@ class ModuleRegistry {
#### Navigation Integration
Only show module routes if enabled in navigation tree.
### Priority: **High** 🔴
### Effort: 1 week
### Priority: ~~High~~ **Complete**
### Effort: ~~1 week~~ Done
---

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
import { Login } from './routes/Login';
import ResetPassword from './routes/ResetPassword';
import Dashboard from '@/routes/Dashboard';
import DashboardRevenue from '@/routes/Dashboard/Revenue';
import DashboardOrders from '@/routes/Dashboard/Orders';
@@ -257,6 +258,8 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account';
import MarketingIndex from '@/routes/Marketing';
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
import CampaignsList from '@/routes/Marketing/Campaigns';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
import MorePage from '@/routes/More';
// Addon Route Component - Dynamically loads addon components
@@ -499,6 +502,7 @@ function AppRoutes() {
<Routes>
{/* Dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
<Route path="/dashboard/orders" element={<DashboardOrders />} />
@@ -576,6 +580,8 @@ function AppRoutes() {
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
<Route path="/marketing/campaigns" element={<CampaignsList />} />
<Route path="/marketing/campaigns/:id" element={<CampaignEdit />} />
{/* Dynamic Addon Routes */}
{addonRoutes.map((route: any) => (

View File

@@ -101,11 +101,13 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
};
const openEditDialog = (block: EmailBlock) => {
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
setEditingBlockId(block.id);
if (block.type === 'card') {
// Convert markdown to HTML for rich text editor
const htmlContent = parseMarkdownBasics(block.content);
console.log('[EmailBuilder] Card content parsed', { original: block.content, html: htmlContent });
setEditingContent(htmlContent);
setEditingCardType(block.cardType);
} else if (block.type === 'button') {
@@ -122,6 +124,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
setEditingAlign(block.align);
}
console.log('[EmailBuilder] Setting editDialogOpen to true');
setEditDialogOpen(true);
};
@@ -270,28 +273,22 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
{/* Edit Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent
className="sm:max-w-2xl"
className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"
onInteractOutside={(e) => {
// Check if WordPress media modal is currently open
// Only prevent closing if WordPress media modal is open
const wpMediaOpen = document.querySelector('.media-modal');
if (wpMediaOpen) {
// If WP media is open, ALWAYS prevent dialog from closing
// regardless of where the click happened
e.preventDefault();
return;
}
// If WP media is not open, prevent closing dialog for outside clicks
e.preventDefault();
// Otherwise, allow the dialog to close normally via outside click
}}
onEscapeKeyDown={(e) => {
// Allow escape to close WP media modal
// Only prevent escape if WP media modal is open
const wpMediaOpen = document.querySelector('.media-modal');
if (wpMediaOpen) {
return;
}
e.preventDefault();
}
// Otherwise, allow escape to close dialog
}}
>
<DialogHeader>
@@ -305,7 +302,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 px-6 py-4">
{editingBlock?.type === 'card' && (
<>
<div className="space-y-2">
@@ -359,7 +356,7 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.filter(v => v.includes('_url')).map((variable) => (
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"

View File

@@ -320,7 +320,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
const id = `block-${Date.now()}-${blockId++}`;
// Check for [card] blocks - match with proper boundaries
// Check for [card] blocks - NEW syntax [card:type]...[/card]
const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/);
if (newCardMatch) {
const cardType = newCardMatch[1] as CardType;
const content = newCardMatch[2].trim();
blocks.push({
id,
type: 'card',
cardType,
content,
});
remaining = remaining.substring(newCardMatch[0].length);
continue;
}
// Check for [card] blocks - OLD syntax [card type="..."]...[/card]
const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
if (cardMatch) {
const attributes = cardMatch[1].trim();
@@ -347,7 +364,24 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
continue;
}
// Check for [button] blocks
// Check for [button] blocks - NEW syntax [button:style](url)Text[/button]
const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
if (newButtonMatch) {
blocks.push({
id,
type: 'button',
text: newButtonMatch[3].trim(),
link: newButtonMatch[2],
style: newButtonMatch[1] as ButtonStyle,
align: 'center',
widthMode: 'fit',
});
remaining = remaining.substring(newButtonMatch[0].length);
continue;
}
// Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button]
const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
if (buttonMatch) {
blocks.push({

View File

@@ -30,25 +30,43 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
>(({ className, children, ...props }, ref) => {
// Get or create portal container inside the app for proper CSS scoping
const getPortalContainer = () => {
const appContainer = document.getElementById('woonoow-admin-app');
if (!appContainer) return document.body;
let portalRoot = document.getElementById('woonoow-dialog-portal');
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-dialog-portal';
appContainer.appendChild(portalRoot);
}
return portalRoot;
};
return (
<DialogPortal container={getPortalContainer()}>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
className={cn(
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[99999] flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground z-10">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
);
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -57,7 +75,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"flex flex-col space-y-1.5 text-center sm:text-left px-6 pt-6 pb-4 border-b",
className
)}
{...props}
@@ -71,7 +89,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t mt-auto",
className
)}
{...props}
@@ -106,6 +124,20 @@ const DialogDescription = React.forwardRef<
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
const DialogBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex-1 overflow-y-auto px-6 py-4",
className
)}
{...props}
/>
)
DialogBody.displayName = "DialogBody"
export {
Dialog,
DialogPortal,
@@ -117,4 +149,5 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
DialogBody,
}

View File

@@ -25,7 +25,7 @@ import { Button } from './button';
import { Input } from './input';
import { Label } from './label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogBody } from './dialog';
import { __ } from '@/lib/i18n';
interface RichTextEditorProps {
@@ -45,7 +45,8 @@ export function RichTextEditor({
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
// StarterKit 3.10+ includes Link by default, disable since we configure separately
StarterKit.configure({ link: false }),
Placeholder.configure({
placeholder,
}),
@@ -75,14 +76,6 @@ export function RichTextEditor({
class:
'prose prose-sm max-w-none focus:outline-none min-h-[200px] px-4 py-3 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-3 [&_h2]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-2 [&_h3]:mb-1 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-2 [&_h4]:mb-1',
},
handleClick: (view, pos, event) => {
const target = event.target as HTMLElement;
if (target.tagName === 'A' || target.closest('a')) {
event.preventDefault();
return true;
}
return false;
},
},
});
@@ -120,6 +113,8 @@ export function RichTextEditor({
const [buttonText, setButtonText] = useState('Click Here');
const [buttonHref, setButtonHref] = useState('{order_url}');
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
const [isEditingButton, setIsEditingButton] = useState(false);
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
const addImage = () => {
openWPMediaImage((file) => {
@@ -135,12 +130,81 @@ export function RichTextEditor({
setButtonText('Click Here');
setButtonHref('{order_url}');
setButtonStyle('solid');
setIsEditingButton(false);
setEditingButtonPos(null);
setButtonDialogOpen(true);
};
// Handle clicking on buttons in the editor to edit them
const handleEditorClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const buttonEl = target.closest('a[data-button]') as HTMLElement | null;
if (buttonEl && editor) {
e.preventDefault();
e.stopPropagation();
// Get button attributes
const text = buttonEl.getAttribute('data-text') || buttonEl.textContent?.replace('🔘 ', '') || 'Click Here';
const href = buttonEl.getAttribute('data-href') || '#';
const style = (buttonEl.getAttribute('data-style') as 'solid' | 'outline') || 'solid';
// Find the position of this button node
const { state } = editor.view;
let foundPos: number | null = null;
state.doc.descendants((node, pos) => {
if (node.type.name === 'button' &&
node.attrs.text === text &&
node.attrs.href === href) {
foundPos = pos;
return false; // Stop iteration
}
return true;
});
// Open dialog in edit mode
setButtonText(text);
setButtonHref(href);
setButtonStyle(style);
setIsEditingButton(true);
setEditingButtonPos(foundPos);
setButtonDialogOpen(true);
}
};
const insertButton = () => {
if (isEditingButton && editingButtonPos !== null && editor) {
// Delete old button and insert new one at same position
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.insertContentAt(editingButtonPos, {
type: 'button',
attrs: { text: buttonText, href: buttonHref, style: buttonStyle },
})
.run();
} else {
// Insert new button
editor.chain().focus().setButton({ text: buttonText, href: buttonHref, style: buttonStyle }).run();
}
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
};
const deleteButton = () => {
if (editingButtonPos !== null && editor) {
editor
.chain()
.focus()
.deleteRange({ from: editingButtonPos, to: editingButtonPos + 1 })
.run();
setButtonDialogOpen(false);
setIsEditingButton(false);
setEditingButtonPos(null);
}
};
const getActiveHeading = () => {
@@ -292,44 +356,115 @@ export function RichTextEditor({
</div>
{/* Editor */}
<div className="overflow-y-auto max-h-[400px] min-h-[200px]">
<div onClick={handleEditorClick}>
<EditorContent editor={editor} />
</div>
{/* Variables Dropdown */}
{/* Variables - Collapsible and Categorized */}
{variables.length > 0 && (
<div className="border-t bg-muted/30 p-3">
<div className="flex items-center gap-2">
<Label htmlFor="variable-select" className="text-xs text-muted-foreground whitespace-nowrap">
{__('Insert Variable:')}
</Label>
<Select onValueChange={(value) => insertVariable(value)}>
<SelectTrigger id="variable-select" className="h-8 text-xs">
<SelectValue placeholder={__('Choose a variable...')} />
</SelectTrigger>
<SelectContent>
{variables.map((variable) => (
<SelectItem key={variable} value={variable} className="text-xs">
<details className="border-t bg-muted/30">
<summary className="p-3 text-xs text-muted-foreground cursor-pointer hover:bg-muted/50 flex items-center gap-2 select-none">
<span className="text-[10px]"></span>
{__('Insert Variable')}
<span className="text-[10px] opacity-60">({variables.length})</span>
</summary>
<div className="p-3 pt-0 space-y-3">
{/* Order Variables */}
{variables.some(v => v.startsWith('order')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Order')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('order')).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors"
>
{`{${variable}}`}
</SelectItem>
</button>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Customer Variables */}
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-green-50 text-green-700 rounded hover:bg-green-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{/* Shipping/Payment Variables */}
{variables.some(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Shipping & Payment')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('shipping') || v.startsWith('payment') || v.startsWith('tracking')).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded hover:bg-orange-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{/* Store/Site Variables */}
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
<div>
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
<div className="flex flex-wrap gap-1">
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(variable)}
className="text-[11px] px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
</div>
</details>
)}
{/* Button Dialog */}
<Dialog open={buttonDialogOpen} onOpenChange={setButtonDialogOpen}>
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
<Dialog open={buttonDialogOpen} onOpenChange={(open) => {
setButtonDialogOpen(open);
if (!open) {
setIsEditingButton(false);
setEditingButtonPos(null);
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Insert Button')}</DialogTitle>
<DialogTitle>{isEditingButton ? __('Edit Button') : __('Insert Button')}</DialogTitle>
<DialogDescription>
{__('Add a styled button to your content. Use variables for dynamic links.')}
{isEditingButton
? __('Edit the button properties below. Click on the button to save.')
: __('Add a styled button to your content. Use variables for dynamic links.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody>
<div className="space-y-4 !p-4">
<div className="space-y-2">
<Label htmlFor="btn-text">{__('Button Text')}</Label>
<Input
@@ -350,7 +485,7 @@ export function RichTextEditor({
/>
{variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{variables.filter(v => v.includes('_url')).map((variable) => (
{variables.filter(v => v.includes('_url') || v.includes('_link')).map((variable) => (
<code
key={variable}
className="text-xs bg-muted px-2 py-1 rounded cursor-pointer hover:bg-muted/80"
@@ -376,13 +511,19 @@ export function RichTextEditor({
</Select>
</div>
</div>
</DialogBody>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-2">
{isEditingButton && (
<Button variant="destructive" onClick={deleteButton} className="sm:mr-auto">
{__('Delete')}
</Button>
)}
<Button variant="outline" onClick={() => setButtonDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={insertButton}>
{__('Insert Button')}
{isEditingButton ? __('Update Button') : __('Insert Button')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -69,8 +69,23 @@ SelectScrollDownButton.displayName =
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
>(({ className, children, position = "popper", ...props }, ref) => {
// Get or create portal container inside the app for proper CSS scoping
const getPortalContainer = () => {
const appContainer = document.getElementById('woonoow-admin-app');
if (!appContainer) return document.body;
let portalRoot = document.getElementById('woonoow-select-portal');
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-select-portal';
appContainer.appendChild(portalRoot);
}
return portalRoot;
};
return (
<SelectPrimitive.Portal container={getPortalContainer()}>
<SelectPrimitive.Content
ref={ref}
className={cn(
@@ -95,7 +110,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
);
})
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<

View File

@@ -37,54 +37,50 @@ export const ButtonExtension = Node.create<ButtonOptions>({
parseHTML() {
return [
{
tag: 'a[data-button]',
getAttrs: (node: HTMLElement) => ({
text: node.getAttribute('data-text') || node.textContent || 'Click Here',
href: node.getAttribute('data-href') || node.getAttribute('href') || '#',
style: node.getAttribute('data-style') || 'solid',
}),
},
{
tag: 'a.button',
getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here',
href: node.getAttribute('href') || '#',
style: 'solid',
}),
},
{
tag: 'a.button-outline',
getAttrs: (node: HTMLElement) => ({
text: node.textContent || 'Click Here',
href: node.getAttribute('href') || '#',
style: 'outline',
}),
},
];
},
renderHTML({ HTMLAttributes }) {
const { text, href, style } = HTMLAttributes;
const className = style === 'outline' ? 'button-outline' : 'button';
const buttonStyle: Record<string, string> = style === 'solid'
? {
display: 'inline-block',
background: '#7f54b3',
color: '#fff',
padding: '14px 28px',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
}
: {
display: 'inline-block',
background: 'transparent',
color: '#7f54b3',
padding: '12px 26px',
border: '2px solid #7f54b3',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: '600',
cursor: 'pointer',
};
// Simple link styling - no fancy button appearance in editor
// The actual button styling happens in email rendering (EmailRenderer.php)
// In editor, just show as a styled link (differentiable from regular links)
return [
'a',
mergeAttributes(this.options.HTMLAttributes, {
href,
class: className,
style: Object.entries(buttonStyle)
.map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
.join('; '),
class: 'button-node',
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
'data-button': '',
'data-text': text,
'data-href': href,
'data-style': style,
title: `Button: ${text}${href}`,
}),
text,
];

View File

@@ -76,6 +76,43 @@
}
}
/* ============================================
WordPress Admin Override Fixes
These rules use high specificity + !important
to override WordPress admin CSS conflicts
============================================ */
/* Fix SVG icon styling - WordPress sets fill:currentColor on all SVGs */
#woonoow-admin-app svg {
fill: none !important;
}
/* But allow explicit fill-current class to work for filled icons */
#woonoow-admin-app svg.fill-current,
#woonoow-admin-app .fill-current svg,
#woonoow-admin-app [class*="fill-"] svg {
fill: currentColor !important;
}
/* Fix radio button indicator - WordPress overrides circle fill */
#woonoow-admin-app [data-radix-radio-group-item] svg,
#woonoow-admin-app [role="radio"] svg {
fill: currentColor !important;
}
/* Fix font-weight inheritance - prevent WordPress bold overrides */
#woonoow-admin-app text,
#woonoow-admin-app tspan {
font-weight: inherit !important;
}
/* Reset form element styling that WordPress overrides */
#woonoow-admin-app input[type="radio"],
#woonoow-admin-app input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
}
/* Command palette input: remove native borders/shadows to match shadcn */
.command-palette-search {
border: none !important;

View File

@@ -22,7 +22,33 @@ export function htmlToMarkdown(html: string): string {
markdown = markdown.replace(/<em>(.*?)<\/em>/gi, '*$1*');
markdown = markdown.replace(/<i>(.*?)<\/i>/gi, '*$1*');
// Links
// TipTap buttons - detect by data-button attribute, BEFORE generic links
// Format: <a data-button data-style="solid" data-href="..." data-text="...">text</a>
// or: <a href="..." class="button..." data-button ...>text</a>
markdown = markdown.replace(/<a[^>]*data-button[^>]*>(.*?)<\/a>/gi, (match, text) => {
// Extract style from data-style or class
let style = 'solid';
const styleMatch = match.match(/data-style=["'](\w+)["']/);
if (styleMatch) {
style = styleMatch[1];
} else if (match.includes('button-outline') || match.includes('outline')) {
style = 'outline';
}
// Extract href from data-href or href attribute
let url = '#';
const dataHrefMatch = match.match(/data-href=["']([^"']+)["']/);
const hrefMatch = match.match(/href=["']([^"']+)["']/);
if (dataHrefMatch) {
url = dataHrefMatch[1];
} else if (hrefMatch) {
url = hrefMatch[1];
}
return `[button:${style}](${url})${text.trim()}[/button]`;
});
// Regular links (not buttons)
markdown = markdown.replace(/<a\s+href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Lists

View File

@@ -98,13 +98,13 @@ export function markdownToHtml(markdown: string): string {
// Parse [button:style](url)Text[/button] (new syntax)
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
});
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
});
// Parse remaining markdown
@@ -151,15 +151,20 @@ export function parseMarkdownBasics(text: string): string {
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
// Allow whitespace and newlines between parts
// Include data-button attributes for TipTap recognition
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
const trimmedText = text.trim();
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
});
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
// Include data-button attributes for TipTap recognition
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
const buttonStyle = style || 'solid';
const buttonClass = buttonStyle === 'outline' ? 'button-outline' : 'button';
const trimmedText = text.trim();
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${buttonStyle}">${trimmedText}</a>`;
});
// Images (must come before links)
@@ -267,8 +272,33 @@ export function htmlToMarkdown(html: string): string {
});
// Convert buttons back to [button] syntax
// TipTap button format with data attributes: <a data-button data-href="..." data-style="..." data-text="...">text</a>
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-href="([^"]+)"[^>]*data-style="([^"]*)"[^>]*>([^<]+)<\/a>/gi, (match, url, style, text) => {
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
});
// Alternate order: data-style before data-href
markdown = markdown.replace(/<a[^>]*data-button[^>]*data-style="([^"]*)"[^>]*data-href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, (match, style, url, text) => {
const styleAttr = style === 'outline' ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${styleAttr}]${text.trim()}[/button]`;
});
// Simple data-button fallback (just has href and class)
markdown = markdown.replace(/<a[^>]*href="([^"]+)"[^>]*class="(button[^"]*)"[^>]*data-button[^>]*>([^<]+)<\/a>/gi, (match, url, className, text) => {
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${style}]${text.trim()}[/button]`;
});
// Buttons wrapped in p tags (from preview HTML): <p><a href="..." class="button...">text</a></p>
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
const style = className.includes('outline') ? ' style="outline"' : '';
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${style}]${text.trim()}[/button]`;
});
// Direct button links without p wrapper
markdown = markdown.replace(/<a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a>/g, (match, url, className, text) => {
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${style}]${text.trim()}[/button]`;
});

View File

@@ -12,9 +12,17 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { toast } from 'sonner';
import { api } from '@/lib/api';
interface WordPressPage {
id: number;
title: string;
slug: string;
}
export default function AppearanceGeneral() {
const [loading, setLoading] = useState(true);
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
const [spaPage, setSpaPage] = useState(0);
const [availablePages, setAvailablePages] = useState<WordPressPage[]>([]);
const [toastPosition, setToastPosition] = useState('top-right');
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
const [predefinedPair, setPredefinedPair] = useState('modern');
@@ -40,11 +48,13 @@ export default function AppearanceGeneral() {
useEffect(() => {
const loadSettings = async () => {
try {
// Load appearance settings
const response = await api.get('/appearance/settings');
const general = response.data?.general;
if (general) {
if (general.spa_mode) setSpaMode(general.spa_mode);
if (general.spa_page) setSpaPage(general.spa_page || 0);
if (general.toast_position) setToastPosition(general.toast_position);
if (general.typography) {
setTypographyMode(general.typography.mode || 'predefined');
@@ -63,8 +73,19 @@ export default function AppearanceGeneral() {
});
}
}
// Load available pages
const pagesResponse = await api.get('/pages/list');
console.log('Pages API response:', pagesResponse);
if (pagesResponse.data) {
console.log('Pages loaded:', pagesResponse.data);
setAvailablePages(pagesResponse.data);
} else {
console.warn('No pages data in response:', pagesResponse);
}
} catch (error) {
console.error('Failed to load settings:', error);
console.error('Error details:', error);
} finally {
setLoading(false);
}
@@ -76,7 +97,8 @@ export default function AppearanceGeneral() {
const handleSave = async () => {
try {
await api.post('/appearance/general', {
spa_mode: spaMode,
spaMode,
spaPage,
toastPosition,
typography: {
mode: typographyMode,
@@ -113,7 +135,7 @@ export default function AppearanceGeneral() {
Disabled
</Label>
<p className="text-sm text-muted-foreground">
Use WordPress default pages (no SPA functionality)
SPA never loads (use WordPress default pages)
</p>
</div>
</div>
@@ -125,7 +147,7 @@ export default function AppearanceGeneral() {
Checkout Only
</Label>
<p className="text-sm text-muted-foreground">
SPA for checkout flow only (cart, checkout, thank you)
SPA starts at cart page (cart checkout thank you account)
</p>
</div>
</div>
@@ -137,13 +159,53 @@ export default function AppearanceGeneral() {
Full SPA
</Label>
<p className="text-sm text-muted-foreground">
Entire customer-facing site uses SPA (recommended)
SPA starts at shop page (shop product cart checkout account)
</p>
</div>
</div>
</RadioGroup>
</SettingsCard>
{/* SPA Page */}
<SettingsCard
title="SPA Page"
description="Select the page where the SPA will load (e.g., /store)"
>
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This page will render the full SPA to the body element with no theme interference.
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
</AlertDescription>
</Alert>
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
<Select
value={spaPage.toString()}
onValueChange={(value) => setSpaPage(parseInt(value))}
>
<SelectTrigger id="spa-page">
<SelectValue placeholder="Select a page..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"> None </SelectItem>
{availablePages.map((page) => (
<SelectItem key={page.id} value={page.id.toString()}>
{page.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
<strong>Full SPA:</strong> Loads shop page initially<br />
<strong>Checkout Only:</strong> Loads cart page initially<br />
<strong>Tip:</strong> You can set this page as your homepage in Settings Reading
</p>
</SettingsSection>
</div>
</SettingsCard>
{/* Toast Notifications */}
<SettingsCard
title="Toast Notifications"

View File

@@ -0,0 +1,400 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
ArrowLeft,
Send,
Eye,
TestTube,
Save,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Campaign {
id: number;
title: string;
subject: string;
content: string;
status: string;
scheduled_at: string | null;
}
export default function CampaignEdit() {
const { id } = useParams<{ id: string }>();
const isNew = id === 'new';
const navigate = useNavigate();
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [subject, setSubject] = useState('');
const [content, setContent] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [showTestDialog, setShowTestDialog] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showSendConfirm, setShowSendConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Fetch campaign if editing
const { data: campaign, isLoading } = useQuery({
queryKey: ['campaign', id],
queryFn: async () => {
const response = await api.get(`/campaigns/${id}`);
return response.data as Campaign;
},
enabled: !isNew && !!id,
});
// Populate form when campaign loads
useEffect(() => {
if (campaign) {
setTitle(campaign.title || '');
setSubject(campaign.subject || '');
setContent(campaign.content || '');
}
}, [campaign]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async (data: { title: string; subject: string; content: string; status?: string }) => {
if (isNew) {
return api.post('/campaigns', data);
} else {
return api.put(`/campaigns/${id}`, data);
}
},
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(isNew ? __('Campaign created') : __('Campaign saved'));
if (isNew && response?.data?.id) {
navigate(`/marketing/campaigns/${response.data.id}`, { replace: true });
}
},
onError: () => {
toast.error(__('Failed to save campaign'));
},
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: async () => {
// First save, then preview
let campaignId = id;
if (isNew || !id) {
const saveResponse = await api.post('/campaigns', { title, subject, content, status: 'draft' });
campaignId = saveResponse?.data?.id;
if (campaignId) {
navigate(`/marketing/campaigns/${campaignId}`, { replace: true });
}
} else {
await api.put(`/campaigns/${id}`, { title, subject, content });
}
const response = await api.get(`/campaigns/${campaignId}/preview`);
return response;
},
onSuccess: (response) => {
setPreviewHtml(response?.html || response?.data?.html || '');
setShowPreview(true);
},
onError: () => {
toast.error(__('Failed to generate preview'));
},
});
// Test email mutation
const testMutation = useMutation({
mutationFn: async (email: string) => {
// First save
if (!isNew && id) {
await api.put(`/campaigns/${id}`, { title, subject, content });
}
return api.post(`/campaigns/${id}/test`, { email });
},
onSuccess: () => {
toast.success(__('Test email sent'));
setShowTestDialog(false);
},
onError: () => {
toast.error(__('Failed to send test email'));
},
});
// Send mutation
const sendMutation = useMutation({
mutationFn: async () => {
// First save
await api.put(`/campaigns/${id}`, { title, subject, content });
return api.post(`/campaigns/${id}/send`);
},
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
toast.success(response?.message || __('Campaign sent successfully'));
setShowSendConfirm(false);
navigate('/marketing/campaigns');
},
onError: (error: any) => {
toast.error(error?.response?.data?.error || __('Failed to send campaign'));
},
});
const handleSave = async () => {
if (!title.trim()) {
toast.error(__('Please enter a title'));
return;
}
setIsSaving(true);
try {
await saveMutation.mutateAsync({ title, subject, content, status: 'draft' });
} finally {
setIsSaving(false);
}
};
const canSend = !isNew && id && campaign?.status !== 'sent' && campaign?.status !== 'sending';
if (!isNew && isLoading) {
return (
<SettingsLayout title={__('Loading...')} description="">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout
title={isNew ? __('New Campaign') : __('Edit Campaign')}
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
>
{/* Back button */}
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
<ArrowLeft className="mr-2 h-4 w-4" />
{__('Back to Campaigns')}
</Button>
</div>
<div className="space-y-6">
{/* Campaign Details */}
<SettingsCard
title={__('Campaign Details')}
description={__('Basic information about your campaign')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Campaign Title')}</Label>
<Input
id="title"
placeholder={__('e.g., Holiday Sale Announcement')}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{__('Internal name for this campaign (not shown to subscribers)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="subject">{__('Email Subject')}</Label>
<Input
id="subject"
placeholder={__('e.g., 🎄 Exclusive Holiday Deals Inside!')}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{__('The subject line subscribers will see in their inbox')}
</p>
</div>
</div>
</SettingsCard>
{/* Campaign Content */}
<SettingsCard
title={__('Campaign Content')}
description={__('Write your newsletter content. The design template is configured in Settings > Notifications.')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="content">{__('Email Content')}</Label>
<Textarea
id="content"
placeholder={__('Write your newsletter content here...\n\nYou can use:\n- {site_name} - Your store name\n- {current_date} - Today\'s date\n- {subscriber_email} - Subscriber\'s email')}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[300px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{__('Use HTML for rich formatting. The design wrapper will be applied from your campaign email template.')}
</p>
</div>
</div>
</SettingsCard>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3 sm:justify-between">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending || !title.trim()}
>
{previewMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{__('Preview')}
</Button>
{!isNew && (
<Button
variant="outline"
onClick={() => setShowTestDialog(true)}
disabled={!id}
>
<TestTube className="mr-2 h-4 w-4" />
{__('Send Test')}
</Button>
)}
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleSave}
disabled={isSaving || !title.trim()}
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{__('Save Draft')}
</Button>
{canSend && (
<Button
onClick={() => setShowSendConfirm(true)}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{__('Send Now')}
</Button>
)}
</div>
</div>
</div>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{__('Email Preview')}</DialogTitle>
</DialogHeader>
<div className="border rounded-lg bg-white p-4">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
</div>
</DialogContent>
</Dialog>
{/* Test Email Dialog */}
<Dialog open={showTestDialog} onOpenChange={setShowTestDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Send Test Email')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input
id="test-email"
type="email"
placeholder="your@email.com"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setShowTestDialog(false)}>
{__('Cancel')}
</Button>
<Button
onClick={() => testMutation.mutate(testEmail)}
disabled={!testEmail || testMutation.isPending}
>
{testMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{__('Send Test')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Send Confirmation Dialog */}
<AlertDialog open={showSendConfirm} onOpenChange={setShowSendConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Send Campaign')}</AlertDialogTitle>
<AlertDialogDescription>
{__('Are you sure you want to send this campaign to all newsletter subscribers? This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{__('Send to All Subscribers')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsLayout>
);
}

View File

@@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Plus,
Search,
Send,
Clock,
CheckCircle2,
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 CampaignsList() {
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: '', // Would need to fetch full 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 (
<SettingsLayout
title={__('Campaigns')}
description={__('Create and send email campaigns to your newsletter subscribers')}
>
<SettingsCard
title={__('All Campaigns')}
description={`${campaigns.length} ${__('campaigns total')}`}
>
<div className="space-y-4">
{/* 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/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/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/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>
</SettingsLayout>
);
}

View File

@@ -1,5 +1,63 @@
import { Navigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { Mail, Send, Tag } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface MarketingCard {
title: string;
description: string;
icon: React.ElementType;
to: string;
}
const cards: MarketingCard[] = [
{
title: __('Newsletter'),
description: __('Manage subscribers and email templates'),
icon: Mail,
to: '/marketing/newsletter',
},
{
title: __('Campaigns'),
description: __('Create and send email campaigns'),
icon: Send,
to: '/marketing/campaigns',
},
{
title: __('Coupons'),
description: __('Discounts, promotions, and coupon codes'),
icon: Tag,
to: '/marketing/coupons',
},
];
export default function Marketing() {
return <Navigate to="/marketing/newsletter" replace />;
const navigate = useNavigate();
return (
<SettingsLayout
title={__('Marketing')}
description={__('Newsletter, campaigns, and promotions')}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{cards.map((card) => (
<button
key={card.to}
onClick={() => navigate(card.to)}
className="flex items-start gap-4 p-6 rounded-lg border bg-card hover:bg-accent transition-colors text-left"
>
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<card.icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{card.title}</div>
<div className="text-sm text-muted-foreground mt-1">
{card.description}
</div>
</div>
</button>
))}
</div>
</SettingsLayout>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink } from 'lucide-react';
import { Tag, Settings as SettingsIcon, Palette, ChevronRight, Minimize2, LogOut, Sun, Moon, Monitor, ExternalLink, Mail, Megaphone } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { useApp } from '@/contexts/AppContext';
@@ -16,10 +16,10 @@ interface MenuItem {
const menuItems: MenuItem[] = [
{
icon: <Tag className="w-5 h-5" />,
label: __('Coupons'),
description: __('Manage discount codes and promotions'),
to: '/coupons'
icon: <Megaphone className="w-5 h-5" />,
label: __('Marketing'),
description: __('Newsletter, coupons, and promotions'),
to: '/marketing'
},
{
icon: <Palette className="w-5 h-5" />,
@@ -102,8 +102,7 @@ export default function MorePage() {
<button
key={option.value}
onClick={() => setTheme(option.value as 'light' | 'dark' | 'system')}
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
theme === option.value
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-colors ${theme === option.value
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}

View File

@@ -127,6 +127,7 @@ export default function ProductEdit() {
onSubmit={handleSubmit}
formRef={formRef}
hideSubmitButton={true}
productId={product.id}
/>
{/* Level 1 compatibility: Custom meta fields from plugins */}

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
interface DirectCartLinksProps {
productId: number;
productType: 'simple' | 'variable';
variations?: Array<{
id: number;
name: string;
attributes: Record<string, string>;
}>;
}
export function DirectCartLinks({ productId, productType, variations = [] }: DirectCartLinksProps) {
const [quantity, setQuantity] = useState(1);
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = '/store'; // This should ideally come from settings
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
if (variationId) {
params.set('variation_id', variationId.toString());
}
if (quantity > 1) {
params.set('quantity', quantity.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');
}
};
const LinkRow = ({
label,
link,
description
}: {
label: string;
link: string;
description?: string;
}) => {
const isCopied = copiedLink === link;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">{label}</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(link, label)}
>
{isCopied ? (
<>
<Check className="h-4 w-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => window.open(link, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<Input
value={link}
readOnly
className="font-mono text-xs"
onClick={(e) => e.currentTarget.select()}
/>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
};
return (
<Card>
<CardHeader>
<CardTitle>Direct-to-Cart Links</CardTitle>
<CardDescription>
Generate copyable links that add this product to cart and redirect to cart or checkout page.
Perfect for landing pages, email campaigns, and social media.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Quantity Selector */}
<div className="space-y-2">
<Label htmlFor="link-quantity">Default Quantity</Label>
<Input
id="link-quantity"
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Set quantity to 1 to exclude from URL (cleaner links)
</p>
</div>
{/* Simple Product Links */}
{productType === 'simple' && (
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="font-medium">Simple Product Links</h4>
</div>
<LinkRow
label="Add to Cart"
link={generateLink(undefined, 'cart')}
description="Adds product to cart and shows cart page"
/>
<LinkRow
label="Direct to Checkout"
link={generateLink(undefined, 'checkout')}
description="Adds product to cart and goes directly to checkout"
/>
</div>
)}
{/* Variable Product Links */}
{productType === 'variable' && variations.length > 0 && (
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="font-medium">Variable Product Links</h4>
<p className="text-xs text-muted-foreground mt-1">
{variations.length} variation(s) - Select a variation to generate links
</p>
</div>
<div className="space-y-2">
{variations.map((variation, index) => (
<details key={variation.id} className="group border rounded-lg">
<summary className="cursor-pointer p-3 hover:bg-muted/50 flex items-center justify-between">
<div className="flex-1">
<span className="font-medium text-sm">{variation.name}</span>
<span className="text-xs text-muted-foreground ml-2">
(ID: {variation.id})
</span>
</div>
<svg
className="w-4 h-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="p-4 pt-0 space-y-3 border-t">
<LinkRow
label="Add to Cart"
link={generateLink(variation.id, 'cart')}
/>
<LinkRow
label="Direct to Checkout"
link={generateLink(variation.id, 'checkout')}
/>
</div>
</details>
))}
</div>
</div>
)}
{/* URL Parameters Reference */}
<div className="mt-6 p-4 bg-muted rounded-lg">
<h4 className="font-medium text-sm mb-2">URL Parameters Reference</h4>
<div className="space-y-1 text-xs text-muted-foreground">
<div><code className="bg-background px-1 py-0.5 rounded">add-to-cart</code> - Product ID (required)</div>
<div><code className="bg-background px-1 py-0.5 rounded">variation_id</code> - Variation ID (for variable products)</div>
<div><code className="bg-background px-1 py-0.5 rounded">quantity</code> - Quantity (default: 1)</div>
<div><code className="bg-background px-1 py-0.5 rounded">redirect</code> - Destination: <code>cart</code> or <code>checkout</code></div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -41,6 +41,7 @@ type Props = {
className?: string;
formRef?: React.RefObject<HTMLFormElement>;
hideSubmitButton?: boolean;
productId?: number;
};
export function ProductFormTabbed({
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
className,
formRef,
hideSubmitButton = false,
productId,
}: Props) {
// Form state
const [name, setName] = useState(initial?.name || '');
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
variations={variations}
setVariations={setVariations}
regularPrice={regularPrice}
productId={productId}
/>
</FormSection>
)}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react';
import { Plus, X, Layers, Image as ImageIcon, Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { openWPMediaImage } from '@/lib/wp-media';
@@ -30,6 +30,7 @@ type VariationsTabProps = {
variations: ProductVariant[];
setVariations: (value: ProductVariant[]) => void;
regularPrice: string;
productId?: number;
};
export function VariationsTab({
@@ -38,8 +39,33 @@ export function VariationsTab({
variations,
setVariations,
regularPrice,
productId,
}: VariationsTabProps) {
const store = getStoreCurrency();
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = '/store';
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return '';
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
params.set('variation_id', variationId.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');
}
};
const addAttribute = () => {
setAttributes([...attributes, { name: '', options: [], variation: false }]);
@@ -305,6 +331,45 @@ export function VariationsTab({
}}
/>
</div>
{/* Direct Cart Links */}
{productId && variation.id && (
<div className="mt-4 pt-4 border-t space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
{__('Direct-to-Cart Links')}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateLink(variation.id!, 'cart'), 'Cart')}
className="flex-1"
>
{copiedLink === generateLink(variation.id!, '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(generateLink(variation.id!, 'checkout'), 'Checkout')}
className="flex-1"
>
{copiedLink === generateLink(variation.id!, 'checkout') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Checkout Link')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, CheckCircle, AlertCircle, Eye, EyeOff, Lock } from 'lucide-react';
import { __ } from '@/lib/i18n';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const key = searchParams.get('key') || '';
const login = searchParams.get('login') || '';
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Validate the reset key on mount
useEffect(() => {
const validateKey = async () => {
if (!key || !login) {
setError(__('Invalid password reset link. Please request a new one.'));
setIsValidating(false);
return;
}
try {
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/validate-reset-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, login }),
});
const data = await response.json();
if (response.ok && data.valid) {
setIsValid(true);
} else {
setError(data.message || __('This password reset link has expired or is invalid. Please request a new one.'));
}
} catch (err) {
setError(__('Unable to validate reset link. Please try again later.'));
} finally {
setIsValidating(false);
}
};
validateKey();
}, [key, login]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate passwords match
if (password !== confirmPassword) {
setError(__('Passwords do not match'));
return;
}
// Validate password strength
if (password.length < 8) {
setError(__('Password must be at least 8 characters long'));
return;
}
setIsLoading(true);
try {
const response = await fetch(`${window.WNW_CONFIG?.restUrl || '/wp-json/'}woonoow/v1/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, login, password }),
});
const data = await response.json();
if (response.ok) {
setSuccess(true);
} else {
setError(data.message || __('Failed to reset password. Please try again.'));
}
} catch (err) {
setError(__('An error occurred. Please try again later.'));
} finally {
setIsLoading(false);
}
};
// Password strength indicator
const getPasswordStrength = (pwd: string) => {
if (pwd.length === 0) return { label: '', color: '' };
if (pwd.length < 8) return { label: __('Too short'), color: 'text-red-500' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (pwd.length >= 12) strength++;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
if (strength <= 2) return { label: __('Weak'), color: 'text-orange-500' };
if (strength <= 3) return { label: __('Medium'), color: 'text-yellow-500' };
return { label: __('Strong'), color: 'text-green-500' };
};
const passwordStrength = getPasswordStrength(password);
// Loading state
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">{__('Validating reset link...')}</p>
</CardContent>
</Card>
</div>
);
}
// Success state
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">{__('Password Reset Successful')}</h2>
<p className="text-muted-foreground text-center mb-6">
{__('Your password has been updated. You can now log in with your new password.')}
</p>
<Button onClick={() => navigate('/login')}>
{__('Go to Login')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Error state (invalid key)
if (!isValid && error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">{__('Invalid Reset Link')}</h2>
<p className="text-muted-foreground text-center mb-6">{error}</p>
<Button variant="outline" onClick={() => window.location.href = window.WNW_CONFIG?.siteUrl + '/my-account/lost-password/'}>
{__('Request New Reset Link')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Reset form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-primary/10 p-3">
<Lock className="h-6 w-6 text-primary" />
</div>
</div>
<CardTitle className="text-2xl text-center">{__('Reset Your Password')}</CardTitle>
<CardDescription className="text-center">
{__('Enter your new password below')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="password">{__('New Password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={__('Enter new password')}
required
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{password && (
<p className={`text-sm ${passwordStrength.color}`}>
{__('Strength')}: {passwordStrength.label}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{__('Confirm Password')}</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={__('Confirm new password')}
required
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-sm text-red-500">{__('Passwords do not match')}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{__('Resetting...')}
</>
) : (
__('Reset Password')
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import { formatMoney, getStoreCurrency } from '@/lib/currency';
interface CustomerSettings {
auto_register_members: boolean;
multiple_addresses_enabled: boolean;
wishlist_enabled: boolean;
vip_min_spent: number;
vip_min_orders: number;
vip_timeframe: 'all' | '30' | '90' | '365';
@@ -25,7 +24,6 @@ export default function CustomersSettings() {
const [settings, setSettings] = useState<CustomerSettings>({
auto_register_members: false,
multiple_addresses_enabled: true,
wishlist_enabled: true,
vip_min_spent: 1000,
vip_min_orders: 10,
vip_timeframe: 'all',
@@ -140,13 +138,7 @@ export default function CustomersSettings() {
onCheckedChange={(checked) => setSettings({ ...settings, multiple_addresses_enabled: checked })}
/>
<ToggleField
id="wishlist_enabled"
label={__('Enable wishlist')}
description={__('Allow customers to save products to their wishlist for later purchase. Customers can add products to wishlist from product cards and manage them in their account.')}
checked={settings.wishlist_enabled}
onCheckedChange={(checked) => setSettings({ ...settings, wishlist_enabled: checked })}
/>
</div>
</SettingsCard>

View File

@@ -38,54 +38,6 @@ export default function EditTemplate() {
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
const [activeTab, setActiveTab] = useState('preview');
// All available template variables
const availableVariables = [
// Order variables
'order_number',
'order_id',
'order_date',
'order_total',
'order_subtotal',
'order_tax',
'order_shipping',
'order_discount',
'order_status',
'order_url',
'order_items_table',
'completion_date',
'estimated_delivery',
// Customer variables
'customer_name',
'customer_first_name',
'customer_last_name',
'customer_email',
'customer_phone',
'billing_address',
'shipping_address',
// Payment variables
'payment_method',
'payment_status',
'payment_date',
'transaction_id',
'payment_retry_url',
// Shipping/Tracking variables
'tracking_number',
'tracking_url',
'shipping_carrier',
'shipping_method',
// URL variables
'review_url',
'shop_url',
'my_account_url',
// Store variables
'site_name',
'site_title',
'store_name',
'store_url',
'support_email',
'current_year',
];
// Fetch email customization settings
const { data: emailSettings } = useQuery({
queryKey: ['email-settings'],
@@ -176,8 +128,10 @@ export default function EditTemplate() {
setBlocks(newBlocks); // Keep blocks in sync
};
// Variable keys for the rich text editor dropdown
const variableKeys = availableVariables;
// Variable keys for the rich text editor dropdown - from API (contextual per event)
const variableKeys = template?.available_variables
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
: [];
// Parse [card] tags and [button] shortcodes for preview
const parseCardsForPreview = (content: string) => {
@@ -310,6 +264,15 @@ export default function EditTemplate() {
store_url: '#',
store_email: 'store@example.com',
support_email: 'support@example.com',
// Account-related URLs and variables
login_url: '#',
reset_link: '#',
reset_key: 'abc123xyz',
user_login: 'johndoe',
user_email: 'john@example.com',
user_temp_password: '••••••••',
customer_first_name: 'John',
customer_last_name: 'Doe',
};
Object.keys(sampleData).forEach((key) => {
@@ -318,16 +281,13 @@ export default function EditTemplate() {
});
// Highlight variables that don't have sample data
availableVariables.forEach(key => {
// Use plain text [variable] instead of HTML spans to avoid breaking href attributes
variableKeys.forEach((key: string) => {
if (!storeVariables[key] && !sampleData[key]) {
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
}
});
// Parse [card] tags
previewBody = parseCardsForPreview(previewBody);
// Get email settings for preview
const settings = emailSettings || {};
const primaryColor = settings.primary_color || '#7f54b3';
@@ -380,14 +340,13 @@ export default function EditTemplate() {
.header { padding: 20px 16px; }
.footer { padding: 20px 16px; }
}
.card-success { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
.card-success * { color: ${heroTextColor} !important; }
.card-success { background-color: #f0fdf4; }
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
.card-highlight * { color: ${heroTextColor} !important; }
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
.card-hero * { color: ${heroTextColor} !important; }
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
.card-info { background-color: #f0f7ff; }
.card-warning { background-color: #fff8e1; }
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }

View File

@@ -153,11 +153,11 @@ export default function TemplateEditor({
.header { padding: 32px; text-align: center; background: #f8f8f8; }
.card-gutter { padding: 0 16px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
.card-success { background-color: #f0fdf4; }
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.card-highlight * { color: #fff !important; }
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
.card-info { background-color: #f0f7ff; }
.card-warning { background-color: #fff8e1; }
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }

View File

@@ -1,61 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: '1rem'
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")]
};

View File

@@ -1,6 +1,7 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
important: '#woonoow-admin-app',
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
theme: {
container: { center: true, padding: "1rem" },

View File

@@ -68,11 +68,15 @@ echo "Copying SPA build files..."
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist
mkdir -p ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist
# Customer SPA - only app.js and app.css
# Customer SPA - app.js, app.css, and fonts
cp customer-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
cp customer-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
if [ -d "customer-spa/dist/fonts" ]; then
cp -r customer-spa/dist/fonts ${BUILD_DIR}/${PLUGIN_NAME}/customer-spa/dist/
echo "✓ Copied customer-spa fonts"
fi
# Admin SPA - only app.js and app.css (no dynamic imports for now)
# Admin SPA - app.js and app.css
cp admin-spa/dist/app.js ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/
cp admin-spa/dist/app.css ${BUILD_DIR}/${PLUGIN_NAME}/admin-spa/dist/

98
check-shop-page.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
/**
* Diagnostic script to check Shop page configuration
* Upload this to your WordPress root and access via browser
*/
// Load WordPress
require_once(__DIR__ . '/../../../wp-load.php');
if (!current_user_can('manage_options')) {
die('Access denied');
}
echo '<h1>WooNooW Shop Page Diagnostic</h1>';
// 1. Check WooCommerce Shop Page ID
$shop_page_id = get_option('woocommerce_shop_page_id');
echo '<h2>1. WooCommerce Shop Page Setting</h2>';
echo '<p>Shop Page ID: ' . ($shop_page_id ? $shop_page_id : 'NOT SET') . '</p>';
if ($shop_page_id) {
$shop_page = get_post($shop_page_id);
if ($shop_page) {
echo '<p>Shop Page Title: ' . esc_html($shop_page->post_title) . '</p>';
echo '<p>Shop Page Status: ' . esc_html($shop_page->post_status) . '</p>';
echo '<p>Shop Page URL: ' . get_permalink($shop_page_id) . '</p>';
echo '<h3>Shop Page Content:</h3>';
echo '<pre>' . esc_html($shop_page->post_content) . '</pre>';
// Check for shortcode
if (has_shortcode($shop_page->post_content, 'woonoow_shop')) {
echo '<p style="color: green;">✓ Has [woonoow_shop] shortcode</p>';
} else {
echo '<p style="color: red;">✗ Missing [woonoow_shop] shortcode</p>';
}
} else {
echo '<p style="color: red;">ERROR: Shop page not found!</p>';
}
}
// 2. Find all pages with woonoow shortcodes
echo '<h2>2. Pages with WooNooW Shortcodes</h2>';
$pages_with_shortcodes = get_posts([
'post_type' => 'page',
'post_status' => 'publish',
'posts_per_page' => -1,
's' => 'woonoow_',
]);
if (empty($pages_with_shortcodes)) {
echo '<p style="color: orange;">No pages found with woonoow_ shortcodes</p>';
} else {
echo '<ul>';
foreach ($pages_with_shortcodes as $page) {
echo '<li>';
echo '<strong>' . esc_html($page->post_title) . '</strong> (ID: ' . $page->ID . ')<br>';
echo 'URL: ' . get_permalink($page->ID) . '<br>';
echo 'Content: <pre>' . esc_html(substr($page->post_content, 0, 200)) . '</pre>';
echo '</li>';
}
echo '</ul>';
}
// 3. Check Customer SPA Settings
echo '<h2>3. Customer SPA Settings</h2>';
$spa_settings = get_option('woonoow_customer_spa_settings', []);
echo '<pre>' . print_r($spa_settings, true) . '</pre>';
// 4. Check if pages were created by installer
echo '<h2>4. WooNooW Page Options</h2>';
$woonoow_pages = [
'shop' => get_option('woonoow_shop_page_id'),
'cart' => get_option('woonoow_cart_page_id'),
'checkout' => get_option('woonoow_checkout_page_id'),
'account' => get_option('woonoow_account_page_id'),
];
foreach ($woonoow_pages as $key => $page_id) {
echo '<p>' . ucfirst($key) . ' Page ID: ' . ($page_id ? $page_id : 'NOT SET');
if ($page_id) {
$page = get_post($page_id);
if ($page) {
echo ' - ' . esc_html($page->post_title) . ' (' . $page->post_status . ')';
} else {
echo ' - <span style="color: red;">PAGE NOT FOUND</span>';
}
}
echo '</p>';
}
echo '<hr>';
echo '<h2>Recommended Actions:</h2>';
echo '<ol>';
echo '<li>If Shop page doesn\'t have [woonoow_shop] shortcode, add it to the page content</li>';
echo '<li>If Shop page ID doesn\'t match WooCommerce setting, update WooCommerce > Settings > Products > Shop Page</li>';
echo '<li>If SPA mode is "disabled", it will only load on pages with shortcodes</li>';
echo '<li>If SPA mode is "full", it will load on all WooCommerce pages</li>';
echo '</ol>';

20
composer.lock generated Normal file
View File

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

View File

@@ -14,7 +14,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",

View File

@@ -16,7 +16,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",

View File

@@ -15,6 +15,9 @@ import Checkout from './pages/Checkout';
import ThankYou from './pages/ThankYou';
import Account from './pages/Account';
import Wishlist from './pages/Wishlist';
import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
// Create QueryClient instance
const queryClient = new QueryClient({
@@ -51,19 +54,28 @@ const getAppearanceSettings = () => {
return (window as any).woonoowCustomer?.appearanceSettings || {};
};
function App() {
const themeConfig = getThemeConfig();
const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
// Get initial route from data attribute (set by PHP based on SPA mode)
const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app');
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
};
// Router wrapper component that uses hooks requiring Router context
function AppRoutes() {
const initialRoute = getInitialRoute();
console.log('[WooNooW Customer] Using initial route:', initialRoute);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider config={themeConfig}>
<HashRouter>
<BaseLayout>
<Routes>
{/* Root route redirects to initial route based on SPA mode */}
<Route path="/" element={<Navigate to={initialRoute} replace />} />
{/* Shop Routes */}
<Route path="/" element={<Shop />} />
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
@@ -75,13 +87,31 @@ function App() {
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/shop" replace />} />
{/* Fallback to initial route */}
<Route path="*" element={<Navigate to={initialRoute} replace />} />
</Routes>
</BaseLayout>
);
}
function App() {
const themeConfig = getThemeConfig();
const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider config={themeConfig}>
<HashRouter>
<AppRoutes />
</HashRouter>
{/* Toast notifications - position from settings */}

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { useCartStore } from '@/lib/cart/store';
/**
* Hook to handle add-to-cart from URL parameters
* Supports both simple and variable products
*
* URL formats:
* - Simple product: ?add-to-cart=123
* - Variable product: ?add-to-cart=123&variation_id=456
* - With quantity: ?add-to-cart=123&quantity=2
* - Direct to checkout: ?add-to-cart=123&redirect=checkout
* - Stay on cart (default): ?add-to-cart=123&redirect=cart
*/
export function useAddToCartFromUrl() {
const navigate = useNavigate();
const location = useLocation();
const { setCart } = useCartStore();
const processedRef = useRef<Set<string>>(new Set());
useEffect(() => {
// Check hash route for add-to-cart parameters
const hash = window.location.hash;
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
const productId = hashParams.get('add-to-cart');
if (!productId) return;
const variationId = hashParams.get('variation_id');
const quantity = parseInt(hashParams.get('quantity') || '1', 10);
const redirect = hashParams.get('redirect') || 'cart';
// Create unique key for this add-to-cart request
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
// Skip if already processed
if (processedRef.current.has(requestKey)) {
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
return;
}
console.log('[WooNooW] Add to cart from URL:', {
productId,
variationId,
quantity,
redirect,
fullUrl: window.location.href,
requestKey,
});
// Mark as processed
processedRef.current.add(requestKey);
addToCart(productId, variationId, quantity)
.then((cartData) => {
// Update cart store with fresh data from API
if (cartData) {
setCart(cartData);
console.log('[WooNooW] Cart updated with fresh data:', cartData);
}
// Remove URL parameters after adding to cart
const currentPath = window.location.hash.split('?')[0];
window.location.hash = currentPath;
// Navigate based on redirect parameter
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
if (!location.pathname.includes(targetPage)) {
console.log(`[WooNooW] Navigating to ${targetPage}`);
navigate(targetPage);
}
})
.catch((error) => {
console.error('[WooNooW] Failed to add product to cart:', error);
toast.error('Failed to add product to cart');
// Remove from processed set on error so it can be retried
processedRef.current.delete(requestKey);
});
}, [location.hash, navigate, setCart]); // Include all dependencies
}
async function addToCart(
productId: string,
variationId: string | null,
quantity: number
): Promise<any> {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
const body: any = {
product_id: parseInt(productId, 10),
quantity,
};
if (variationId) {
body.variation_id = parseInt(variationId, 10);
}
console.log('[WooNooW] Adding to cart:', body);
const response = await fetch(`${apiRoot}/cart/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to add to cart');
}
const data = await response.json();
console.log('[WooNooW] Product added to cart:', data);
// API returns {message, cart_item_key, cart} on success
if (data.cart_item_key && data.cart) {
toast.success(data.message || 'Product added to cart');
return data.cart; // Return cart data to update store
} else {
throw new Error(data.message || 'Failed to add to cart');
}
}

View File

@@ -128,10 +128,10 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<span className="hidden lg:block">Account</span>
</Link>
) : (
<a href="/wp-login.php" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Link to="/login" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<User className="h-5 w-5" />
<span className="hidden lg:block">Account</span>
</a>
</Link>
))}
{/* Wishlist */}
@@ -248,10 +248,10 @@ function ClassicLayout({ children }: BaseLayoutProps) {
<span>Account</span>
</Link>
) : (
<a href="/wp-login.php" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
<Link to="/login" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline">
<User className="h-5 w-5" />
<span>Login</span>
</a>
</Link>
)
)}
</div>
@@ -423,9 +423,9 @@ function ModernLayout({ children }: BaseLayoutProps) {
<User className="h-4 w-4" /> Account
</Link>
) : (
<a href="/wp-login.php" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Link to="/login" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
<User className="h-4 w-4" /> Account
</a>
</Link>
)
)}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
@@ -557,9 +557,9 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
<User className="h-4 w-4" /> Account
</Link>
) : (
<a href="/wp-login.php" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
<Link to="/login" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
<User className="h-4 w-4" /> Account
</a>
</Link>
))}
{headerSettings.elements.wishlist && isEnabled('wishlist') && (wishlistSettings.show_in_header ?? true) && (
<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">

View File

@@ -0,0 +1,111 @@
import { Cart } from './store';
const getApiConfig = () => {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
return { apiRoot, nonce };
};
/**
* Update cart item quantity via API
*/
export async function updateCartItemQuantity(
cartItemKey: string,
quantity: number
): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
cart_item_key: cartItemKey,
quantity,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to update cart');
}
const data = await response.json();
return data.cart;
}
/**
* Remove item from cart via API
*/
export async function removeCartItem(cartItemKey: string): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/remove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
cart_item_key: cartItemKey,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to remove item');
}
const data = await response.json();
return data.cart;
}
/**
* Clear entire cart via API
*/
export async function clearCartAPI(): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/clear`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to clear cart');
}
const data = await response.json();
return data.cart;
}
/**
* Fetch current cart from API
*/
export async function fetchCart(): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart`, {
method: 'GET',
headers: {
'X-WP-Nonce': nonce,
},
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch cart');
}
const data = await response.json();
return data;
}

View File

@@ -1,7 +1,18 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
import { useModules } from '@/hooks/useModules';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface AccountLayoutProps {
children: ReactNode;
@@ -12,6 +23,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
const user = (window as any).woonoowCustomer?.user;
const { isEnabled } = useModules();
const wishlistEnabled = (window as any).woonoowCustomer?.settings?.wishlist_enabled !== false;
const [isLoggingOut, setIsLoggingOut] = useState(false);
const allMenuItems = [
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
@@ -27,8 +39,27 @@ export function AccountLayout({ children }: AccountLayoutProps) {
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
);
const handleLogout = () => {
window.location.href = '/wp-login.php?action=logout';
const handleLogout = async () => {
setIsLoggingOut(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
await fetch(`${apiRoot}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
});
// Full page reload to clear cookies and refresh state
window.location.href = window.location.origin + '/store/';
} catch (error) {
// Even on error, try to redirect and let server handle session
window.location.href = window.location.origin + '/store/';
}
};
const isActive = (path: string) => {
@@ -38,6 +69,38 @@ export function AccountLayout({ children }: AccountLayoutProps) {
return location.pathname.startsWith(path);
};
// Logout Button with AlertDialog
const LogoutButton = () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<button
disabled={isLoggingOut}
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">{isLoggingOut ? 'Logging out...' : 'Logout'}</span>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Log out?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to log out of your account? You'll need to sign in again to access your orders and account details.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleLogout}
className="bg-red-600 hover:bg-red-700 text-white"
>
Log Out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
// Sidebar Navigation
const SidebarNav = () => (
<aside className="bg-white rounded-lg border p-4">
@@ -60,8 +123,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
isActive(item.path)
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${isActive(item.path)
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100'
}`}
@@ -72,13 +134,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
);
})}
<button
onClick={handleLogout}
className="w-full font-[inherit] flex items-center gap-3 px-4 py-2.5 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="font-medium">Logout</span>
</button>
<LogoutButton />
</nav>
</aside>
);
@@ -93,8 +149,7 @@ export function AccountLayout({ children }: AccountLayoutProps) {
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${
isActive(item.path)
className={`flex items-center gap-2 px-6 py-4 border-b-2 transition-colors whitespace-nowrap text-sm ${isActive(item.path)
? 'border-primary text-primary font-medium'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
@@ -128,3 +183,4 @@ export function AccountLayout({ children }: AccountLayoutProps) {
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Container from '@/components/Layout/Container';
import { AccountLayout } from './components/AccountLayout';
import Dashboard from './Dashboard';
@@ -12,11 +12,12 @@ import AccountDetails from './AccountDetails';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
const location = useLocation();
// Redirect to login if not authenticated
if (!user?.isLoggedIn) {
window.location.href = '/wp-login.php?redirect_to=' + encodeURIComponent(window.location.href);
return null;
const currentPath = location.pathname;
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
}
return (

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCartStore, type CartItem } from '@/lib/cart/store';
import { useCartSettings } from '@/hooks/useAppearanceSettings';
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -13,37 +14,96 @@ import {
} from '@/components/ui/dialog';
import Container from '@/components/Layout/Container';
import { formatPrice } from '@/lib/currency';
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react';
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function Cart() {
const navigate = useNavigate();
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
const { cart, setCart } = useCartStore();
const { layout, elements } = useCartSettings();
const [showClearDialog, setShowClearDialog] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Fetch cart from server on mount to sync with WooCommerce
useEffect(() => {
const loadCart = async () => {
try {
const serverCart = await fetchCart();
setCart(serverCart);
} catch (error) {
console.error('Failed to fetch cart:', error);
} finally {
setIsLoading(false);
}
};
loadCart();
}, [setCart]);
// Calculate total from items
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const handleUpdateQuantity = (key: string, newQuantity: number) => {
const handleUpdateQuantity = async (key: string, newQuantity: number) => {
if (newQuantity < 1) {
handleRemoveItem(key);
return;
}
updateQuantity(key, newQuantity);
setIsUpdating(true);
try {
const updatedCart = await updateCartItemQuantity(key, newQuantity);
setCart(updatedCart);
} catch (error) {
console.error('Failed to update quantity:', error);
toast.error('Failed to update quantity');
} finally {
setIsUpdating(false);
}
};
const handleRemoveItem = (key: string) => {
removeItem(key);
const handleRemoveItem = async (key: string) => {
setIsUpdating(true);
try {
const updatedCart = await removeCartItem(key);
setCart(updatedCart);
toast.success('Item removed from cart');
} catch (error) {
console.error('Failed to remove item:', error);
toast.error('Failed to remove item');
} finally {
setIsUpdating(false);
}
};
const handleClearCart = () => {
clearCart();
const handleClearCart = async () => {
setIsUpdating(true);
try {
const updatedCart = await clearCartAPI();
setCart(updatedCart);
setShowClearDialog(false);
toast.success('Cart cleared');
} catch (error) {
console.error('Failed to clear cart:', error);
toast.error('Failed to clear cart');
setShowClearDialog(false);
} finally {
setIsUpdating(false);
}
};
// Show loading state while fetching cart
if (isLoading) {
return (
<Container>
<div className="text-center py-16">
<Loader2 className="mx-auto h-16 w-16 text-gray-400 mb-4 animate-spin" />
<p className="text-gray-600">Loading cart...</p>
</div>
</Container>
);
}
if (cart.items.length === 0) {
return (
<Container>

View File

@@ -237,13 +237,16 @@ export default function Checkout() {
const data = (response as any).data || response;
if (data.ok && data.order_id) {
// Clear cart
cart.items.forEach(item => {
useCartStore.getState().removeItem(item.key);
});
// Clear cart - use store method directly
useCartStore.getState().clearCart();
toast.success('Order placed successfully!');
navigate(`/order-received/${data.order_id}`);
// Use full page reload instead of SPA routing
// This ensures auto-registered users get their auth cookies properly set
const thankYouUrl = `${window.location.origin}/store/#/order-received/${data.order_id}?key=${data.order_key}`;
window.location.href = thankYouUrl;
window.location.reload();
} else {
throw new Error(data.error || 'Failed to create order');
}

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import Container from '@/components/Layout/Container';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { KeyRound, ArrowLeft, Mail, CheckCircle } from 'lucide-react';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
const response = await fetch(`${apiRoot}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({ email }),
});
const data = await response.json();
if (data.success) {
setIsSuccess(true);
toast.success('Password reset email sent!');
} else {
setError(data.message || 'Failed to send reset email');
}
} catch (err: any) {
setError(err.message || 'An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
// Success state
if (isSuccess) {
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check Your Email</h1>
<p className="text-gray-600 mb-6">
We've sent a password reset link to <strong>{email}</strong>.
Please check your inbox and click the link to reset your password.
</p>
<div className="space-y-3">
<Link to="/login">
<Button className="w-full">
Return to Login
</Button>
</Link>
<button
onClick={() => {
setIsSuccess(false);
setEmail('');
}}
className="text-sm text-gray-600 hover:text-primary"
>
Try a different email
</button>
</div>
</div>
</div>
</div>
</Container>
);
}
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
{/* Back link */}
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
<div className="bg-white rounded-2xl shadow-xl p-8 border">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<KeyRound className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Forgot Password?</h1>
<p className="text-gray-600 mt-2">
Enter your email and we'll send you a link to reset your password.
</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
autoComplete="email"
disabled={isLoading}
className="pl-10"
/>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</Button>
</form>
{/* Footer */}
<div className="mt-6 text-center text-sm text-gray-600">
Remember your password?{' '}
<Link to="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</div>
</div>
</div>
</Container>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { toast } from 'sonner';
import Container from '@/components/Layout/Container';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { LogIn, Eye, EyeOff, ArrowLeft } from 'lucide-react';
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirect') || '/my-account';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
const response = await fetch(`${apiRoot}/auth/customer-login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success) {
// Update window config with new nonce and user data
if ((window as any).woonoowCustomer) {
(window as any).woonoowCustomer.nonce = data.nonce;
(window as any).woonoowCustomer.user = {
isLoggedIn: true,
id: data.user.id,
name: data.user.name,
email: data.user.email,
firstName: data.user.first_name,
lastName: data.user.last_name,
avatar: data.user.avatar,
};
}
// Merge guest wishlist to account
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const guestProductIds = JSON.parse(stored) as number[];
if (guestProductIds.length > 0) {
// Merge each product to account wishlist
const newNonce = data.nonce;
for (const productId of guestProductIds) {
try {
await fetch(`${apiRoot}/account/wishlist`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': newNonce,
},
credentials: 'include',
body: JSON.stringify({ product_id: productId }),
});
} catch (e) {
// Skip if product already in wishlist or other error
console.debug('Wishlist merge skipped for product:', productId);
}
}
// Clear guest wishlist after merge
localStorage.removeItem(GUEST_WISHLIST_KEY);
}
}
} catch (e) {
console.error('Failed to merge guest wishlist:', e);
}
toast.success('Login successful!');
// 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
const targetUrl = window.location.origin + '/store/#' + redirectTo;
window.location.href = targetUrl;
// Force page reload to refresh cookies and server-side state
window.location.reload();
} else {
setError(data.message || 'Login failed');
}
} catch (err: any) {
setError(err.message || 'An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
{/* Back link */}
<Link
to="/shop"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
>
<ArrowLeft className="w-4 h-4" />
Continue shopping
</Link>
<div className="bg-white rounded-2xl shadow-xl p-8 border">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<LogIn className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Welcome Back</h1>
<p className="text-gray-600 mt-2">Sign in to your account</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="username">Email or Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your email or username"
required
autoComplete="username"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
disabled={isLoading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
{/* Footer links */}
<div className="mt-6 text-center text-sm text-gray-600">
<Link
to="/forgot-password"
className="hover:text-primary"
>
Forgot your password?
</Link>
</div>
</div>
</div>
</div>
</Container>
);
}

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import Container from '@/components/Layout/Container';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { KeyRound, ArrowLeft, Eye, EyeOff, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const key = searchParams.get('key') || '';
const login = searchParams.get('login') || '';
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(false);
const [error, setError] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
// Validate the reset key on mount
useEffect(() => {
const validateKey = async () => {
if (!key || !login) {
setError('Invalid password reset link. Please request a new one.');
setIsValidating(false);
return;
}
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
const response = await fetch(`${apiRoot}/auth/validate-reset-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({ key, login }),
});
const data = await response.json();
if (response.ok && data.valid) {
setIsValid(true);
} else {
setError(data.message || 'This password reset link has expired or is invalid.');
}
} catch (err) {
setError('Unable to validate reset link. Please try again later.');
} finally {
setIsValidating(false);
}
};
validateKey();
}, [key, login]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
setIsLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
const response = await fetch(`${apiRoot}/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({ key, login, password }),
});
const data = await response.json();
if (response.ok && data.success) {
setIsSuccess(true);
toast.success('Password reset successfully!');
} else {
setError(data.message || 'Failed to reset password. Please try again.');
}
} catch (err: any) {
setError(err.message || 'An error occurred. Please try again later.');
} finally {
setIsLoading(false);
}
};
// Password strength indicator
const getPasswordStrength = (pwd: string) => {
if (pwd.length === 0) return { label: '', color: '', width: '0%' };
if (pwd.length < 8) return { label: 'Too short', color: 'bg-red-500', width: '25%' };
let strength = 0;
if (pwd.length >= 8) strength++;
if (pwd.length >= 12) strength++;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) strength++;
if (/\d/.test(pwd)) strength++;
if (/[!@#$%^&*(),.?":{}|<>]/.test(pwd)) strength++;
if (strength <= 2) return { label: 'Weak', color: 'bg-orange-500', width: '50%' };
if (strength <= 3) return { label: 'Medium', color: 'bg-yellow-500', width: '75%' };
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
};
const passwordStrength = getPasswordStrength(password);
// Loading state
if (isValidating) {
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-gray-600">Validating reset link...</p>
</div>
</div>
</div>
</Container>
);
}
// Success state
if (isSuccess) {
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Password Reset!</h1>
<p className="text-gray-600 mb-6">
Your password has been successfully updated. You can now log in with your new password.
</p>
<Link to="/login">
<Button className="w-full">Sign In</Button>
</Link>
</div>
</div>
</div>
</Container>
);
}
// Error state (invalid key)
if (!isValid && error) {
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Invalid Reset Link</h1>
<p className="text-gray-600 mb-6">{error}</p>
<Link to="/forgot-password">
<Button className="w-full">Request New Link</Button>
</Link>
</div>
</div>
</div>
</Container>
);
}
// Reset form
return (
<Container>
<div className="min-h-[60vh] flex items-center justify-center py-12">
<div className="w-full max-w-md">
{/* Back link */}
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-6 no-underline"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
<div className="bg-white rounded-2xl shadow-xl p-8 border">
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<KeyRound className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Reset Your Password</h1>
<p className="text-gray-600 mt-2">
Enter your new password below.
</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password"
required
disabled={isLoading}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{password && (
<div className="space-y-1">
<div className="h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${passwordStrength.color}`}
style={{ width: passwordStrength.width }}
/>
</div>
<p className="text-xs text-gray-500">
Strength: <span className="font-medium">{passwordStrength.label}</span>
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
disabled={isLoading}
/>
{confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-500">Passwords do not match</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || password !== confirmPassword}
>
{isLoading ? 'Resetting...' : 'Reset Password'}
</Button>
</form>
{/* Footer */}
<div className="mt-6 text-center text-sm text-gray-600">
Remember your password?{' '}
<Link to="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</div>
</div>
</div>
</Container>
);
}

View File

@@ -1,25 +1,31 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useParams, Link, useSearchParams } from 'react-router-dom';
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
import Container from '@/components/Layout/Container';
import { CheckCircle, ShoppingBag, Package, Truck } from 'lucide-react';
import { CheckCircle, ShoppingBag, Package, Truck, User, LogIn } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatPrice } from '@/lib/currency';
import { apiClient } from '@/lib/api/client';
export default function ThankYou() {
const { orderId } = useParams<{ orderId: string }>();
const [searchParams] = useSearchParams();
const orderKey = searchParams.get('key');
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
const [order, setOrder] = useState<any>(null);
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
useEffect(() => {
const fetchOrderData = async () => {
if (!orderId) return;
try {
const orderData = await apiClient.get(`/orders/${orderId}`) as any;
// Use public order endpoint with key validation
const keyParam = orderKey ? `?key=${orderKey}` : '';
const orderData = await apiClient.get(`/checkout/order/${orderId}${keyParam}`) as any;
setOrder(orderData);
// Fetch related products from first order item
@@ -30,15 +36,16 @@ export default function ThankYou() {
setRelatedProducts(productData.related_products.slice(0, 4));
}
}
} catch (error) {
console.error('Failed to fetch order data:', error);
} catch (err: any) {
console.error('Failed to fetch order data:', err);
setError(err.message || 'Failed to load order');
} finally {
setLoading(false);
}
};
fetchOrderData();
}, [orderId]);
}, [orderId, orderKey]);
if (loading || settingsLoading || !order) {
return (
@@ -180,6 +187,7 @@ export default function ThankYou() {
: 'Thank you for your business!'}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{elements.continue_shopping_button && (
<Link to="/shop">
<Button size="lg" className="gap-2">
@@ -188,6 +196,22 @@ export default function ThankYou() {
</Button>
</Link>
)}
{isLoggedIn ? (
<Link to="/my-account">
<Button size="lg" variant="outline" className="gap-2">
<User className="w-5 h-5" />
Go to Account
</Button>
</Link>
) : (
<Link to="/login">
<Button size="lg" variant="outline" className="gap-2">
<LogIn className="w-5 h-5" />
Login / Create Account
</Button>
</Link>
)}
</div>
</div>
</div>
@@ -424,17 +448,32 @@ export default function ThankYou() {
</div>
)}
{/* Continue Shopping Button */}
{/* Action Buttons */}
<div className="text-center flex flex-col sm:flex-row gap-3 justify-center">
{elements.continue_shopping_button && (
<div className="text-center">
<Link to="/shop">
<Button size="lg" className="gap-2">
<ShoppingBag className="w-5 h-5" />
Continue Shopping
</Button>
</Link>
</div>
)}
{isLoggedIn ? (
<Link to="/my-account">
<Button size="lg" variant="outline" className="gap-2">
<User className="w-5 h-5" />
Go to Account
</Button>
</Link>
) : (
<Link to="/login">
<Button size="lg" variant="outline" className="gap-2">
<LogIn className="w-5 h-5" />
Login / Create Account
</Button>
</Link>
)}
</div>
{/* Related Products */}
{elements.related_products && relatedProducts.length > 0 && (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { Trash2, ShoppingCart, Heart } from 'lucide-react';
import { useWishlist } from '@/hooks/useWishlist';
import { useCartStore } from '@/lib/cart/store';
@@ -119,7 +119,7 @@ export default function Wishlist() {
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Guest Wishlist:</strong> You have {guestProducts.length} items saved locally.
<a href="/wp-login.php" className="underline ml-1">Login</a> to sync your wishlist to your account.
<Link to="/login" className="underline ml-1">Login</Link> to sync your wishlist to your account.
</p>
</div>
)}

View File

@@ -56,6 +56,13 @@ class AppearanceController {
],
],
]);
// Get all WordPress pages for page selector
register_rest_route(self::API_NAMESPACE, '/pages/list', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_pages_list'],
'permission_callback' => [__CLASS__, 'check_permission'],
]);
}
public static function check_permission() {
@@ -82,6 +89,7 @@ class AppearanceController {
$general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'spa_page' => absint($request->get_param('spaPage') ?? 0),
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),
'typography' => [
'mode' => sanitize_text_field($request->get_param('typography')['mode'] ?? 'predefined'),
@@ -371,6 +379,30 @@ class AppearanceController {
return $sanitized;
}
/**
* Get list of WordPress pages for page selector
*/
public static function get_pages_list(WP_REST_Request $request) {
$pages = get_pages([
'post_status' => 'publish',
'sort_column' => 'post_title',
'sort_order' => 'ASC',
]);
$pages_list = array_map(function($page) {
return [
'id' => $page->ID,
'title' => $page->post_title,
'slug' => $page->post_name,
];
}, $pages);
return new WP_REST_Response([
'success' => true,
'data' => $pages_list,
], 200);
}
/**
* Get default settings structure
*/
@@ -378,6 +410,7 @@ class AppearanceController {
return [
'general' => [
'spa_mode' => 'full',
'spa_page' => 0,
'toast_position' => 'top-right',
'typography' => [
'mode' => 'predefined',

View File

@@ -6,12 +6,15 @@ use WooNooW\Compat\AddonRegistry;
use WooNooW\Compat\RouteRegistry;
use WooNooW\Compat\NavigationRegistry;
class Assets {
public static function init() {
class Assets
{
public static function init()
{
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
}
public static function enqueue($hook) {
public static function enqueue($hook)
{
// Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[WooNooW Assets] Hook: ' . $hook);
@@ -42,7 +45,8 @@ class Assets {
/** ----------------------------------------
* DEV MODE (Vite dev server)
* -------------------------------------- */
private static function enqueue_dev(): void {
private static function enqueue_dev(): void
{
$dev_url = self::dev_server_url(); // e.g. http://localhost:5173
// 1) Create a small handle to attach config (window.WNW_API)
@@ -117,9 +121,9 @@ class Assets {
// 1) React Refresh preamble (required by @vitejs/plugin-react)
?>
<script type="module">
import RefreshRuntime from "<?php echo esc_url( $dev_url ); ?>/@react-refresh";
import RefreshRuntime from "<?php echo esc_url($dev_url); ?>/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshReg$ = () => { };
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script>
@@ -136,7 +140,8 @@ class Assets {
/** ----------------------------------------
* PROD MODE (built assets in admin-spa/dist)
* -------------------------------------- */
private static function enqueue_prod(): void {
private static function enqueue_prod(): void
{
// Get plugin root directory (2 levels up from includes/Admin/)
$plugin_dir = dirname(dirname(__DIR__));
$dist_dir = $plugin_dir . '/admin-spa/dist/';
@@ -159,26 +164,14 @@ class Assets {
if (file_exists($dist_dir . $css)) {
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
// Fix icon rendering in WP-Admin (prevent WordPress admin styles from overriding)
$icon_fix_css = '
/* Fix Lucide icons in WP-Admin - force outlined style */
#woonoow-admin-app svg {
fill: none !important;
stroke: currentColor !important;
stroke-width: 2 !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
';
wp_add_inline_style('wnw-admin', $icon_fix_css);
// Note: Icon fixes are now in index.css with proper specificity
}
if (file_exists($dist_dir . $js)) {
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
// Add type="module" attribute for Vite build
add_filter('script_loader_tag', function($tag, $handle, $src) {
add_filter('script_loader_tag', function ($tag, $handle, $src) {
if ($handle === 'wnw-admin') {
$tag = str_replace('<script ', '<script type="module" ', $tag);
}
@@ -190,7 +183,8 @@ class Assets {
}
/** Attach runtime config to a handle */
private static function localize_runtime(string $handle): void {
private static function localize_runtime(string $handle): void
{
wp_localize_script($handle, 'WNW_API', [
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
'nonce' => wp_create_nonce('wp_rest'),
@@ -249,7 +243,8 @@ class Assets {
}
/** Runtime store meta for frontend (currency, decimals, separators, position). */
private static function store_runtime(): array {
private static function store_runtime(): array
{
// WooCommerce helpers may not exist in some contexts; guard with defaults
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
@@ -275,7 +270,8 @@ class Assets {
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
* in Local by Flywheel or other local dev environments.
*/
private static function is_dev_mode(): bool {
private static function is_dev_mode(): bool
{
// Only enable dev mode if explicitly set via constant
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
@@ -297,7 +293,8 @@ class Assets {
}
/** Dev server URL (filterable) */
private static function dev_server_url(): string {
private static function dev_server_url(): string
{
// Auto-detect based on current host (for Local by Flywheel compatibility)
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$protocol = is_ssl() ? 'https' : 'http';
@@ -314,7 +311,8 @@ class Assets {
}
/** Basic asset versioning */
private static function asset_version(): string {
private static function asset_version(): string
{
// Bump when releasing; in dev we don't cache-bust
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
}

View File

@@ -78,6 +78,58 @@ class AuthController {
], 200 );
}
/**
* Customer login endpoint (no admin permission required)
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function customer_login( WP_REST_Request $request ): WP_REST_Response {
$username = sanitize_text_field( $request->get_param( 'username' ) );
$password = $request->get_param( 'password' );
if ( empty( $username ) || empty( $password ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Username and password are required', 'woonoow' ),
], 400 );
}
// Authenticate user
$user = wp_authenticate( $username, $password );
if ( is_wp_error( $user ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Invalid username or password', 'woonoow' ),
], 401 );
}
// Clear old cookies and set new ones
wp_clear_auth_cookie();
wp_set_current_user( $user->ID );
wp_set_auth_cookie( $user->ID, true );
// Trigger login action
do_action( 'wp_login', $user->user_login, $user );
// Get customer data
$customer_data = [
'id' => $user->ID,
'name' => $user->display_name,
'email' => $user->user_email,
'first_name' => get_user_meta( $user->ID, 'first_name', true ),
'last_name' => get_user_meta( $user->ID, 'last_name', true ),
'avatar' => get_avatar_url( $user->ID ),
];
return new WP_REST_Response( [
'success' => true,
'user' => $customer_data,
'nonce' => wp_create_nonce( 'wp_rest' ),
], 200 );
}
/**
* Logout endpoint
*
@@ -134,4 +186,144 @@ class AuthController {
],
], 200 );
}
/**
* Forgot password endpoint - sends password reset email
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function forgot_password( WP_REST_Request $request ): WP_REST_Response {
$email = sanitize_email( $request->get_param( 'email' ) );
if ( empty( $email ) || ! is_email( $email ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Please enter a valid email address', 'woonoow' ),
], 400 );
}
// Check if user exists
$user = get_user_by( 'email', $email );
if ( ! $user ) {
// For security, don't reveal if email exists or not
// But still return success to prevent email enumeration attacks
return new WP_REST_Response( [
'success' => true,
'message' => __( 'If an account exists with this email, you will receive a password reset link.', 'woonoow' ),
], 200 );
}
// Use WordPress's built-in password reset functionality
$result = retrieve_password( $user->user_login );
if ( is_wp_error( $result ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Failed to send password reset email. Please try again.', 'woonoow' ),
], 500 );
}
return new WP_REST_Response( [
'success' => true,
'message' => __( 'Password reset email sent! Please check your inbox.', 'woonoow' ),
], 200 );
}
/**
* Validate password reset key
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function validate_reset_key( WP_REST_Request $request ): WP_REST_Response {
$key = sanitize_text_field( $request->get_param( 'key' ) );
$login = sanitize_text_field( $request->get_param( 'login' ) );
if ( empty( $key ) || empty( $login ) ) {
return new WP_REST_Response( [
'valid' => false,
'message' => __( 'Invalid password reset link', 'woonoow' ),
], 400 );
}
// Check the reset key
$user = check_password_reset_key( $key, $login );
if ( is_wp_error( $user ) ) {
$error_code = $user->get_error_code();
$message = __( 'This password reset link has expired or is invalid.', 'woonoow' );
if ( $error_code === 'invalid_key' ) {
$message = __( 'This password reset link is invalid.', 'woonoow' );
} elseif ( $error_code === 'expired_key' ) {
$message = __( 'This password reset link has expired. Please request a new one.', 'woonoow' );
}
return new WP_REST_Response( [
'valid' => false,
'message' => $message,
], 400 );
}
return new WP_REST_Response( [
'valid' => true,
'user' => [
'login' => $user->user_login,
'email' => $user->user_email,
],
], 200 );
}
/**
* Reset password with key
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response Response object
*/
public static function reset_password( WP_REST_Request $request ): WP_REST_Response {
$key = sanitize_text_field( $request->get_param( 'key' ) );
$login = sanitize_text_field( $request->get_param( 'login' ) );
$password = $request->get_param( 'password' );
if ( empty( $key ) || empty( $login ) || empty( $password ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Missing required fields', 'woonoow' ),
], 400 );
}
// Validate password strength
if ( strlen( $password ) < 8 ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'Password must be at least 8 characters long', 'woonoow' ),
], 400 );
}
// Validate the reset key
$user = check_password_reset_key( $key, $login );
if ( is_wp_error( $user ) ) {
return new WP_REST_Response( [
'success' => false,
'message' => __( 'This password reset link has expired or is invalid. Please request a new one.', 'woonoow' ),
], 400 );
}
// Reset the password
reset_password( $user, $password );
// Delete the password reset key so it can't be reused
delete_user_meta( $user->ID, 'default_password_nag' );
// Trigger password changed action
do_action( 'password_reset', $user, $password );
return new WP_REST_Response( [
'success' => true,
'message' => __( 'Password reset successfully. You can now log in with your new password.', 'woonoow' ),
], 200 );
}
}

View File

@@ -0,0 +1,320 @@
<?php
/**
* Campaigns REST Controller
*
* REST API endpoints for newsletter campaigns
*
* @package WooNooW\API
*/
namespace WooNooW\API;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\Campaigns\CampaignManager;
class CampaignsController {
const API_NAMESPACE = 'woonoow/v1';
/**
* Register REST routes
*/
public static function register_routes() {
// List campaigns
register_rest_route(self::API_NAMESPACE, '/campaigns', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_campaigns'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Create campaign
register_rest_route(self::API_NAMESPACE, '/campaigns', [
'methods' => 'POST',
'callback' => [__CLASS__, 'create_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Get single campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Update campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [__CLASS__, 'update_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Delete campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Send campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/send', [
'methods' => 'POST',
'callback' => [__CLASS__, 'send_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Send test email
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/test', [
'methods' => 'POST',
'callback' => [__CLASS__, 'send_test_email'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
// Preview campaign
register_rest_route(self::API_NAMESPACE, '/campaigns/(?P<id>\d+)/preview', [
'methods' => 'GET',
'callback' => [__CLASS__, 'preview_campaign'],
'permission_callback' => [__CLASS__, 'check_admin_permission'],
]);
}
/**
* Check admin permission
*/
public static function check_admin_permission() {
return current_user_can('manage_options');
}
/**
* Get all campaigns
*/
public static function get_campaigns(WP_REST_Request $request) {
$campaigns = CampaignManager::get_all();
return new WP_REST_Response([
'success' => true,
'data' => $campaigns,
]);
}
/**
* Create campaign
*/
public static function create_campaign(WP_REST_Request $request) {
$data = [
'title' => $request->get_param('title'),
'subject' => $request->get_param('subject'),
'content' => $request->get_param('content'),
'status' => $request->get_param('status') ?: 'draft',
'scheduled_at' => $request->get_param('scheduled_at'),
];
$campaign_id = CampaignManager::create($data);
if (is_wp_error($campaign_id)) {
return new WP_REST_Response([
'success' => false,
'error' => $campaign_id->get_error_message(),
], 400);
}
$campaign = CampaignManager::get($campaign_id);
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
], 201);
}
/**
* Get single campaign
*/
public static function get_campaign(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
if (!$campaign) {
return new WP_REST_Response([
'success' => false,
'error' => __('Campaign not found', 'woonoow'),
], 404);
}
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
]);
}
/**
* Update campaign
*/
public static function update_campaign(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$data = [];
if ($request->has_param('title')) {
$data['title'] = $request->get_param('title');
}
if ($request->has_param('subject')) {
$data['subject'] = $request->get_param('subject');
}
if ($request->has_param('content')) {
$data['content'] = $request->get_param('content');
}
if ($request->has_param('status')) {
$data['status'] = $request->get_param('status');
}
if ($request->has_param('scheduled_at')) {
$data['scheduled_at'] = $request->get_param('scheduled_at');
}
$result = CampaignManager::update($campaign_id, $data);
if (is_wp_error($result)) {
return new WP_REST_Response([
'success' => false,
'error' => $result->get_error_message(),
], 400);
}
$campaign = CampaignManager::get($campaign_id);
return new WP_REST_Response([
'success' => true,
'data' => $campaign,
]);
}
/**
* Delete campaign
*/
public static function delete_campaign(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::delete($campaign_id);
if (!$result) {
return new WP_REST_Response([
'success' => false,
'error' => __('Failed to delete campaign', 'woonoow'),
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => __('Campaign deleted', 'woonoow'),
]);
}
/**
* Send campaign
*/
public static function send_campaign(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$result = CampaignManager::send($campaign_id);
if (!$result['success']) {
return new WP_REST_Response([
'success' => false,
'error' => $result['error'],
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => sprintf(
__('Campaign sent to %d recipients (%d failed)', 'woonoow'),
$result['sent'],
$result['failed']
),
'sent' => $result['sent'],
'failed' => $result['failed'],
'total' => $result['total'],
]);
}
/**
* Send test email
*/
public static function send_test_email(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$email = sanitize_email($request->get_param('email'));
if (!is_email($email)) {
return new WP_REST_Response([
'success' => false,
'error' => __('Invalid email address', 'woonoow'),
], 400);
}
$result = CampaignManager::send_test($campaign_id, $email);
if (!$result) {
return new WP_REST_Response([
'success' => false,
'error' => __('Failed to send test email', 'woonoow'),
], 400);
}
return new WP_REST_Response([
'success' => true,
'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email),
]);
}
/**
* Preview campaign
*/
public static function preview_campaign(WP_REST_Request $request) {
$campaign_id = (int) $request->get_param('id');
$campaign = CampaignManager::get($campaign_id);
if (!$campaign) {
return new WP_REST_Response([
'success' => false,
'error' => __('Campaign not found', 'woonoow'),
], 404);
}
// Use reflection to call private render method or make it public
// For now, return a simple preview
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
$content = $campaign['content'];
$subject = $campaign['subject'] ?: $campaign['title'];
if ($template) {
$content = str_replace('{content}', $campaign['content'], $template['body']);
$content = str_replace('{campaign_title}', $campaign['title'], $content);
}
// Replace placeholders
$site_name = get_bloginfo('name');
$content = str_replace(['{site_name}', '{store_name}'], $site_name, $content);
$content = str_replace('{site_url}', home_url(), $content);
$content = str_replace('{subscriber_email}', 'subscriber@example.com', $content);
$content = str_replace('{unsubscribe_url}', '#unsubscribe', $content);
$content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content);
$content = str_replace('{current_year}', date('Y'), $content);
// Render with design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
$content = $renderer->render_html($design_path, $content, $subject, [
'site_name' => $site_name,
'site_url' => home_url(),
]);
}
return new WP_REST_Response([
'success' => true,
'subject' => $subject,
'html' => $content,
]);
}
}

View File

@@ -32,6 +32,18 @@ class CheckoutController {
'callback' => [ new self(), 'get_fields' ],
'permission_callback' => [ \WooNooW\Api\Permissions::class, 'anon_or_wp_nonce' ],
]);
// Public order view endpoint for thank you page
register_rest_route($namespace, '/checkout/order/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [ new self(), 'get_order' ],
'permission_callback' => '__return_true', // Public, validated via order_key
'args' => [
'key' => [
'type' => 'string',
'required' => false,
],
],
]);
}
/**
@@ -133,6 +145,69 @@ class CheckoutController {
];
}
/**
* Public order view endpoint for thank you page
* Validates access via order_key (for guests) or logged-in customer ID
* GET /checkout/order/{id}?key=wc_order_xxx
*/
public function get_order(WP_REST_Request $r): array {
$order_id = absint($r['id']);
$order_key = sanitize_text_field($r->get_param('key') ?? '');
if (!$order_id) {
return ['error' => __('Invalid order ID', 'woonoow')];
}
$order = wc_get_order($order_id);
if (!$order) {
return ['error' => __('Order not found', 'woonoow')];
}
// Validate access: order_key must match OR user must be logged in and own the order
$valid_key = $order_key && hash_equals($order->get_order_key(), $order_key);
$valid_owner = is_user_logged_in() && get_current_user_id() === $order->get_customer_id();
if (!$valid_key && !$valid_owner) {
return ['error' => __('Unauthorized access to order', 'woonoow')];
}
// Build order items
$items = [];
foreach ($order->get_items() as $item) {
$product = $item->get_product();
$items[] = [
'id' => $item->get_id(),
'product_id' => $product ? $product->get_id() : 0,
'name' => $item->get_name(),
'qty' => (int) $item->get_quantity(),
'price' => (float) $item->get_total() / max(1, $item->get_quantity()),
'total' => (float) $item->get_total(),
'image' => $product ? wp_get_attachment_image_url($product->get_image_id(), 'thumbnail') : null,
];
}
return [
'ok' => true,
'id' => $order->get_id(),
'number' => $order->get_order_number(),
'status' => $order->get_status(),
'subtotal' => (float) $order->get_subtotal(),
'shipping_total' => (float) $order->get_shipping_total(),
'tax_total' => (float) $order->get_total_tax(),
'total' => (float) $order->get_total(),
'currency' => $order->get_currency(),
'currency_symbol' => get_woocommerce_currency_symbol($order->get_currency()),
'payment_method' => $order->get_payment_method_title(),
'billing' => [
'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(),
'email' => $order->get_billing_email(),
'phone' => $order->get_billing_phone(),
],
'items' => $items,
];
}
/**
* Submit an order:
* {
@@ -187,6 +262,68 @@ class CheckoutController {
update_user_meta($user_id, 'billing_email', sanitize_email($billing['email']));
}
}
} else {
// Guest checkout - check if auto-register is enabled
$customer_settings = \WooNooW\Compat\CustomerSettingsProvider::get_settings();
$auto_register = $customer_settings['auto_register_members'] ?? false;
if ($auto_register && !empty($payload['billing']['email'])) {
$email = sanitize_email($payload['billing']['email']);
// Check if user already exists
$existing_user = get_user_by('email', $email);
if ($existing_user) {
// User exists - link order to them
$order->set_customer_id($existing_user->ID);
} else {
// Create new user account
$password = wp_generate_password(12, true, true);
$userdata = [
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'first_name' => sanitize_text_field($payload['billing']['first_name'] ?? ''),
'last_name' => sanitize_text_field($payload['billing']['last_name'] ?? ''),
'display_name' => trim((sanitize_text_field($payload['billing']['first_name'] ?? '') . ' ' . sanitize_text_field($payload['billing']['last_name'] ?? ''))) ?: $email,
'role' => 'customer', // WooCommerce customer role
];
$new_user_id = wp_insert_user($userdata);
if (!is_wp_error($new_user_id)) {
// Link order to new user
$order->set_customer_id($new_user_id);
// Store temp password in user meta for email template
// The real password is already set via wp_insert_user
update_user_meta($new_user_id, '_woonoow_temp_password', $password);
// AUTO-LOGIN: Set authentication cookie so user is logged in after page reload
wp_set_auth_cookie($new_user_id, true);
wp_set_current_user($new_user_id);
// Set WooCommerce customer billing data
$customer = new \WC_Customer($new_user_id);
if (!empty($payload['billing']['first_name'])) $customer->set_billing_first_name(sanitize_text_field($payload['billing']['first_name']));
if (!empty($payload['billing']['last_name'])) $customer->set_billing_last_name(sanitize_text_field($payload['billing']['last_name']));
if (!empty($payload['billing']['email'])) $customer->set_billing_email(sanitize_email($payload['billing']['email']));
if (!empty($payload['billing']['phone'])) $customer->set_billing_phone(sanitize_text_field($payload['billing']['phone']));
if (!empty($payload['billing']['address_1'])) $customer->set_billing_address_1(sanitize_text_field($payload['billing']['address_1']));
if (!empty($payload['billing']['city'])) $customer->set_billing_city(sanitize_text_field($payload['billing']['city']));
if (!empty($payload['billing']['state'])) $customer->set_billing_state(sanitize_text_field($payload['billing']['state']));
if (!empty($payload['billing']['postcode'])) $customer->set_billing_postcode(sanitize_text_field($payload['billing']['postcode']));
if (!empty($payload['billing']['country'])) $customer->set_billing_country(sanitize_text_field($payload['billing']['country']));
$customer->save();
// Send new account email (WooCommerce will handle this automatically via hook)
do_action('woocommerce_created_customer', $new_user_id, $userdata, $password);
}
}
}
}
// Add items
@@ -265,6 +402,12 @@ class CheckoutController {
header('Server-Timing: app;dur=' . round((microtime(true) - $__t0) * 1000, 1));
}
// Clear WooCommerce cart after successful order placement
// This ensures the cart page won't re-populate from server session
if (function_exists('WC') && WC()->cart) {
WC()->cart->empty_cart();
}
return [
'ok' => true,
'order_id' => $order->get_id(),

View File

@@ -32,12 +32,12 @@ class ModuleSettingsController extends WP_REST_Controller {
* Register routes
*/
public function register_routes() {
// GET /woonoow/v1/modules/{module_id}/settings
// GET /woonoow/v1/modules/{module_id}/settings (public - needed by frontend)
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<module_id>[a-zA-Z0-9_-]+)/settings', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_settings'],
'permission_callback' => [$this, 'check_permission'],
'permission_callback' => '__return_true', // Public: settings are non-sensitive, needed by customer pages
'args' => [
'module_id' => [
'required' => true,

View File

@@ -56,6 +56,23 @@ class NewsletterController {
return current_user_can('manage_options');
},
]);
// Public unsubscribe endpoint (no auth needed, uses token)
register_rest_route(self::API_NAMESPACE, '/newsletter/unsubscribe', [
'methods' => 'GET',
'callback' => [__CLASS__, 'unsubscribe'],
'permission_callback' => '__return_true',
'args' => [
'email' => [
'required' => true,
'type' => 'string',
],
'token' => [
'required' => true,
'type' => 'string',
],
],
]);
}
public static function get_template(WP_REST_Request $request) {
@@ -197,4 +214,78 @@ class NewsletterController {
],
], 200);
}
/**
* Handle unsubscribe request
*/
public static function unsubscribe(WP_REST_Request $request) {
$email = sanitize_email(urldecode($request->get_param('email')));
$token = sanitize_text_field($request->get_param('token'));
// Verify token
$expected_token = self::generate_unsubscribe_token($email);
if (!hash_equals($expected_token, $token)) {
return new WP_REST_Response([
'success' => false,
'message' => __('Invalid unsubscribe link', 'woonoow'),
], 400);
}
// Get subscribers
$subscribers = get_option('woonoow_newsletter_subscribers', []);
$found = false;
foreach ($subscribers as &$sub) {
if (isset($sub['email']) && $sub['email'] === $email) {
$sub['status'] = 'unsubscribed';
$sub['unsubscribed_at'] = current_time('mysql');
$found = true;
break;
}
}
if (!$found) {
return new WP_REST_Response([
'success' => false,
'message' => __('Email not found', 'woonoow'),
], 404);
}
update_option('woonoow_newsletter_subscribers', $subscribers);
do_action('woonoow_newsletter_unsubscribed', $email);
// Return HTML page for nice UX
$site_name = get_bloginfo('name');
$html = sprintf(
'<!DOCTYPE html><html><head><title>%s</title><style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}.box{background:white;padding:40px;border-radius:8px;text-align:center;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;}h1{color:#333;margin-bottom:16px;}p{color:#666;}</style></head><body><div class="box"><h1>✓ Unsubscribed</h1><p>You have been unsubscribed from %s newsletter.</p></div></body></html>',
__('Unsubscribed', 'woonoow'),
esc_html($site_name)
);
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
/**
* Generate secure unsubscribe token
*/
private static function generate_unsubscribe_token($email) {
$secret = wp_salt('auth');
return hash_hmac('sha256', $email, $secret);
}
/**
* Generate unsubscribe URL for email templates
*/
public static function generate_unsubscribe_url($email) {
$token = self::generate_unsubscribe_token($email);
$base_url = rest_url('woonoow/v1/newsletter/unsubscribe');
return add_query_arg([
'email' => urlencode($email),
'token' => $token,
], $base_url);
}
}

View File

@@ -69,7 +69,7 @@ class NotificationsController {
],
]);
// GET/PUT /woonoow/v1/notifications/templates/:eventId/:channelId
// GET/POST /woonoow/v1/notifications/templates/:eventId/:channelId
register_rest_route($this->namespace, '/' . $this->rest_base . '/templates/(?P<eventId>[a-zA-Z0-9_-]+)/(?P<channelId>[a-zA-Z0-9_-]+)', [
[
'methods' => 'GET',
@@ -77,7 +77,7 @@ class NotificationsController {
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'PUT',
'methods' => 'POST',
'callback' => [$this, 'save_template'],
'permission_callback' => [$this, 'check_permission'],
],
@@ -486,6 +486,9 @@ class NotificationsController {
}
}
// Add available variables for this event (contextual)
$template['available_variables'] = EventRegistry::get_variables_for_event($event_id, $recipient_type);
return new WP_REST_Response($template, 200);
}

View File

@@ -38,11 +38,6 @@ class Permissions {
$has_wc = current_user_can('manage_woocommerce');
$has_opts = current_user_can('manage_options');
$result = $has_wc || $has_opts;
error_log(sprintf('WooNooW Permissions: check_admin_permission() - WC:%s Options:%s Result:%s',
$has_wc ? 'YES' : 'NO',
$has_opts ? 'YES' : 'NO',
$result ? 'ALLOWED' : 'DENIED'
));
return $result;
}
}

View File

@@ -447,6 +447,7 @@ class ProductsController {
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price']));
if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
@@ -800,16 +801,19 @@ class ProductsController {
$value = $term ? $term->name : $value;
}
} else {
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
$meta_key = 'attribute_' . $attr_name;
// Custom attribute - stored as lowercase in meta
$meta_key = 'attribute_' . strtolower($attr_name);
$value = get_post_meta($variation_id, $meta_key, true);
// Capitalize the attribute name for display
// Capitalize the attribute name for display to match admin SPA
$clean_name = ucfirst($attr_name);
}
// Only add if value exists
if (!empty($value)) {
$formatted_attributes[$clean_name] = $value;
}
}
$image_url = $image ? $image[0] : '';
if (!$image_url && $variation->get_image_id()) {
@@ -857,36 +861,106 @@ class ProductsController {
* Save product variations
*/
private static function save_product_variations($product, $variations_data) {
// Get existing variation IDs
$existing_variation_ids = $product->get_children();
$variations_to_keep = [];
foreach ($variations_data as $var_data) {
if (isset($var_data['id']) && $var_data['id']) {
// Update existing variation
$variation = wc_get_product($var_data['id']);
if (!$variation) continue;
$variations_to_keep[] = $var_data['id'];
} else {
// Create new variation
$variation = new WC_Product_Variation();
$variation->set_parent_id($product->get_id());
}
if ($variation) {
// Build attributes array
$wc_attributes = [];
if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
$parent_attributes = $product->get_attributes();
foreach ($var_data['attributes'] as $display_name => $value) {
if (empty($value)) continue;
foreach ($parent_attributes as $attr_name => $parent_attr) {
if (!$parent_attr->get_variation()) continue;
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
$wc_attributes[strtolower($attr_name)] = strtolower($value);
break;
}
}
}
}
if (!empty($wc_attributes)) {
$variation->set_attributes($wc_attributes);
}
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']);
// Set prices - if not provided, use parent's price as fallback
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
$variation->set_regular_price($var_data['regular_price']);
} elseif (!$variation->get_regular_price()) {
// Fallback to parent price if variation has no price
$parent_price = $product->get_regular_price();
if ($parent_price) {
$variation->set_regular_price($parent_price);
}
}
if (isset($var_data['sale_price']) && $var_data['sale_price'] !== '') {
$variation->set_sale_price($var_data['sale_price']);
}
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
if (isset($var_data['attributes'])) $variation->set_attributes($var_data['attributes']);
// Handle image - support both image_id and image URL
if (isset($var_data['image']) && !empty($var_data['image'])) {
$image_id = attachment_url_to_postid($var_data['image']);
if ($image_id) {
$variation->set_image_id($image_id);
}
if ($image_id) $variation->set_image_id($image_id);
} elseif (isset($var_data['image_id'])) {
$variation->set_image_id($var_data['image_id']);
}
$variation->save();
// Save variation first
$saved_id = $variation->save();
$variations_to_keep[] = $saved_id;
// Manually save attributes using direct database insert
if (!empty($wc_attributes)) {
global $wpdb;
foreach ($wc_attributes as $attr_name => $attr_value) {
$meta_key = 'attribute_' . $attr_name;
$wpdb->delete(
$wpdb->postmeta,
['post_id' => $saved_id, 'meta_key' => $meta_key],
['%d', '%s']
);
$wpdb->insert(
$wpdb->postmeta,
[
'post_id' => $saved_id,
'meta_key' => $meta_key,
'meta_value' => $attr_value
],
['%d', '%s', '%s']
);
}
}
}
// Delete variations that are no longer in the list
$variations_to_delete = array_diff($existing_variation_ids, $variations_to_keep);
foreach ($variations_to_delete as $variation_id) {
$variation_to_delete = wc_get_product($variation_id);
if ($variation_to_delete) {
$variation_to_delete->delete(true);
}
}
}

View File

@@ -23,6 +23,7 @@ use WooNooW\Api\CustomersController;
use WooNooW\Api\NewsletterController;
use WooNooW\Api\ModulesController;
use WooNooW\Api\ModuleSettingsController;
use WooNooW\Api\CampaignsController;
use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController;
@@ -64,6 +65,34 @@ class Routes {
'permission_callback' => '__return_true',
] );
// Customer login endpoint (no admin permission required)
register_rest_route( $namespace, '/auth/customer-login', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'customer_login' ],
'permission_callback' => '__return_true',
] );
// Forgot password endpoint (public)
register_rest_route( $namespace, '/auth/forgot-password', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'forgot_password' ],
'permission_callback' => '__return_true',
] );
// Validate password reset key (public)
register_rest_route( $namespace, '/auth/validate-reset-key', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'validate_reset_key' ],
'permission_callback' => '__return_true',
] );
// Reset password with key (public)
register_rest_route( $namespace, '/auth/reset-password', [
'methods' => 'POST',
'callback' => [ AuthController::class, 'reset_password' ],
'permission_callback' => '__return_true',
] );
// Defer to controllers to register their endpoints
CheckoutController::register();
OrdersController::register();
@@ -125,6 +154,9 @@ class Routes {
// Newsletter controller
NewsletterController::register_routes();
// Campaigns controller
CampaignsController::register_routes();
// Modules controller
$modules_controller = new ModulesController();
$modules_controller->register_routes();

View File

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

View File

@@ -22,6 +22,7 @@ use WooNooW\Core\DataStores\OrderStore;
use WooNooW\Core\MediaUpload;
use WooNooW\Core\Notifications\PushNotificationHandler;
use WooNooW\Core\Notifications\EmailManager;
use WooNooW\Core\Campaigns\CampaignManager;
use WooNooW\Core\ActivityLog\ActivityLogTable;
use WooNooW\Branding;
use WooNooW\Frontend\Assets as FrontendAssets;
@@ -40,10 +41,11 @@ class Bootstrap {
MediaUpload::init();
PushNotificationHandler::init();
EmailManager::instance(); // Initialize custom email system
CampaignManager::init(); // Initialize campaigns CPT
// Frontend (customer-spa)
FrontendAssets::init();
Shortcodes::init();
// Note: Shortcodes removed - WC pages now redirect to SPA routes via TemplateOverride
TemplateOverride::init();
new PageAppearance();
@@ -66,5 +68,64 @@ class Bootstrap {
MailQueue::init();
WooEmailOverride::init();
OrderStore::init();
// Initialize cart for REST API requests
add_action('woocommerce_init', [self::class, 'init_cart_for_rest_api']);
// Load custom variation attributes for WooCommerce admin
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
}
/**
* Properly initialize WooCommerce cart for REST API requests
* This is the recommended approach per WooCommerce core team
*/
public static function init_cart_for_rest_api() {
// Only load cart for REST API requests
if (!WC()->is_rest_api_request()) {
return;
}
// Load frontend includes (required for cart)
WC()->frontend_includes();
// Load cart using WooCommerce's official method
if (null === WC()->cart && function_exists('wc_load_cart')) {
wc_load_cart();
}
}
/**
* Load custom variation attributes from post meta for WooCommerce admin
* This ensures WooCommerce's native admin displays custom attributes correctly
*/
public static function load_variation_attributes($variation) {
if (!$variation instanceof \WC_Product_Variation) {
return;
}
$parent = wc_get_product($variation->get_parent_id());
if (!$parent) {
return;
}
$attributes = [];
foreach ($parent->get_attributes() as $attr_name => $attribute) {
if (!$attribute->get_variation()) {
continue;
}
// Read from post meta (stored as lowercase)
$meta_key = 'attribute_' . strtolower($attr_name);
$value = get_post_meta($variation->get_id(), $meta_key, true);
if (!empty($value)) {
$attributes[strtolower($attr_name)] = $value;
}
}
if (!empty($attributes)) {
$variation->set_attributes($attributes);
}
}
}

View File

@@ -0,0 +1,479 @@
<?php
/**
* Campaign Manager
*
* Manages newsletter campaign CRUD operations and sending
*
* @package WooNooW\Core\Campaigns
*/
namespace WooNooW\Core\Campaigns;
if (!defined('ABSPATH')) exit;
class CampaignManager {
const POST_TYPE = 'wnw_campaign';
const CRON_HOOK = 'woonoow_process_scheduled_campaigns';
private static $instance = null;
/**
* Get instance
*/
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Initialize
*/
public static function init() {
add_action('init', [__CLASS__, 'register_post_type']);
add_action(self::CRON_HOOK, [__CLASS__, 'process_scheduled_campaigns']);
}
/**
* Register campaign post type
*/
public static function register_post_type() {
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('Campaigns', 'woonoow'),
'singular_name' => __('Campaign', 'woonoow'),
],
'public' => false,
'show_ui' => false,
'show_in_rest' => false,
'supports' => ['title'],
'capability_type' => 'post',
'map_meta_cap' => true,
]);
}
/**
* Create a new campaign
*
* @param array $data Campaign data
* @return int|WP_Error Campaign ID or error
*/
public static function create($data) {
$post_data = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'),
];
$campaign_id = wp_insert_post($post_data, true);
if (is_wp_error($campaign_id)) {
return $campaign_id;
}
// Save meta fields
self::update_meta($campaign_id, $data);
return $campaign_id;
}
/**
* Update campaign
*
* @param int $campaign_id Campaign ID
* @param array $data Campaign data
* @return bool|WP_Error
*/
public static function update($campaign_id, $data) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow'));
}
// Update title if provided
if (isset($data['title'])) {
wp_update_post([
'ID' => $campaign_id,
'post_title' => sanitize_text_field($data['title']),
]);
}
// Update meta fields
self::update_meta($campaign_id, $data);
return true;
}
/**
* Update campaign meta
*
* @param int $campaign_id
* @param array $data
*/
private static function update_meta($campaign_id, $data) {
$meta_fields = [
'subject' => '_wnw_subject',
'content' => '_wnw_content',
'status' => '_wnw_status',
'scheduled_at' => '_wnw_scheduled_at',
];
foreach ($meta_fields as $key => $meta_key) {
if (isset($data[$key])) {
$value = $data[$key];
// Sanitize based on field type
if ($key === 'content') {
$value = wp_kses_post($value);
} elseif ($key === 'scheduled_at') {
$value = sanitize_text_field($value);
} elseif ($key === 'status') {
$allowed = ['draft', 'scheduled', 'sending', 'sent', 'failed'];
$value = in_array($value, $allowed) ? $value : 'draft';
} else {
$value = sanitize_text_field($value);
}
update_post_meta($campaign_id, $meta_key, $value);
}
}
// Set default status if not provided
if (!get_post_meta($campaign_id, '_wnw_status', true)) {
update_post_meta($campaign_id, '_wnw_status', 'draft');
}
}
/**
* Get campaign by ID
*
* @param int $campaign_id
* @return array|null
*/
public static function get($campaign_id) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return null;
}
return self::format_campaign($post);
}
/**
* Get all campaigns
*
* @param array $args Query args
* @return array
*/
public static function get_all($args = []) {
$defaults = [
'post_type' => self::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'DESC',
];
$query_args = wp_parse_args($args, $defaults);
$query_args['post_type'] = self::POST_TYPE; // Force post type
$posts = get_posts($query_args);
return array_map([__CLASS__, 'format_campaign'], $posts);
}
/**
* Format campaign post to array
*
* @param WP_Post $post
* @return array
*/
private static function format_campaign($post) {
return [
'id' => $post->ID,
'title' => $post->post_title,
'subject' => get_post_meta($post->ID, '_wnw_subject', true),
'content' => get_post_meta($post->ID, '_wnw_content', true),
'status' => get_post_meta($post->ID, '_wnw_status', true) ?: 'draft',
'scheduled_at' => get_post_meta($post->ID, '_wnw_scheduled_at', true),
'sent_at' => get_post_meta($post->ID, '_wnw_sent_at', true),
'recipient_count' => (int) get_post_meta($post->ID, '_wnw_recipient_count', true),
'sent_count' => (int) get_post_meta($post->ID, '_wnw_sent_count', true),
'failed_count' => (int) get_post_meta($post->ID, '_wnw_failed_count', true),
'created_at' => $post->post_date,
'updated_at' => $post->post_modified,
];
}
/**
* Delete campaign
*
* @param int $campaign_id
* @return bool
*/
public static function delete($campaign_id) {
$post = get_post($campaign_id);
if (!$post || $post->post_type !== self::POST_TYPE) {
return false;
}
return wp_delete_post($campaign_id, true) !== false;
}
/**
* Send campaign
*
* @param int $campaign_id
* @return array Result with sent/failed counts
*/
public static function send($campaign_id) {
$campaign = self::get($campaign_id);
if (!$campaign) {
return ['success' => false, 'error' => __('Campaign not found', 'woonoow')];
}
if ($campaign['status'] === 'sent') {
return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')];
}
// Get subscribers
$subscribers = self::get_subscribers();
if (empty($subscribers)) {
return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')];
}
// Update status to sending
update_post_meta($campaign_id, '_wnw_status', 'sending');
update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers));
$sent = 0;
$failed = 0;
// Get email template
$template = self::render_campaign_email($campaign);
// Send in batches
$batch_size = 50;
$batches = array_chunk($subscribers, $batch_size);
foreach ($batches as $batch) {
foreach ($batch as $subscriber) {
$email = $subscriber['email'];
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body);
// Send email
$result = wp_mail(
$email,
$template['subject'],
$body,
['Content-Type: text/html; charset=UTF-8']
);
if ($result) {
$sent++;
} else {
$failed++;
}
}
// Small delay between batches
if (count($batches) > 1) {
sleep(2);
}
}
// Update campaign stats
update_post_meta($campaign_id, '_wnw_sent_count', $sent);
update_post_meta($campaign_id, '_wnw_failed_count', $failed);
update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql'));
update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent');
return [
'success' => true,
'sent' => $sent,
'failed' => $failed,
'total' => count($subscribers),
];
}
/**
* Send test email
*
* @param int $campaign_id
* @param string $email Test email address
* @return bool
*/
public static function send_test($campaign_id, $email) {
$campaign = self::get($campaign_id);
if (!$campaign) {
return false;
}
$template = self::render_campaign_email($campaign);
// Replace subscriber-specific variables
$body = str_replace('{subscriber_email}', $email, $template['body']);
$body = str_replace('{unsubscribe_url}', '#', $body);
return wp_mail(
$email,
'[TEST] ' . $template['subject'],
$body,
['Content-Type: text/html; charset=UTF-8']
);
}
/**
* Render campaign email using EmailRenderer
*
* @param array $campaign
* @return array ['subject' => string, 'body' => string]
*/
private static function render_campaign_email($campaign) {
$renderer = \WooNooW\Core\Notifications\EmailRenderer::instance();
// Get the campaign email template
$template = $renderer->get_template_settings('newsletter_campaign', 'customer');
// Fallback if no template configured
if (!$template) {
$subject = $campaign['subject'] ?: $campaign['title'];
$body = $campaign['content'];
} else {
$subject = $template['subject'] ?: $campaign['subject'];
// Replace {content} with campaign content
$body = str_replace('{content}', $campaign['content'], $template['body']);
// Replace {campaign_title}
$body = str_replace('{campaign_title}', $campaign['title'], $body);
}
// Replace common variables
$site_name = get_bloginfo('name');
$site_url = home_url();
$subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject);
$body = str_replace(['{site_name}', '{store_name}'], $site_name, $body);
$body = str_replace('{site_url}', $site_url, $body);
$body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body);
$body = str_replace('{current_year}', date('Y'), $body);
// Render through email design template
$design_path = $renderer->get_design_template();
if (file_exists($design_path)) {
$body = $renderer->render_html($design_path, $body, $subject, [
'site_name' => $site_name,
'site_url' => $site_url,
]);
}
return [
'subject' => $subject,
'body' => $body,
];
}
/**
* Get subscribers
*
* @return array
*/
private static function get_subscribers() {
// Check if using custom table
$use_table = !get_option('woonoow_newsletter_limit_enabled', true);
if ($use_table && self::has_subscribers_table()) {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_results(
"SELECT email, user_id FROM {$table} WHERE status = 'active'",
ARRAY_A
);
}
// Use wp_options storage
$subscribers = get_option('woonoow_newsletter_subscribers', []);
return array_filter($subscribers, function($sub) {
return ($sub['status'] ?? 'active') === 'active';
});
}
/**
* Check if subscribers table exists
*
* @return bool
*/
private static function has_subscribers_table() {
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscribers';
return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table;
}
/**
* Get unsubscribe URL
*
* @param string $email
* @return string
*/
private static function get_unsubscribe_url($email) {
// Use NewsletterController's secure token-based URL
return \WooNooW\API\NewsletterController::generate_unsubscribe_url($email);
}
/**
* Process scheduled campaigns (WP-Cron)
*/
public static function process_scheduled_campaigns() {
// Only if scheduling is enabled
if (!get_option('woonoow_campaign_scheduling_enabled', false)) {
return;
}
$campaigns = self::get_all([
'meta_query' => [
[
'key' => '_wnw_status',
'value' => 'scheduled',
],
[
'key' => '_wnw_scheduled_at',
'value' => current_time('mysql'),
'compare' => '<=',
'type' => 'DATETIME',
],
],
]);
foreach ($campaigns as $campaign) {
self::send($campaign['id']);
}
}
/**
* Enable scheduling (registers cron)
*/
public static function enable_scheduling() {
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
}
}
/**
* Disable scheduling (clears cron)
*/
public static function disable_scheduling() {
wp_clear_scheduled_hook(self::CRON_HOOK);
}
}

View File

@@ -60,6 +60,9 @@ class EmailManager {
// New customer account
add_action('woocommerce_created_customer', [$this, 'send_new_customer_email'], 10, 3);
// Password reset - intercept WordPress default email and use our template
add_filter('retrieve_password_message', [$this, 'handle_password_reset_email'], 10, 4);
// Low stock / Out of stock
add_action('woocommerce_low_stock', [$this, 'send_low_stock_email'], 10, 1);
add_action('woocommerce_no_stock', [$this, 'send_out_of_stock_email'], 10, 1);
@@ -304,6 +307,110 @@ class EmailManager {
]);
}
/**
* Handle password reset email - intercept WordPress default and use our template
*
* @param string $message Email message (we replace this)
* @param string $key Reset key
* @param string $user_login User login
* @param WP_User $user_data User object
* @return string Empty string to prevent WordPress sending default email
*/
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
// Check if WooNooW notification system is enabled
if (!self::is_enabled()) {
return $message; // Use WordPress default
}
// Check if event is enabled
if (!$this->is_event_enabled('password_reset', 'email', 'customer')) {
return $message; // Use WordPress default
}
// Build reset URL - use SPA page from appearance settings
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if ($spa_page_id > 0) {
$spa_url = get_permalink($spa_page_id);
} else {
// Fallback to home URL if SPA page not configured
$spa_url = home_url('/');
}
// Build SPA reset password URL with hash router format
// Format: /store/#/reset-password?key=KEY&login=LOGIN
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
// Create a pseudo WC_Customer for template rendering
$customer = null;
if (class_exists('WC_Customer')) {
try {
$customer = new \WC_Customer($user_data->ID);
} catch (\Exception $e) {
$customer = null;
}
}
// Send our custom email
$this->send_password_reset_email($user_data, $key, $reset_link, $customer);
// Return empty string to prevent WordPress from sending its default plain-text email
return '';
}
/**
* Send password reset email using our template
*
* @param WP_User $user User object
* @param string $key Reset key
* @param string $reset_link Full reset link URL
* @param WC_Customer|null $customer WooCommerce customer object if available
*/
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
// Get email renderer
$renderer = EmailRenderer::instance();
// Build extra data for template variables
$extra_data = [
'reset_key' => $key,
'reset_link' => $reset_link,
'user_login' => $user->user_login,
'user_email' => $user->user_email,
'customer_name' => $user->display_name ?: $user->user_login,
'customer_email' => $user->user_email,
];
// Use WC_Customer if available for better template rendering
$data = $customer ?: $user;
// Render email
$email = $renderer->render('password_reset', 'customer', $data, $extra_data);
if (!$email) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailManager] Password reset email rendering failed for user: ' . $user->user_login);
}
return;
}
// Send email via wp_mail
$headers = [
'Content-Type: text/html; charset=UTF-8',
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
];
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailManager] Password reset email sent to ' . $email['to'] . ' - Result: ' . ($sent ? 'success' : 'failed'));
}
// Log email sent
do_action('woonoow_email_sent', 'password_reset', 'customer', $email);
}
/**
* Send low stock email
*

View File

@@ -140,9 +140,12 @@ class EmailRenderer {
*/
private function get_variables($event_id, $data, $extra_data = []) {
$variables = [
'site_name' => get_bloginfo('name'),
'site_title' => get_bloginfo('name'),
'store_name' => get_bloginfo('name'),
'store_url' => home_url(),
'site_title' => get_bloginfo('name'),
'shop_url' => get_permalink(wc_get_page_id('shop')),
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'support_email' => get_option('admin_email'),
'current_year' => date('Y'),
];
@@ -250,6 +253,14 @@ class EmailRenderer {
// Customer variables
if ($data instanceof \WC_Customer) {
// Get temp password from user meta (stored during auto-registration)
$user_temp_password = get_user_meta($data->get_id(), '_woonoow_temp_password', true);
// Generate login URL (pointing to SPA login instead of wp-login)
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$login_url = $spa_page_id ? get_permalink($spa_page_id) . '#/login' : wp_login_url();
$variables = array_merge($variables, [
'customer_id' => $data->get_id(),
'customer_name' => $data->get_display_name(),
@@ -257,6 +268,10 @@ class EmailRenderer {
'customer_last_name' => $data->get_last_name(),
'customer_email' => $data->get_email(),
'customer_username' => $data->get_username(),
'user_temp_password' => $user_temp_password ?: '',
'login_url' => $login_url,
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
'shop_url' => get_permalink(wc_get_page_id('shop')),
]);
}
@@ -273,8 +288,11 @@ class EmailRenderer {
* @return string
*/
private function parse_cards($content) {
// Match [card ...] ... [/card] patterns
preg_match_all('/\[card([^\]]*)\](.*?)\[\/card\]/s', $content, $matches, PREG_SET_ORDER);
// Use a single unified regex to match BOTH syntaxes in document order
// This ensures cards are rendered in the order they appear
$combined_pattern = '/\[card(?::(\w+)|([^\]]*)?)\](.*?)\[\/card\]/s';
preg_match_all($combined_pattern, $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
if (empty($matches)) {
// No cards found, wrap entire content in a single card
@@ -283,8 +301,19 @@ class EmailRenderer {
$html = '';
foreach ($matches as $match) {
$attributes = $this->parse_card_attributes($match[1]);
$card_content = $match[2];
// Determine which syntax was matched
$full_match = $match[0][0];
$new_syntax_type = !empty($match[1][0]) ? $match[1][0] : null; // [card:type] format
$old_syntax_attrs = $match[2][0] ?? ''; // [card type="..."] format
$card_content = $match[3][0];
if ($new_syntax_type) {
// NEW syntax [card:type]
$attributes = ['type' => $new_syntax_type];
} else {
// OLD syntax [card type="..."] or [card]
$attributes = $this->parse_card_attributes($old_syntax_attrs);
}
$html .= $this->render_card($card_content, $attributes);
$html .= $this->render_card_spacing();
@@ -337,10 +366,65 @@ class EmailRenderer {
// Get email customization settings for colors
$email_settings = get_option('woonoow_email_settings', []);
$primary_color = $email_settings['primary_color'] ?? '#7f54b3';
$secondary_color = $email_settings['secondary_color'] ?? '#7f54b3';
$button_text_color = $email_settings['button_text_color'] ?? '#ffffff';
$hero_gradient_start = $email_settings['hero_gradient_start'] ?? '#667eea';
$hero_gradient_end = $email_settings['hero_gradient_end'] ?? '#764ba2';
$hero_text_color = $email_settings['hero_text_color'] ?? '#ffffff';
// Parse button shortcodes with FULL INLINE STYLES for Gmail compatibility
// Helper function to generate button HTML
$generateButtonHtml = function($url, $style, $text) use ($primary_color, $secondary_color, $button_text_color) {
if ($style === 'outline') {
// Outline button - transparent background with border
$button_style = sprintf(
'display: inline-block; background-color: transparent; color: %s; padding: 14px 28px; border: 2px solid %s; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
esc_attr($secondary_color),
esc_attr($secondary_color)
);
} else {
// Solid button - full background color
$button_style = sprintf(
'display: inline-block; background-color: %s; color: %s; padding: 14px 28px; border: none; border-radius: 6px; text-decoration: none; font-weight: 600; font-family: "Inter", Arial, sans-serif; font-size: 16px; text-align: center; mso-padding-alt: 0;',
esc_attr($primary_color),
esc_attr($button_text_color)
);
}
// Use table-based button for better email client compatibility
return sprintf(
'<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 16px auto;"><tr><td align="center"><a href="%s" style="%s">%s</a></td></tr></table>',
esc_url($url),
$button_style,
esc_html($text)
);
};
// NEW FORMAT: [button:style](url)Text[/button]
$content = preg_replace_callback(
'/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/',
function($matches) use ($generateButtonHtml) {
$style = $matches[1]; // solid or outline
$url = $matches[2];
$text = trim($matches[3]);
return $generateButtonHtml($url, $style, $text);
},
$content
);
// OLD FORMAT: [button url="..." style="solid|outline"]Text[/button]
$content = preg_replace_callback(
'/\[button\s+url=["\']([^"\']+)["\'](?:\s+style=["\'](solid|outline)["\'])?\]([^\[]+)\[\/button\]/',
function($matches) use ($generateButtonHtml) {
$url = $matches[1];
$style = $matches[2] ?? 'solid';
$text = trim($matches[3]);
return $generateButtonHtml($url, $style, $text);
},
$content
);
$class = 'card';
$style = 'width: 100%; background-color: #ffffff; border-radius: 8px;';
$content_style = 'padding: 32px 40px;';
@@ -367,15 +451,15 @@ class EmailRenderer {
}
// Success card - green theme
elseif ($type === 'success') {
$style .= ' background-color: #f0fdf4; border-left: 4px solid #22c55e;';
$style .= ' background-color: #f0fdf4;';
}
// Info card - blue theme
elseif ($type === 'info') {
$style .= ' background-color: #f0f7ff; border-left: 4px solid #0071e3;';
$style .= ' background-color: #f0f7ff;';
}
// Warning card - orange theme
// Warning card - orange/yellow theme
elseif ($type === 'warning') {
$style .= ' background-color: #fff8e1; border-left: 4px solid #ff9800;';
$style .= ' background-color: #fff8e1;';
}
}
@@ -556,8 +640,13 @@ class EmailRenderer {
* @return string
*/
private function get_social_icon_url($platform, $color = 'white') {
// Use local PNG icons
$plugin_url = plugin_dir_url(dirname(dirname(dirname(__FILE__))));
// Use plugin URL constant if available, otherwise calculate from file path
if (defined('WOONOOW_URL')) {
$plugin_url = WOONOOW_URL;
} else {
// File is at includes/Core/Notifications/EmailRenderer.php - need 4 levels up
$plugin_url = plugin_dir_url(dirname(dirname(dirname(dirname(__FILE__)))));
}
$filename = sprintf('mage--%s-%s.png', $platform, $color);
return $plugin_url . 'assets/icons/' . $filename;
}

View File

@@ -43,6 +43,22 @@ class EventRegistry {
'wc_email' => 'customer_new_account',
'enabled' => true,
],
'password_reset' => [
'id' => 'password_reset',
'label' => __('Password Reset', 'woonoow'),
'description' => __('When a customer requests a password reset', 'woonoow'),
'category' => 'customers',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => [
'{reset_link}' => __('Password reset link', 'woonoow'),
'{reset_key}' => __('Password reset key', 'woonoow'),
'{user_login}' => __('Username', 'woonoow'),
'{user_email}' => __('User email', 'woonoow'),
'{site_name}' => __('Site name', 'woonoow'),
],
],
// ===== NEWSLETTER EVENTS =====
'newsletter_welcome' => [
@@ -63,6 +79,21 @@ class EventRegistry {
'wc_email' => '',
'enabled' => true,
],
'newsletter_campaign' => [
'id' => 'newsletter_campaign',
'label' => __('Newsletter Campaign', 'woonoow'),
'description' => __('Master email design template for newsletter campaigns', 'woonoow'),
'category' => 'marketing',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => [
'{content}' => __('Campaign content', 'woonoow'),
'{campaign_title}' => __('Campaign title', 'woonoow'),
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
],
],
// ===== ORDER INITIATION =====
'order_placed' => [
@@ -340,4 +371,150 @@ class EventRegistry {
public static function event_exists($event_id, $recipient_type) {
return self::get_event($event_id, $recipient_type) !== null;
}
/**
* Get variables available for a specific event
*
* Returns both common variables and event-specific variables
*
* @param string $event_id Event ID
* @param string $recipient_type Recipient type
* @return array Array of variable definitions with key => description
*/
public static function get_variables_for_event($event_id, $recipient_type = 'customer') {
// Common variables available for ALL events
$common = [
'{site_name}' => __('Store/Site name', 'woonoow'),
'{site_title}' => __('Site title', 'woonoow'),
'{store_url}' => __('Store URL', 'woonoow'),
'{shop_url}' => __('Shop page URL', 'woonoow'),
'{my_account_url}' => __('My Account page URL', 'woonoow'),
'{login_url}' => __('Login page URL', 'woonoow'),
'{support_email}' => __('Support email address', 'woonoow'),
'{current_year}' => __('Current year', 'woonoow'),
'{current_date}' => __('Current date', 'woonoow'),
];
// Customer variables (for customer-facing events)
$customer_vars = [
'{customer_name}' => __('Customer full name', 'woonoow'),
'{customer_first_name}' => __('Customer first name', 'woonoow'),
'{customer_last_name}' => __('Customer last name', 'woonoow'),
'{customer_email}' => __('Customer email', 'woonoow'),
'{customer_phone}' => __('Customer phone', 'woonoow'),
];
// Order variables (for order-related events)
$order_vars = [
'{order_id}' => __('Order ID/number', 'woonoow'),
'{order_number}' => __('Order number', 'woonoow'),
'{order_date}' => __('Order date', 'woonoow'),
'{order_total}' => __('Order total', 'woonoow'),
'{order_subtotal}' => __('Order subtotal', 'woonoow'),
'{order_tax}' => __('Order tax', 'woonoow'),
'{order_shipping}' => __('Shipping cost', 'woonoow'),
'{order_discount}' => __('Discount amount', 'woonoow'),
'{order_status}' => __('Order status', 'woonoow'),
'{order_url}' => __('Order details URL', 'woonoow'),
'{order_items_table}' => __('Order items table (HTML)', 'woonoow'),
'{billing_address}' => __('Billing address', 'woonoow'),
'{shipping_address}' => __('Shipping address', 'woonoow'),
'{payment_method}' => __('Payment method', 'woonoow'),
'{payment_status}' => __('Payment status', 'woonoow'),
'{shipping_method}' => __('Shipping method', 'woonoow'),
];
// Shipping/tracking variables (for shipped/delivered events)
$shipping_vars = [
'{tracking_number}' => __('Tracking number', 'woonoow'),
'{tracking_url}' => __('Tracking URL', 'woonoow'),
'{shipping_carrier}' => __('Shipping carrier', 'woonoow'),
'{estimated_delivery}' => __('Estimated delivery date', 'woonoow'),
];
// Product variables (for stock alerts)
$product_vars = [
'{product_name}' => __('Product name', 'woonoow'),
'{product_sku}' => __('Product SKU', 'woonoow'),
'{product_url}' => __('Product URL', 'woonoow'),
'{product_price}' => __('Product price', 'woonoow'),
'{stock_quantity}' => __('Stock quantity', 'woonoow'),
];
// Newsletter variables
$newsletter_vars = [
'{subscriber_email}' => __('Subscriber email', 'woonoow'),
'{subscriber_name}' => __('Subscriber name', 'woonoow'),
'{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'),
];
// Build variables based on event ID and category
$event = self::get_event($event_id, $recipient_type);
// If event not found, try to match by just event_id
if (!$event) {
$all_events = self::get_all_events();
foreach ($all_events as $e) {
if ($e['id'] === $event_id) {
$event = $e;
break;
}
}
}
// Start with common vars
$variables = $common;
// Add category-specific vars
if ($event) {
$category = $event['category'] ?? '';
// Add customer vars for customer-facing events
if (($event['recipient_type'] ?? '') === 'customer') {
$variables = array_merge($variables, $customer_vars);
}
// Add based on category
switch ($category) {
case 'orders':
$variables = array_merge($variables, $order_vars);
// Add tracking for completed/shipped events
if (in_array($event_id, ['order_completed', 'order_shipped', 'order_delivered'])) {
$variables = array_merge($variables, $shipping_vars);
}
break;
case 'products':
$variables = array_merge($variables, $product_vars);
break;
case 'marketing':
$variables = array_merge($variables, $newsletter_vars);
// Add campaign-specific for newsletter_campaign
if ($event_id === 'newsletter_campaign') {
$variables['{content}'] = __('Campaign content', 'woonoow');
$variables['{campaign_title}'] = __('Campaign title', 'woonoow');
}
break;
case 'customers':
$variables = array_merge($variables, $customer_vars);
// Add account-specific vars
if ($event_id === 'new_customer') {
$variables['{user_temp_password}'] = __('Temporary password', 'woonoow');
}
break;
}
// Add event-specific variables if defined
if (!empty($event['variables'])) {
$variables = array_merge($variables, $event['variables']);
}
}
// Sort alphabetically for easier browsing
ksort($variables);
return $variables;
}
}

View File

@@ -90,6 +90,7 @@ class DefaultTemplates
'order_cancelled' => self::customer_order_cancelled(),
'order_refunded' => self::customer_order_refunded(),
'new_customer' => self::customer_new_customer(),
'newsletter_campaign' => self::customer_newsletter_campaign(),
],
'staff' => [
'order_placed' => self::staff_order_placed(),
@@ -139,6 +140,7 @@ class DefaultTemplates
'order_cancelled' => 'Order #{order_number} has been cancelled',
'order_refunded' => 'Refund processed for order #{order_number}',
'new_customer' => 'Welcome to {site_name}! 🎁 Exclusive offer inside',
'newsletter_campaign' => '{campaign_title}',
],
'staff' => [
'order_placed' => '[NEW ORDER] #{order_number} - ${order_total} from {customer_name}',
@@ -194,18 +196,85 @@ Your account is ready. Here\'s what you can do now:
✓ Easy returns and refunds
[/card]
[button url="{my_account_url}"]Access Your Account[/button]
[button url="{shop_url}"]Start Shopping[/button]
[card type="success"]
**Your Login Credentials:**
[card type="info"]
💡 **Tip:** Check your account settings to receive personalized recommendations based on your interests.
📧 **Email:** {customer_email}
🔑 **Password:** {user_temp_password}
[button url="{login_url}" style="solid"]Log In Now[/button]
We recommend changing your password in Account Settings after logging in.
[/card]
[button url="{shop_url}" style="outline"]Start Shopping[/button]
[card type="basic"]
Got questions? Our customer service team is ready to help: {support_email}
[/card]';
}
/**
* Customer: Password Reset
* Sent when customer requests a password reset
*/
private static function customer_password_reset()
{
return '[card type="hero"]
## Reset Your Password 🔐
Hi {customer_name},
You\'ve requested to reset your password for your {site_name} account.
[/card]
[card type="warning"]
**Click the button below to reset your password:**
[button url="{reset_link}" style="solid"]Reset My Password[/button]
This link will expire in 24 hours for security reasons.
[/card]
[card type="basic"]
**Didn\'t request this?**
If you didn\'t request a password reset, you can safely ignore this email. Your password will remain unchanged.
For security, never share this link with anyone.
[/card]
[card type="basic" bg="#f5f5f5"]
If the button above doesn\'t work, copy and paste this link into your browser:
{reset_link}
[/card]';
}
/**
* Customer: Newsletter Campaign
* Master design template for newsletter campaigns
* The {content} variable is replaced with the actual campaign content
*/
private static function customer_newsletter_campaign()
{
return '[card type="hero"]
## {campaign_title}
[/card]
[card]
{content}
[/card]
[card type="basic" bg="#f5f5f5"]
You are receiving this because you subscribed to {site_name} newsletter.
[Unsubscribe]({unsubscribe_url}) | [Visit Store]({site_url})
© {current_year} {site_name}. All rights reserved.
[/card]';
}
/**
* Customer: Order Placed
* Sent immediately when customer places an order

View File

@@ -15,6 +15,7 @@ class Assets {
add_action('wp_head', [self::class, 'add_inline_config'], 5);
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
}
/**
@@ -61,9 +62,6 @@ class Assets {
null,
false // Load in header
);
error_log('WooNooW Customer: Loading from Vite dev server at ' . $dev_server);
error_log('WooNooW Customer: Scripts enqueued - vite client and main.tsx');
} else {
// Production mode: Load from build
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
@@ -71,14 +69,16 @@ class Assets {
// Check if build exists
if (!file_exists($dist_path)) {
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
return;
}
// Production build - load app.js and app.css directly
$js_url = $plugin_url . 'customer-spa/dist/app.js';
$css_url = $plugin_url . 'customer-spa/dist/app.css';
wp_enqueue_script(
'woonoow-customer-spa',
$plugin_url . 'customer-spa/dist/app.js',
$js_url,
[],
null,
true
@@ -94,13 +94,31 @@ class Assets {
wp_enqueue_style(
'woonoow-customer-spa',
$plugin_url . 'customer-spa/dist/app.css',
$css_url,
[],
null
);
}
}
/**
* Inject SPA mounting point for full mode
*/
public static function inject_spa_mount_point() {
if (!self::should_load_assets()) {
return;
}
// Check if we're in full mode and not on a page with shortcode
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
if ($mode === 'full') {
// Only inject if the mount point doesn't already exist (from shortcode)
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
}
}
/**
* Add inline config and scripts to page head
*/
@@ -211,7 +229,6 @@ class Assets {
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
<?php
error_log('WooNooW Customer: Scripts output directly in head with React Refresh preamble');
}
}
@@ -221,22 +238,43 @@ class Assets {
private static function should_load_assets() {
global $post;
// First check: Is this a designated SPA page?
if (self::is_spa_page()) {
return true;
}
// Get Customer SPA settings
$spa_settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
// If disabled, don't load
if ($mode === 'disabled') {
// Still check for shortcodes
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
if (function_exists('is_shop') && is_shop()) {
$shop_page_id = get_option('woocommerce_shop_page_id');
if ($shop_page_id) {
$shop_page = get_post($shop_page_id);
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
return true;
}
if ($post && has_shortcode($post->post_content, 'woonoow_cart')) {
}
}
// Check for shortcodes on regular pages
if ($post) {
if (has_shortcode($post->post_content, 'woonoow_shop')) {
return true;
}
if ($post && has_shortcode($post->post_content, 'woonoow_checkout')) {
if (has_shortcode($post->post_content, 'woonoow_cart')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
return true;
}
if (has_shortcode($post->post_content, 'woonoow_account')) {
return true;
}
}
return false;
}
@@ -296,6 +334,27 @@ class Assets {
return false;
}
/**
* Check if current page is the designated SPA page
*/
private static function is_spa_page() {
global $post;
if (!$post) {
return false;
}
// Get SPA page ID from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
// Check if current page matches the SPA page
if ($spa_page_id && $post->ID == $spa_page_id) {
return true;
}
return false;
}
/**
* Dequeue conflicting scripts when SPA is active
*/

View File

@@ -9,14 +9,16 @@ use WP_Error;
* Cart Controller - Customer-facing cart API
* Handles cart operations for customer-spa
*/
class CartController {
class CartController
{
/**
* Initialize controller
*/
public static function init() {
public static function init()
{
// Bypass cookie authentication for cart endpoints to allow guest users
add_filter('rest_authentication_errors', function($result) {
add_filter('rest_authentication_errors', function ($result) {
// If already authenticated or error, return as is
if (!empty($result)) {
return $result;
@@ -35,7 +37,8 @@ class CartController {
/**
* Register REST API routes
*/
public static function register_routes() {
public static function register_routes()
{
$namespace = 'woonoow/v1';
// Get cart
@@ -49,14 +52,14 @@ class CartController {
$result = register_rest_route($namespace, '/cart/add', [
'methods' => 'POST',
'callback' => [__CLASS__, 'add_to_cart'],
'permission_callback' => function() {
'permission_callback' => function () {
// Allow both logged-in and guest users
return true;
},
'args' => [
'product_id' => [
'required' => true,
'validate_callback' => function($param) {
'validate_callback' => function ($param) {
return is_numeric($param);
},
],
@@ -75,7 +78,8 @@ class CartController {
register_rest_route($namespace, '/cart/update', [
'methods' => 'POST',
'callback' => [__CLASS__, 'update_cart'],
'permission_callback' => function() { return true; },
'permission_callback' => function () {
return true; },
'args' => [
'cart_item_key' => [
'required' => true,
@@ -92,7 +96,8 @@ class CartController {
register_rest_route($namespace, '/cart/remove', [
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_from_cart'],
'permission_callback' => function() { return true; },
'permission_callback' => function () {
return true; },
'args' => [
'cart_item_key' => [
'required' => true,
@@ -105,7 +110,8 @@ class CartController {
register_rest_route($namespace, '/cart/apply-coupon', [
'methods' => 'POST',
'callback' => [__CLASS__, 'apply_coupon'],
'permission_callback' => function() { return true; },
'permission_callback' => function () {
return true; },
'args' => [
'coupon_code' => [
'required' => true,
@@ -114,15 +120,24 @@ class CartController {
],
]);
// Clear cart
register_rest_route($namespace, '/cart/clear', [
'methods' => 'POST',
'callback' => [__CLASS__, 'clear_cart'],
'permission_callback' => function () {
return true; },
]);
// Remove coupon
register_rest_route($namespace, '/cart/remove-coupon', [
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_coupon'],
'permission_callback' => function() { return true; },
'permission_callback' => function () {
return true; },
'args' => [
'coupon_code' => [
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'type' => 'string',
],
],
]);
@@ -131,9 +146,18 @@ class CartController {
/**
* Get cart contents
*/
public static function get_cart(WP_REST_Request $request) {
public static function get_cart(WP_REST_Request $request)
{
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
WC()->initialize_cart();
}
// Set session cookie for guest users to persist cart
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
return new WP_REST_Response(self::format_cart(), 200);
@@ -142,124 +166,90 @@ class CartController {
/**
* Add item to cart
*/
public static function add_to_cart(WP_REST_Request $request) {
public static function add_to_cart(WP_REST_Request $request)
{
$product_id = $request->get_param('product_id');
$quantity = $request->get_param('quantity');
$quantity = $request->get_param('quantity') ?: 1; // Default to 1
$variation_id = $request->get_param('variation_id');
error_log("WooNooW Cart: Adding product {$product_id} (variation: {$variation_id}) qty: {$quantity}");
// Check if WooCommerce is available
if (!function_exists('WC')) {
error_log('WooNooW Cart Error: WooCommerce not loaded');
return new WP_Error('wc_not_loaded', 'WooCommerce is not loaded', ['status' => 500]);
}
// Initialize WooCommerce session and cart for REST API requests
// WooCommerce doesn't auto-initialize these for REST API calls
if (!WC()->session) {
error_log('WooNooW Cart: Initializing WC session for REST API');
WC()->initialize_session();
}
if (!WC()->cart) {
error_log('WooNooW Cart: Initializing WC cart for REST API');
WC()->initialize_cart();
}
// Set session cookie for guest users
// CRITICAL: Set session cookie for guest users to persist cart
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
error_log('WooNooW Cart: Session cookie set for guest user');
}
error_log('WooNooW Cart: WC Session and Cart initialized successfully');
// Validate product
$product = wc_get_product($product_id);
if (!$product) {
error_log("WooNooW Cart Error: Product {$product_id} not found");
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
}
error_log("WooNooW Cart: Product validated - {$product->get_name()} (Type: {$product->get_type()})");
// For variable products, validate the variation and get attributes
// For variable products, get attributes from request or variation
$variation_attributes = [];
if ($variation_id > 0) {
$variation = wc_get_product($variation_id);
if (!$variation) {
error_log("WooNooW Cart Error: Variation {$variation_id} not found");
return new WP_Error('invalid_variation', "Variation {$variation_id} not found", ['status' => 404]);
return new WP_Error('invalid_variation', "Variation not found", ['status' => 404]);
}
if ($variation->get_parent_id() != $product_id) {
error_log("WooNooW Cart Error: Variation {$variation_id} does not belong to product {$product_id}");
return new WP_Error('invalid_variation', "Variation does not belong to this product", ['status' => 400]);
}
if (!$variation->is_purchasable() || !$variation->is_in_stock()) {
error_log("WooNooW Cart Error: Variation {$variation_id} is not purchasable or out of stock");
return new WP_Error('variation_not_available', "This variation is not available for purchase", ['status' => 400]);
if (!$variation->is_in_stock()) {
return new WP_Error('variation_not_available', "This variation is out of stock", ['status' => 400]);
}
// Get variation attributes from post meta
// WooCommerce stores variation attributes as post meta with 'attribute_' prefix
$variation_attributes = [];
// Build attributes from request parameters (like WooCommerce does)
// Check for attribute_* parameters in the request
$params = $request->get_params();
foreach ($params as $key => $value) {
if (strpos($key, 'attribute_') === 0) {
$variation_attributes[sanitize_title($key)] = wc_clean($value);
}
}
// Get parent product to know which attributes to look for
$parent_product = wc_get_product($product_id);
$parent_attributes = $parent_product->get_attributes();
// If no attributes in request, get from variation meta directly
if (empty($variation_attributes)) {
$parent = wc_get_product($product_id);
foreach ($parent->get_attributes() as $attr_name => $attribute) {
if (!$attribute->get_variation())
continue;
error_log("WooNooW Cart: Parent product attributes: " . print_r(array_keys($parent_attributes), true));
$meta_key = 'attribute_' . $attr_name;
$value = get_post_meta($variation_id, $meta_key, true);
// For each parent attribute, get the value from variation post meta
foreach ($parent_attributes as $attribute) {
if ($attribute->get_variation()) {
$attribute_name = $attribute->get_name();
$meta_key = 'attribute_' . $attribute_name;
// Get the value from post meta
$attribute_value = get_post_meta($variation_id, $meta_key, true);
error_log("WooNooW Cart: Checking attribute {$attribute_name} (meta key: {$meta_key}): {$attribute_value}");
if (!empty($attribute_value)) {
// WooCommerce expects lowercase attribute names
$wc_attribute_key = 'attribute_' . strtolower($attribute_name);
$variation_attributes[$wc_attribute_key] = $attribute_value;
if (!empty($value)) {
$variation_attributes[$meta_key] = $value;
}
}
}
error_log("WooNooW Cart: Variation validated - {$variation->get_name()}");
error_log("WooNooW Cart: Variation attributes extracted: " . print_r($variation_attributes, true));
}
// Clear any existing notices before adding to cart
wc_clear_notices();
// Add to cart with variation attributes
error_log("WooNooW Cart: Calling WC()->cart->add_to_cart({$product_id}, {$quantity}, {$variation_id}, attributes)");
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation_attributes);
if (!$cart_item_key) {
// Get WooCommerce notices to provide better error message
$notices = wc_get_notices('error');
$error_messages = [];
foreach ($notices as $notice) {
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
}
$error_message = !empty($error_messages) ? implode(', ', $error_messages) : 'Failed to add product to cart';
wc_clear_notices(); // Clear notices after reading
wc_clear_notices();
error_log("WooNooW Cart Error: add_to_cart returned false - {$error_message}");
error_log("WooNooW Cart Error: All WC notices: " . print_r($notices, true));
return new WP_Error('add_to_cart_failed', $error_message, ['status' => 400]);
}
error_log("WooNooW Cart: Product added successfully - Key: {$cart_item_key}");
return new WP_REST_Response([
'message' => 'Product added to cart',
'cart_item_key' => $cart_item_key,
@@ -270,12 +260,20 @@ class CartController {
/**
* Update cart item quantity
*/
public static function update_cart(WP_REST_Request $request) {
public static function update_cart(WP_REST_Request $request)
{
$cart_item_key = $request->get_param('cart_item_key');
$quantity = $request->get_param('quantity');
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
// Update quantity
@@ -294,11 +292,25 @@ class CartController {
/**
* Remove item from cart
*/
public static function remove_from_cart(WP_REST_Request $request) {
public static function remove_from_cart(WP_REST_Request $request)
{
$cart_item_key = $request->get_param('cart_item_key');
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]);
WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
// Check if item exists in cart
$cart_contents = WC()->cart->get_cart();
if (!isset($cart_contents[$cart_item_key])) {
return new WP_Error('item_not_found', "Cart item not found", ['status' => 404]);
}
// Remove item
@@ -314,10 +326,36 @@ class CartController {
], 200);
}
/**
* Clear entire cart
*/
public static function clear_cart(WP_REST_Request $request)
{
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) {
WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
// Empty the cart
WC()->cart->empty_cart();
return new WP_REST_Response([
'message' => 'Cart cleared',
'cart' => self::format_cart(),
], 200);
}
/**
* Apply coupon to cart
*/
public static function apply_coupon(WP_REST_Request $request) {
public static function apply_coupon(WP_REST_Request $request)
{
$coupon_code = $request->get_param('coupon_code');
if (!WC()->cart) {
@@ -340,7 +378,8 @@ class CartController {
/**
* Remove coupon from cart
*/
public static function remove_coupon(WP_REST_Request $request) {
public static function remove_coupon(WP_REST_Request $request)
{
$coupon_code = $request->get_param('coupon_code');
if (!WC()->cart) {
@@ -363,7 +402,8 @@ class CartController {
/**
* Format cart data for API response
*/
private static function format_cart() {
private static function format_cart()
{
$cart = WC()->cart;
if (!$cart) {
@@ -374,6 +414,18 @@ class CartController {
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
$product = $cart_item['data'];
// Format variation attributes with clean names (Size instead of attribute_size)
$formatted_attributes = [];
if (!empty($cart_item['variation'])) {
foreach ($cart_item['variation'] as $attr_key => $attr_value) {
// Remove 'attribute_' prefix and capitalize
$clean_key = str_replace('attribute_', '', $attr_key);
$clean_key = ucfirst($clean_key);
// Capitalize value
$formatted_attributes[$clean_key] = ucfirst($attr_value);
}
}
$items[] = [
'key' => $cart_item_key,
'product_id' => $cart_item['product_id'],
@@ -385,7 +437,7 @@ class CartController {
'total' => $cart_item['line_total'],
'image' => wp_get_attachment_url($product->get_image_id()),
'permalink' => get_permalink($cart_item['product_id']),
'attributes' => $cart_item['variation'] ?? [],
'attributes' => $formatted_attributes,
];
}

View File

@@ -5,12 +5,21 @@ namespace WooNooW\Frontend;
* Template Override
* Overrides WooCommerce templates to use WooNooW SPA
*/
class TemplateOverride {
class TemplateOverride
{
/**
* Initialize
*/
public static function init() {
public static function init()
{
// Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
// Use blank template for full-page SPA
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
@@ -32,13 +41,120 @@ class TemplateOverride {
// Override single product template
add_filter('woocommerce_locate_template', [__CLASS__, 'override_template'], 10, 3);
// Remove theme header and footer when SPA is active
add_action('get_header', [__CLASS__, 'remove_theme_header']);
add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
}
/**
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
* Let WooCommerce handle the cart operation properly, we just redirect afterward
*
* This is the proper approach - WooCommerce manages sessions correctly,
* we just customize where the redirect goes.
*/
public static function intercept_add_to_cart()
{
// Only act if add-to-cart is present
if (!isset($_GET['add-to-cart'])) {
return;
}
// Get SPA page from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
if (!$spa_page_id) {
return; // No SPA page configured, let WooCommerce handle everything
}
// Hook into WooCommerce's redirect filter AFTER it adds to cart
// This is the proper way to customize the redirect destination
add_filter('woocommerce_add_to_cart_redirect', function ($url) use ($spa_page_id) {
// Get redirect parameter from original request
$redirect_to = isset($_GET['redirect']) ? sanitize_text_field($_GET['redirect']) : 'cart';
// Build redirect URL with hash route for SPA
$redirect_url = get_permalink($spa_page_id);
// Determine hash route based on redirect parameter
$hash_route = '/cart'; // Default
if ($redirect_to === 'checkout') {
$hash_route = '/checkout';
} elseif ($redirect_to === 'shop') {
$hash_route = '/shop';
}
// Return the SPA URL with hash route
return trailingslashit($redirect_url) . '#' . $hash_route;
}, 999);
// Prevent caching
add_action('template_redirect', function () {
nocache_headers();
}, 1);
}
/**
* Redirect WooCommerce pages to SPA routes
* Maps: /shop → /store/#/, /cart → /store/#/cart, etc.
*/
public static function redirect_wc_pages_to_spa()
{
// Get SPA page URL
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
if (!$spa_page_id) {
return; // No SPA page configured
}
// Already on SPA page, don't redirect
global $post;
if ($post && $post->ID == $spa_page_id) {
return;
}
$spa_url = trailingslashit(get_permalink($spa_page_id));
// Check which WC page we're on and redirect
if (is_shop()) {
wp_redirect($spa_url . '#/', 302);
exit;
}
if (is_product()) {
global $product;
if ($product) {
$slug = $product->get_slug();
wp_redirect($spa_url . '#/products/' . $slug, 302);
exit;
}
}
if (is_cart()) {
wp_redirect($spa_url . '#/cart', 302);
exit;
}
if (is_checkout() && !is_order_received_page()) {
wp_redirect($spa_url . '#/checkout', 302);
exit;
}
if (is_account_page()) {
wp_redirect($spa_url . '#/account', 302);
exit;
}
}
/**
* Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs
*/
public static function disable_canonical_redirect($redirect_url, $requested_url) {
public static function disable_canonical_redirect($redirect_url, $requested_url)
{
$settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
@@ -63,12 +179,38 @@ class TemplateOverride {
/**
* Use SPA template (blank page)
*/
public static function use_spa_template($template) {
public static function use_spa_template($template)
{
// Check if current page is a designated SPA page
if (self::is_spa_page()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
// Legacy: Check SPA mode settings
$settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
// Mode 1: Disabled
// Mode 1: Disabled - but still check for shortcodes (legacy)
if ($mode === 'disabled') {
// Check if page has woonoow shortcodes
global $post;
if (
$post && (
has_shortcode($post->post_content, 'woonoow_shop') ||
has_shortcode($post->post_content, 'woonoow_cart') ||
has_shortcode($post->post_content, 'woonoow_checkout') ||
has_shortcode($post->post_content, 'woonoow_account')
)
) {
// Use blank template for shortcode pages too
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
return $spa_template;
}
}
return $template;
}
@@ -145,7 +287,8 @@ class TemplateOverride {
/**
* Start SPA wrapper
*/
public static function start_spa_wrapper() {
public static function start_spa_wrapper()
{
// Check if we should use SPA
if (!self::should_use_spa()) {
return;
@@ -184,7 +327,8 @@ class TemplateOverride {
/**
* End SPA wrapper
*/
public static function end_spa_wrapper() {
public static function end_spa_wrapper()
{
if (!self::should_use_spa()) {
return;
}
@@ -196,7 +340,8 @@ class TemplateOverride {
/**
* Check if we should use SPA
*/
private static function should_use_spa() {
private static function should_use_spa()
{
// Check if frontend mode is enabled
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
@@ -217,10 +362,108 @@ class TemplateOverride {
return false;
}
/**
* Remove theme header when SPA is active
*/
public static function remove_theme_header()
{
if (self::should_remove_theme_elements()) {
remove_all_actions('wp_head');
// Re-add essential WordPress head actions
add_action('wp_head', 'wp_enqueue_scripts', 1);
add_action('wp_head', 'wp_print_styles', 8);
add_action('wp_head', 'wp_print_head_scripts', 9);
add_action('wp_head', 'wp_resource_hints', 2);
add_action('wp_head', 'wp_site_icon', 99);
}
}
/**
* Remove theme footer when SPA is active
*/
public static function remove_theme_footer()
{
if (self::should_remove_theme_elements()) {
remove_all_actions('wp_footer');
// Re-add essential WordPress footer actions
add_action('wp_footer', 'wp_print_footer_scripts', 20);
}
}
/**
* Check if current page is the designated SPA page
*/
private static function is_spa_page()
{
global $post;
if (!$post) {
return false;
}
// Get SPA page ID from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
// Check if current page matches the SPA page
if ($spa_page_id && $post->ID == $spa_page_id) {
return true;
}
return false;
}
/**
* Check if we should remove theme header/footer
*/
private static function should_remove_theme_elements()
{
// Remove for designated SPA pages
if (self::is_spa_page()) {
return true;
}
$settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
// Check if we're on a WooCommerce page in full mode
if ($mode === 'full') {
if (is_shop() || is_product() || is_cart() || is_checkout() || is_account_page() || is_woocommerce()) {
return true;
}
}
// Also remove for pages with shortcodes (even in disabled mode)
global $post;
if (
$post && (
has_shortcode($post->post_content, 'woonoow_shop') ||
has_shortcode($post->post_content, 'woonoow_cart') ||
has_shortcode($post->post_content, 'woonoow_checkout') ||
has_shortcode($post->post_content, 'woonoow_account')
)
) {
return true;
}
// Special check for Shop page (archive)
if (function_exists('is_shop') && is_shop()) {
$shop_page_id = get_option('woocommerce_shop_page_id');
if ($shop_page_id) {
$shop_page = get_post($shop_page_id);
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
return true;
}
}
}
return false;
}
/**
* Override WooCommerce templates
*/
public static function override_template($template, $template_name, $template_path) {
public static function override_template($template, $template_name, $template_path)
{
// Only override if SPA is enabled
if (!self::should_use_spa()) {
return $template;

View File

@@ -75,6 +75,25 @@ class NewsletterSettings {
'placeholder' => __('I agree to receive marketing emails', 'woonoow'),
'default' => __('I agree to receive marketing emails and understand I can unsubscribe at any time.', 'woonoow'),
],
// Campaign Settings
'campaign_scheduling' => [
'type' => 'toggle',
'label' => __('Campaign Scheduling', 'woonoow'),
'description' => __('Enable scheduled campaigns. When on, you can schedule campaigns to be sent at a specific date and time.', 'woonoow'),
'default' => false,
],
'subscriber_limit_enabled' => [
'type' => 'toggle',
'label' => __('Subscriber Limit', 'woonoow'),
'description' => __('Limit subscribers to 1000. When disabled, a custom database table will be created for unlimited subscribers.', 'woonoow'),
'default' => true,
],
'subscriber_limit' => [
'type' => 'number',
'label' => __('Max Subscribers', 'woonoow'),
'description' => __('Maximum number of subscribers when limit is enabled (default: 1000)', 'woonoow'),
'default' => 1000,
],
];
return $schemas;

View File

@@ -2,8 +2,9 @@
"scripts": {
"dev": "cd admin-spa && npm run dev",
"dev:admin": "cd admin-spa && npm run dev",
"build:admin": "cd admin-spa && npm i && npm run build && mkdir -p ../admin-spa/dist && cp -r admin-spa/dist/* plugin/admin-spa/dist/ 2>/dev/null || true",
"build:customer": "echo \"(todo) customer-spa build\"",
"dev:customer": "cd customer-spa && npm run dev",
"build:admin": "cd admin-spa && npm install && npm run build",
"build:customer": "cd customer-spa && npm install && npm run build",
"build": "npm run build:admin && npm run build:customer",
"pack": "node scripts/package-zip.mjs"
},

View File

@@ -8,23 +8,19 @@
</head>
<body <?php body_class('woonoow-spa-page'); ?>>
<?php
// Determine page type and data attributes
$page_type = 'shop';
$data_attrs = 'data-page="shop"';
// Determine initial route based on SPA mode
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
if (is_product()) {
$page_type = 'product';
global $post;
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
} elseif (is_cart()) {
// Set initial page based on mode
if ($spa_mode === 'checkout_only') {
// Checkout Only mode starts at cart
$page_type = 'cart';
$data_attrs = 'data-page="cart"';
} elseif (is_checkout()) {
$page_type = 'checkout';
$data_attrs = 'data-page="checkout"';
} elseif (is_account_page()) {
$page_type = 'account';
$data_attrs = 'data-page="account"';
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
} else {
// Full SPA mode starts at shop
$page_type = 'shop';
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
}
?>