diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dfefd944..0d0f0bc2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -120,66 +120,24 @@ jobs: echo "Base URL: http://localhost:8889" cat cypress.env.json 2>/dev/null || echo "❌ cypress.env.json not found" - - name: Run Setup Wizard Test (Must Run First) + - name: Run Setup Test (Must Run First) id: setup-test - continue-on-error: true run: | - echo "=== Starting Setup Wizard Test ===" - - # Don't exit on errors - we want to capture the result + echo "=== Starting Setup Test ===" npx cypress run \ --config-file cypress.config.test.js \ - --spec "tests/e2e/cypress/integration/setup-wizard-complete.spec.js" \ - --browser ${{ matrix.browser }} \ - --reporter spec \ - --reporter-options "verbose=true" \ - --env "CYPRESS_CRASH_REPORTS=0" \ - --record false \ - --video true \ - --screenshot-on-failure true - - CYPRESS_EXIT_CODE=$? - - if [ $CYPRESS_EXIT_CODE -eq 0 ]; then - echo "✅ Setup wizard test passed successfully" - echo "setup_success=true" >> $GITHUB_OUTPUT - else - echo "❌ Setup wizard test failed with exit code $CYPRESS_EXIT_CODE" - echo "Capturing detailed failure information..." - echo "=== WordPress State ===" - curl -I http://localhost:8889 || echo "WordPress not responding" - echo "=== Test Files ===" - ls -la tests/e2e/cypress/screenshots/ 2>/dev/null || echo "No screenshots directory" - ls -la tests/e2e/cypress/videos/ 2>/dev/null || echo "No videos directory" - echo "=== Docker Logs ===" - docker logs $(docker ps -q --filter "name=tests-wordpress") 2>&1 | tail -10 || echo "No Docker logs" - echo "=== File System ===" - ls -la tests/e2e/cypress/integration/setup-wizard-complete.spec.js || echo "Setup test file missing" - echo "setup_success=false" >> $GITHUB_OUTPUT - fi - - - name: Verify Setup Completed Successfully - if: success() - run: | - echo "=== Verifying Setup Wizard Completion ===" - # Test for setup completion indicators - curl -s "http://localhost:8889/wp-admin/network/" | grep -i "ultimo\|dashboard" && echo "✅ Setup appears successful" || echo "⚠️ Setup verification unclear" + --spec "tests/e2e/cypress/integration/000-setup.spec.js" \ + --browser ${{ matrix.browser }} - name: Run Checkout Tests (After Setup) - if: always() # Run checkout tests regardless of setup wizard result for debugging - continue-on-error: true + id: checkout-tests run: | + set +e echo "=== Starting Checkout Tests ===" - echo "Setup wizard result: ${GITHUB_OUTPUT}" - echo "Setup success flag: ${{ steps.setup-test.outputs.setup_success }}" - echo "Running checkout test suite (setup wizard success not required for debugging)..." - # Run all checkout tests in sequence CHECKOUT_TESTS=( - "tests/e2e/cypress/integration/checkout-registration.spec.js" - "tests/e2e/cypress/integration/checkout-validation.spec.js" - "tests/e2e/cypress/integration/checkout-scenarios.spec.js" - "tests/e2e/cypress/integration/checkout-confirmation.spec.js" + "tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js" + "tests/e2e/cypress/integration/020-free-trial-flow.spec.js" ) TOTAL_FAILURES=0 @@ -190,13 +148,7 @@ jobs: npx cypress run \ --config-file cypress.config.test.js \ --spec "$TEST_SPEC" \ - --browser ${{ matrix.browser }} \ - --reporter spec \ - --reporter-options "verbose=true" \ - --env "CYPRESS_CRASH_REPORTS=0" \ - --record false \ - --video true \ - --screenshot-on-failure true + --browser ${{ matrix.browser }} CYPRESS_EXIT_CODE=$? @@ -209,14 +161,12 @@ jobs: done if [ $TOTAL_FAILURES -gt 0 ]; then - echo "❌ $TOTAL_FAILURES checkout tests failed" - echo "Capturing failure information..." - ls -la tests/e2e/cypress/screenshots/ 2>/dev/null || echo "No screenshots" - ls -la tests/e2e/cypress/videos/ 2>/dev/null || echo "No videos" - else - echo "✅ All checkout tests passed successfully!" + echo "❌ $TOTAL_FAILURES checkout test(s) failed" + exit 1 fi + echo "✅ All checkout tests passed!" + - name: Fix permissions for Cypress output if: always() run: sudo chown -R $USER:$USER tests/e2e/cypress @@ -237,12 +187,6 @@ jobs: name: cypress-videos-${{ matrix.php }}-${{ matrix.browser }} path: tests/e2e/cypress/videos - - name: Fail job if tests failed - if: failure() - run: | - echo "❌ One or more e2e tests failed." - exit 1 - - name: Stop WordPress Environment if: always() run: npm run env:stop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09bb75c1..041cbde8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + php-version: ["8.2", "8.3", "8.4", "8.5"] services: mysql: image: mariadb:11.4 diff --git a/.wp-env.json b/.wp-env.json index 55599c60..5e599888 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -7,7 +7,8 @@ "." ], "mappings": { - "wp-content/mu-plugins/email-smtp-dev.php": "./mu-plugins/email-smtp-dev/email-smtp-dev.php" + "wp-content/mu-plugins/email-smtp-dev.php": "./mu-plugins/email-smtp-dev/email-smtp-dev.php", + "wp-content/sunrise.php": "./sunrise.php" }, "config": { "WP_DEBUG": true, @@ -15,7 +16,8 @@ "WP_DEBUG_LOG": true, "SCRIPT_DEBUG": true, "WP_MEMORY_LIMIT": "256M", - "WP_MAX_MEMORY_LIMIT": "512M" + "WP_MAX_MEMORY_LIMIT": "512M", + "SUNRISE": true } }, "tests": { @@ -24,7 +26,8 @@ "." ], "mappings": { - "wp-content/mu-plugins/email-smtp-test.php": "./mu-plugins/email-smtp-test/email-smtp-test.php" + "wp-content/mu-plugins/email-smtp-test.php": "./mu-plugins/email-smtp-test/email-smtp-test.php", + "wp-content/sunrise.php": "./sunrise.php" }, "config": { "WP_DEBUG": true, @@ -32,7 +35,8 @@ "WP_DEBUG_LOG": true, "SCRIPT_DEBUG": true, "WP_MEMORY_LIMIT": "256M", - "WP_MAX_MEMORY_LIMIT": "512M" + "WP_MAX_MEMORY_LIMIT": "512M", + "SUNRISE": true } } } diff --git a/DEVELOPER-DOCUMENTATION.md b/DEVELOPER-DOCUMENTATION.md index d7f071a2..cfa99ae8 100644 --- a/DEVELOPER-DOCUMENTATION.md +++ b/DEVELOPER-DOCUMENTATION.md @@ -1,1597 +1,3 @@ -# WP Ultimo Developer Documentation +# Ultimate Multisite Developer Documentation -## Table of Contents - -1. [Introduction](#introduction) -2. [REST API Reference](#rest-api-reference) -3. [Action Hooks Reference](#action-hooks-reference) -4. [Filter Hooks Reference](#filter-hooks-reference) -5. [Integration Guide](#integration-guide) -6. [Addon Development](#addon-development) -7. [Code Examples](#code-examples) - ---- - -## Introduction - -This comprehensive guide provides developers with everything needed to integrate with, extend, or develop addons for Ultimate Multisite. Ultimate Multisite transforms a WordPress Multisite network into a Website-as-a-Service (WaaS) platform. - -### Key Features for Developers: -- **REST API** - Complete CRUD operations for all entities -- **Action Hooks** - 200+ hooks for lifecycle events -- **Filter Hooks** - 280+ filters for customization -- **Addon Framework** - Structured addon development system -- **Authentication** - API key-based authentication system - -### Requirements: -- WordPress Multisite installation -- PHP 7.4 or higher -- WP Ultimo plugin activated - ---- - -## REST API Reference - -### Base Configuration - -**Base URL:** `{site_url}/wp-json/wu/v2/` -**Authentication:** API Key & Secret (HTTP Basic Auth or URL Parameters) - -### Authentication - -#### Enable API -```php -// Enable API in WP Ultimo settings or programmatically -wu_save_setting('enable_api', true); -``` - -#### Get API Credentials -```php -$api_key = wu_get_setting('api_key'); -$api_secret = wu_get_setting('api_secret'); -``` - -#### Authentication Methods - -**HTTP Basic Auth (Recommended):** -```bash -curl -u "api_key:api_secret" https://yoursite.com/wp-json/wu/v2/customers -``` - -**URL Parameters:** -```bash -curl "https://yoursite.com/wp-json/wu/v2/customers?api_key=your_key&api_secret=your_secret" -``` - -### Core Endpoints - -#### 1. Customers API - -**Base Route:** `/customers` - -**Get All Customers** -```http -GET /wu/v2/customers -``` - -**Get Single Customer** -```http -GET /wu/v2/customers/{id} -``` - -**Create Customer** -```http -POST /wu/v2/customers -Content-Type: application/json - -{ - "user_id": 123, - "email_verification": "verified", - "type": "customer", - "has_trialed": false, - "vip": false -} -``` - -**Update Customer** -```http -PUT /wu/v2/customers/{id} -Content-Type: application/json - -{ - "vip": true, - "extra_information": "VIP customer notes" -} -``` - -**Delete Customer** -```http -DELETE /wu/v2/customers/{id} -``` - -#### 2. Sites API - -**Base Route:** `/sites` - -**Create Site** -```http -POST /wu/v2/sites -Content-Type: application/json - -{ - "customer_id": 5, - "membership_id": 10, - "domain": "example.com", - "path": "/", - "title": "My New Site", - "template_id": 1, - "type": "customer_owned" -} -``` - -#### 3. Memberships API - -**Base Route:** `/memberships` - -**Create Membership** -```http -POST /wu/v2/memberships -Content-Type: application/json - -{ - "customer_id": 5, - "plan_id": 3, - "status": "active", - "gateway": "stripe", - "gateway_subscription_id": "sub_1234567890", - "auto_renew": true -} -``` - -#### 4. Products API - -**Base Route:** `/products` - -**Get All Products** -```http -GET /wu/v2/products -``` - -#### 5. Payments API - -**Base Route:** `/payments` - -**Create Payment** -```http -POST /wu/v2/payments -Content-Type: application/json - -{ - "customer_id": 5, - "membership_id": 10, - "status": "completed", - "gateway": "stripe", - "gateway_payment_id": "pi_1234567890", - "total": 29.99, - "currency": "USD" -} -``` - -#### 6. Domains API - -**Base Route:** `/domains` - -**Map Domain** -```http -POST /wu/v2/domains -Content-Type: application/json - -{ - "domain": "custom-domain.com", - "customer_id": 5, - "primary_domain": 1, - "stage": "domain-mapping" -} -``` - -### Registration Endpoint - -The `/register` endpoint provides a complete checkout/registration flow: - -```http -POST /wu/v2/register -Content-Type: application/json - -{ - "customer": { - "username": "newuser", - "password": "securepass123", - "email": "user@example.com" - }, - "products": ["basic-plan"], - "duration": 1, - "duration_unit": "month", - "auto_renew": true, - "site": { - "site_url": "mynewsite", - "site_title": "My New Site", - "template_id": 1 - }, - "payment": { - "status": "completed" - }, - "membership": { - "status": "active" - } -} -``` - -**Response:** -```json -{ - "customer": { ... }, - "membership": { ... }, - "payment": { ... }, - "site": { "id": 123 } -} -``` - -### Error Responses - -```json -{ - "code": "wu_rest_invalid_parameter", - "message": "Invalid parameter value", - "data": { - "status": 400, - "params": { - "email": "Invalid email format" - } - } -} -``` - -### Pagination and Filtering - -**Query Parameters:** -```http -GET /wu/v2/customers?per_page=20&page=2&search=john&status=active -``` - -Common parameters: -- `per_page` - Items per page (default: 20, max: 100) -- `page` - Page number -- `search` - Search term -- `orderby` - Sort field -- `order` - Sort direction (asc/desc) -- `status` - Filter by status -- `date_created` - Filter by date range - ---- - -## Action Hooks Reference - -### Lifecycle Hooks - -#### Plugin Activation -```php -/** - * Fires when WP Ultimo is activated. - * - * @since 2.0.0 - */ -do_action('wu_activation'); - -// Usage Example: -add_action('wu_activation', function() { - // Initialize custom data - add_option('my_addon_version', '1.0.0'); -}); -``` - -#### Settings Management -```php -/** - * Fires after settings are saved. - * - * @since 2.0.0 - * @param array $settings The settings being saved. - */ -do_action('wu_after_save_settings', $settings); - -// Usage Example: -add_action('wu_after_save_settings', function($settings) { - if (isset($settings['enable_feature'])) { - // React to feature toggle - update_option('feature_enabled', $settings['enable_feature']); - } -}); -``` - -### Customer Hooks - -#### Customer Creation -```php -/** - * Fires after a customer is created. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Customer $customer The customer object. - */ -do_action('wu_customer_post_create', $customer); - -// Usage Example: -add_action('wu_customer_post_create', function($customer) { - // Send welcome email - wp_mail( - $customer->get_email(), - 'Welcome!', - 'Thanks for joining our platform!' - ); -}); -``` - -#### Customer Status Change -```php -/** - * Fires when customer status changes. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Customer $customer The customer object. - * @param string $old_status Previous status. - * @param string $new_status New status. - */ -do_action('wu_customer_status_change', $customer, $old_status, $new_status); -``` - -### Site Hooks - -#### Site Creation -```php -/** - * Fires after a site is published. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Site $site The site object. - * @param WP_Ultimo\Models\Membership $membership The associated membership. - */ -do_action('wu_site_published', $site, $membership); - -// Usage Example: -add_action('wu_site_published', function($site, $membership) { - // Set up initial site configuration - switch_to_blog($site->get_id()); - - // Install default plugins - activate_plugin('essential-plugin/essential-plugin.php'); - - restore_current_blog(); -}, 10, 2); -``` - -#### Site Template Application -```php -/** - * Fires before applying a site template. - * - * @since 2.0.0 - * @param int $site_id The site ID being configured. - * @param int $template_id The template being applied. - */ -do_action('wu_before_apply_template', $site_id, $template_id); - -// Usage Example: -add_action('wu_before_apply_template', function($site_id, $template_id) { - // Custom template preparation - switch_to_blog($site_id); - - // Set custom options based on template - if ($template_id === 5) { // E-commerce template - update_option('woocommerce_store_setup', 'complete'); - } - - restore_current_blog(); -}, 10, 2); -``` - -### Membership Hooks - -#### Membership Status Changes -```php -/** - * Fires when membership transitions to active status. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Membership $membership The membership object. - */ -do_action('wu_membership_status_to_active', $membership); - -/** - * Fires when membership expires. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Membership $membership The membership object. - */ -do_action('wu_membership_status_to_expired', $membership); - -// Usage Example: -add_action('wu_membership_status_to_expired', function($membership) { - // Suspend related sites - $sites = $membership->get_sites(); - foreach ($sites as $site) { - $site->set_status('suspended'); - $site->save(); - } -}); -``` - -### Payment Hooks - -#### Payment Processing -```php -/** - * Fires when a payment is completed. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Payment $payment The payment object. - */ -do_action('wu_payment_completed', $payment); - -/** - * Fires when a payment fails. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Payment $payment The payment object. - * @param string $error_message The error message. - */ -do_action('wu_payment_failed', $payment, $error_message); - -// Usage Example: -add_action('wu_payment_failed', function($payment, $error) { - // Notify administrators - $admin_email = get_option('admin_email'); - wp_mail( - $admin_email, - 'Payment Failed', - sprintf('Payment #%d failed: %s', $payment->get_id(), $error) - ); -}, 10, 2); -``` - -### Checkout Hooks - -#### Checkout Flow -```php -/** - * Fires before checkout processing begins. - * - * @since 2.0.0 - * @param WP_Ultimo\Checkout\Cart $cart The cart object. - */ -do_action('wu_checkout_before_processing', $cart); - -/** - * Fires after successful checkout completion. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Payment $payment The payment object. - * @param WP_Ultimo\Models\Customer $customer The customer object. - * @param WP_Ultimo\Models\Membership $membership The membership object. - */ -do_action('wu_checkout_completed', $payment, $customer, $membership); - -// Usage Example: -add_action('wu_checkout_completed', function($payment, $customer, $membership) { - // Track conversion in analytics - if (function_exists('gtag')) { - gtag('event', 'purchase', [ - 'transaction_id' => $payment->get_id(), - 'value' => $payment->get_total(), - 'currency' => $payment->get_currency() - ]); - } -}, 10, 3); -``` - -### Domain Hooks - -#### Domain Management -```php -/** - * Fires when a domain is mapped successfully. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Domain $domain The domain object. - */ -do_action('wu_domain_mapped', $domain); - -/** - * Fires when domain SSL is verified. - * - * @since 2.0.0 - * @param WP_Ultimo\Models\Domain $domain The domain object. - */ -do_action('wu_domain_ssl_verified', $domain); - -// Usage Example: -add_action('wu_domain_mapped', function($domain) { - // Update CDN configuration - if (function_exists('cloudflare_update_zone')) { - cloudflare_update_zone($domain->get_domain()); - } -}); -``` - ---- - -## Filter Hooks Reference - -### Content and Output Filters - -#### Customizing Output -```php -/** - * Filters the checkout form fields. - * - * @since 2.0.0 - * @param array $fields The form fields. - * @param WP_Ultimo\Models\Checkout_Form $form The checkout form object. - * @return array Modified fields array. - */ -$fields = apply_filters('wu_checkout_form_final_fields', $fields, $form); - -// Usage Example: -add_filter('wu_checkout_form_final_fields', function($fields, $form) { - // Add custom field - $fields['company'] = [ - 'type' => 'text', - 'title' => 'Company Name', - 'required' => true, - 'placeholder' => 'Enter company name' - ]; - - return $fields; -}, 10, 2); -``` - -#### Email Content -```php -/** - * Filters email content before sending. - * - * @since 2.0.0 - * @param string $content The email content. - * @param string $email_type The email type. - * @param WP_Ultimo\Models\Customer $customer The customer object. - * @return string Modified content. - */ -$content = apply_filters('wu_email_content', $content, $email_type, $customer); - -// Usage Example: -add_filter('wu_email_content', function($content, $type, $customer) { - if ($type === 'welcome') { - // Add custom welcome message - $content .= "\n\nSpecial offer: Use code WELCOME10 for 10% off!"; - } - return $content; -}, 10, 3); -``` - -### Pricing and Cart Filters - -#### Price Modifications -```php -/** - * Filters the final cart total. - * - * @since 2.0.0 - * @param float $total The cart total. - * @param WP_Ultimo\Checkout\Cart $cart The cart object. - * @return float Modified total. - */ -$total = apply_filters('wu_cart_total', $total, $cart); - -// Usage Example: -add_filter('wu_cart_total', function($total, $cart) { - $customer = $cart->get_customer(); - - // VIP discount - if ($customer && $customer->is_vip()) { - $total = $total * 0.9; // 10% discount - } - - return $total; -}, 10, 2); -``` - -#### Tax Calculations -```php -/** - * Filters tax rates by location. - * - * @since 2.0.0 - * @param float $rate The tax rate. - * @param string $country The country code. - * @param string $state The state code. - * @return float Modified tax rate. - */ -$rate = apply_filters('wu_tax_rate', $rate, $country, $state); - -// Usage Example: -add_filter('wu_tax_rate', function($rate, $country, $state) { - // Custom tax rate for specific region - if ($country === 'US' && $state === 'CA') { - return 0.0875; // 8.75% CA tax - } - - return $rate; -}, 10, 3); -``` - -### Limitation Filters - -#### Feature Limitations -```php -/** - * Filters whether a feature is allowed for a site. - * - * @since 2.0.0 - * @param bool $allowed Whether the feature is allowed. - * @param int $site_id The site ID. - * @param WP_Ultimo\Models\Membership $membership The membership object. - * @return bool Modified permission. - */ -$allowed = apply_filters('wu_limitation_feature_allowed', $allowed, $site_id, $membership); - -// Usage Example: -add_filter('wu_limitation_feature_allowed', function($allowed, $site_id, $membership) { - // Allow premium features for VIP customers - if ($membership->get_customer()->is_vip()) { - return true; - } - - return $allowed; -}, 10, 3); -``` - -#### Storage Limits -```php -/** - * Filters disk space limit for a site. - * - * @since 2.0.0 - * @param int $limit The disk space limit in MB. - * @param int $site_id The site ID. - * @param WP_Ultimo\Models\Membership $membership The membership object. - * @return int Modified limit. - */ -$limit = apply_filters('wu_disk_space_limit', $limit, $site_id, $membership); - -// Usage Example: -add_filter('wu_disk_space_limit', function($limit, $site_id, $membership) { - // Bonus storage for long-term customers - if ($membership->get_days_active() > 365) { - $limit += 500; // Extra 500MB - } - - return $limit; -}, 10, 3); -``` - -### Gateway and Payment Filters - -#### Gateway Selection -```php -/** - * Filters available payment gateways. - * - * @since 2.0.0 - * @param array $gateways Available gateways. - * @param WP_Ultimo\Checkout\Cart $cart The cart object. - * @return array Modified gateways array. - */ -$gateways = apply_filters('wu_available_gateways', $gateways, $cart); - -// Usage Example: -add_filter('wu_available_gateways', function($gateways, $cart) { - // Hide PayPal for enterprise plans - if ($cart->get_total() > 1000) { - unset($gateways['paypal']); - } - - return $gateways; -}, 10, 2); -``` - -### Template and Theme Filters - -#### Template Selection -```php -/** - * Filters available site templates. - * - * @since 2.0.0 - * @param array $templates Available templates. - * @param WP_Ultimo\Models\Customer $customer The customer object. - * @return array Modified templates array. - */ -$templates = apply_filters('wu_available_templates', $templates, $customer); - -// Usage Example: -add_filter('wu_available_templates', function($templates, $customer) { - // Premium templates for VIP customers only - if (!$customer->is_vip()) { - foreach ($templates as $key => $template) { - if ($template['category'] === 'premium') { - unset($templates[$key]); - } - } - } - - return $templates; -}, 10, 2); -``` - ---- - -## Integration Guide - -### Third-Party Service Integration - -#### CRM Integration Example -```php -// Hook into customer creation -add_action('wu_customer_post_create', 'sync_customer_to_crm'); - -function sync_customer_to_crm($customer) { - $crm_api = new Your_CRM_API(); - - $crm_api->create_contact([ - 'email' => $customer->get_email(), - 'name' => $customer->get_display_name(), - 'signup_date' => $customer->get_date_registered(), - 'plan' => $customer->get_membership()->get_plan()->get_name() - ]); - - // Store CRM ID for future reference - $customer->add_meta('crm_contact_id', $crm_api->get_last_contact_id()); -} -``` - -#### Analytics Integration -```php -// Track key events -add_action('wu_checkout_completed', 'track_conversion', 10, 3); -add_action('wu_membership_status_to_cancelled', 'track_churn'); -add_action('wu_payment_failed', 'track_payment_failure'); - -function track_conversion($payment, $customer, $membership) { - // Google Analytics 4 - gtag('event', 'purchase', [ - 'transaction_id' => $payment->get_id(), - 'value' => $payment->get_total(), - 'currency' => $payment->get_currency(), - 'items' => [ - [ - 'item_id' => $membership->get_plan()->get_id(), - 'item_name' => $membership->get_plan()->get_name(), - 'category' => 'subscription', - 'quantity' => 1, - 'price' => $payment->get_total() - ] - ] - ]); -} -``` - -### Custom Gateway Development - -#### Create Custom Gateway -```php -class My_Custom_Gateway extends \WP_Ultimo\Gateways\Base_Gateway { - - public $id = 'my_gateway'; - - public function __construct() { - $this->title = 'My Payment Gateway'; - $this->description = 'Custom payment processing'; - $this->supports = ['one-time', 'recurring']; - - parent::__construct(); - } - - public function process_single_payment($payment, $cart, $order) { - // Process one-time payment - $result = $this->api_call('charge', [ - 'amount' => $payment->get_total(), - 'currency' => $payment->get_currency(), - 'customer' => $payment->get_customer_id() - ]); - - if ($result->success) { - $payment->set_gateway_payment_id($result->transaction_id); - $payment->set_status('completed'); - return true; - } - - return new WP_Error('payment_failed', $result->error_message); - } - - public function process_signup($membership, $customer, $cart, $order) { - // Set up recurring subscription - $subscription = $this->api_call('subscription/create', [ - 'customer_id' => $customer->get_gateway_customer_id(), - 'plan_id' => $membership->get_plan()->get_gateway_plan_id(), - 'trial_days' => $membership->get_trial_days() - ]); - - if ($subscription->success) { - $membership->set_gateway_subscription_id($subscription->id); - return true; - } - - return new WP_Error('subscription_failed', $subscription->error); - } -} - -// Register the gateway -add_filter('wu_payment_gateways', function($gateways) { - $gateways['my_gateway'] = 'My_Custom_Gateway'; - return $gateways; -}); -``` - -### Webhook Handling - -#### Custom Webhook Endpoint -```php -// Register webhook endpoint -add_action('rest_api_init', function() { - register_rest_route('my-addon/v1', '/webhook', [ - 'methods' => 'POST', - 'callback' => 'handle_my_webhook', - 'permission_callback' => 'verify_webhook_signature' - ]); -}); - -function handle_my_webhook($request) { - $payload = $request->get_json_params(); - - switch ($payload['event_type']) { - case 'customer.updated': - $customer = wu_get_customer($payload['customer_id']); - if ($customer) { - // Sync changes from external system - $customer->set_vip($payload['data']['is_vip']); - $customer->save(); - } - break; - - case 'subscription.cancelled': - $membership = wu_get_membership_by_hash($payload['subscription_id']); - if ($membership) { - $membership->cancel(); - } - break; - } - - return ['status' => 'processed']; -} - -function verify_webhook_signature($request) { - $signature = $request->get_header('X-Webhook-Signature'); - $payload = $request->get_body(); - $secret = get_option('my_webhook_secret'); - - return hash_hmac('sha256', $payload, $secret) === $signature; -} -``` - ---- - -## Addon Development - -### Addon Structure - -``` -my-addon/ -├── my-addon.php # Main plugin file -├── inc/ -│ ├── class-my-addon.php # Main addon class -│ ├── admin-pages/ # Admin interface -│ ├── models/ # Custom data models -│ └── integrations/ # Third-party integrations -├── assets/ -│ ├── js/ -│ └── css/ -└── templates/ # Template files -``` - -### Main Addon File Template - -```php -
'; - echo 'My Addon requires WP Ultimo to be installed and activated.'; - echo '
'; - }); - return; - } - - // Initialize addon - My_Addon::get_instance(); -}); - -/** - * Main addon class - */ -class My_Addon { - - use \WP_Ultimo\Traits\Singleton; - - /** - * Initialize the addon - */ - public function init() { - // Load dependencies - $this->load_dependencies(); - - // Setup hooks - $this->setup_hooks(); - - // Initialize components - $this->init_components(); - } - - /** - * Load required files - */ - private function load_dependencies() { - require_once MY_ADDON_PATH . 'inc/class-my-addon.php'; - } - - /** - * Setup WordPress hooks - */ - private function setup_hooks() { - // Activation/deactivation - register_activation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'activate']); - register_deactivation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'deactivate']); - - // WP Ultimo hooks - add_action('wu_checkout_completed', [$this, 'on_checkout_completed'], 10, 3); - add_filter('wu_checkout_form_fields', [$this, 'add_custom_fields'], 10, 2); - } - - /** - * Initialize addon components - */ - private function init_components() { - // Initialize admin pages, models, etc. - } - - /** - * Plugin activation - */ - public function activate() { - // Create custom tables, set options, etc. - $this->create_custom_table(); - update_option('my_addon_version', MY_ADDON_VERSION); - } - - /** - * Plugin deactivation - */ - public function deactivate() { - // Cleanup if needed - } - - /** - * Handle checkout completion - */ - public function on_checkout_completed($payment, $customer, $membership) { - // Custom logic when checkout completes - $this->send_welcome_email($customer); - $this->setup_customer_account($customer, $membership); - } - - /** - * Add custom checkout fields - */ - public function add_custom_fields($fields, $form) { - $fields['company_size'] = [ - 'type' => 'select', - 'title' => 'Company Size', - 'options' => [ - 'small' => '1-10 employees', - 'medium' => '11-100 employees', - 'large' => '100+ employees' - ], - 'required' => false - ]; - - return $fields; - } -} -``` - -### Custom Model Example - -```php -table_name = "{$wpdb->prefix}my_addon_leads"; - } - - /** - * Get the company name - */ - public function get_company() { - return $this->get_meta('company'); - } - - /** - * Set the company name - */ - public function set_company($company) { - return $this->add_meta('company', $company); - } - - /** - * Convert lead to customer - */ - public function convert_to_customer($user_data = []) { - // Create WordPress user - $user_id = wp_create_user( - $user_data['username'] ?? $this->get_email(), - $user_data['password'] ?? wp_generate_password(), - $this->get_email() - ); - - if (is_wp_error($user_id)) { - return $user_id; - } - - // Create WP Ultimo customer - $customer = wu_create_customer([ - 'user_id' => $user_id, - 'email_verification' => 'verified', - 'type' => 'customer' - ]); - - if (is_wp_error($customer)) { - return $customer; - } - - // Copy lead data to customer - $customer->add_meta('company', $this->get_company()); - $customer->add_meta('lead_source', $this->get_source()); - - // Mark lead as converted - $this->set_status('converted'); - $this->add_meta('converted_customer_id', $customer->get_id()); - $this->save(); - - return $customer; - } -} -``` - -### Admin Page Integration - -```php -id, [ - 'title' => __('Leads', 'my-addon'), - 'menu_title' => __('Leads', 'my-addon'), - 'capability' => 'wu_read_leads', - 'position' => $this->position, - 'parent' => 'wp-ultimo', - 'callback' => [$this, 'render'] - ]); - } - - /** - * Render the page - */ - public function render() { - // Get leads data - $leads = My_Addon\Models\Lead::query([ - 'number' => 20, - 'paged' => absint($_GET['paged'] ?? 1) - ]); - - // Render template - wu_get_template('admin/leads-list', [ - 'leads' => $leads, - 'page_title' => __('Manage Leads', 'my-addon') - ]); - } -} -``` - ---- - -## Code Examples - -### Advanced Integration Examples - -#### 1. Multi-Gateway Payment Processing - -```php -/** - * Process payment with fallback gateways - */ -class Smart_Payment_Processor { - - private $gateway_priority = ['stripe', 'paypal', 'manual']; - - public function process_payment_with_fallback($payment, $cart) { - foreach ($this->gateway_priority as $gateway_id) { - $gateway = wu_get_gateway($gateway_id); - - if (!$gateway || !$gateway->is_available()) { - continue; - } - - $result = $gateway->process_single_payment($payment, $cart); - - if (!is_wp_error($result)) { - // Payment successful - do_action('wu_payment_processed_successfully', $payment, $gateway_id); - return $result; - } - - // Log failed attempt - wu_log_add('payment-processing', sprintf( - 'Gateway %s failed for payment %d: %s', - $gateway_id, - $payment->get_id(), - $result->get_error_message() - )); - } - - // All gateways failed - do_action('wu_payment_processing_failed', $payment); - return new WP_Error('all_gateways_failed', 'All payment methods failed'); - } -} -``` - -#### 2. Dynamic Pricing Engine - -```php -/** - * Advanced pricing rules engine - */ -class Dynamic_Pricing_Engine { - - public function __construct() { - add_filter('wu_cart_total', [$this, 'apply_dynamic_pricing'], 20, 2); - add_filter('wu_product_price', [$this, 'modify_product_price'], 10, 3); - } - - public function apply_dynamic_pricing($total, $cart) { - $customer = $cart->get_customer(); - $rules = $this->get_pricing_rules(); - - foreach ($rules as $rule) { - if ($this->rule_applies($rule, $cart, $customer)) { - $total = $this->apply_rule($rule, $total, $cart); - } - } - - return $total; - } - - private function get_pricing_rules() { - return [ - [ - 'type' => 'volume_discount', - 'condition' => ['total_greater_than' => 100], - 'discount' => 0.1 // 10% - ], - [ - 'type' => 'loyalty_discount', - 'condition' => ['customer_tenure_months' => 12], - 'discount' => 0.15 // 15% - ], - [ - 'type' => 'seasonal_promo', - 'condition' => ['date_range' => ['2024-11-01', '2024-12-31']], - 'discount' => 0.2 // 20% - ] - ]; - } - - private function rule_applies($rule, $cart, $customer) { - foreach ($rule['condition'] as $condition => $value) { - switch ($condition) { - case 'total_greater_than': - if ($cart->get_total() <= $value) return false; - break; - - case 'customer_tenure_months': - if (!$customer || $customer->get_months_active() < $value) return false; - break; - - case 'date_range': - $now = current_time('Y-m-d'); - if ($now < $value[0] || $now > $value[1]) return false; - break; - } - } - - return true; - } - - private function apply_rule($rule, $total, $cart) { - $discount_amount = $total * $rule['discount']; - - // Log the discount application - wu_log_add('pricing', sprintf( - 'Applied %s rule: %.2f discount on total %.2f', - $rule['type'], - $discount_amount, - $total - )); - - return $total - $discount_amount; - } -} - -new Dynamic_Pricing_Engine(); -``` - -#### 3. Advanced Site Provisioning - -```php -/** - * Custom site provisioning with external services - */ -class Advanced_Site_Provisioner { - - public function __construct() { - add_action('wu_site_published', [$this, 'provision_site'], 10, 2); - add_action('wu_membership_status_to_expired', [$this, 'suspend_site_services']); - } - - public function provision_site($site, $membership) { - $plan = $membership->get_plan(); - - // Configure based on plan features - switch_to_blog($site->get_id()); - - // Install plugins based on plan - $this->install_plan_plugins($plan); - - // Configure SSL - if ($plan->has_feature('ssl')) { - $this->setup_ssl($site); - } - - // Setup CDN - if ($plan->has_feature('cdn')) { - $this->configure_cdn($site); - } - - // Configure backups - if ($plan->has_feature('backups')) { - $this->setup_automated_backups($site, $plan->get_backup_frequency()); - } - - // Setup monitoring - $this->setup_site_monitoring($site, $membership->get_customer()); - - restore_current_blog(); - - // Send completion notification - $this->send_provisioning_complete_email($site, $membership); - } - - private function install_plan_plugins($plan) { - $plugins = $plan->get_included_plugins(); - - foreach ($plugins as $plugin_slug) { - if ($this->plugin_exists($plugin_slug)) { - activate_plugin($plugin_slug); - - // Configure plugin if needed - $this->configure_plugin($plugin_slug, $plan); - } - } - } - - private function setup_ssl($site) { - $domain = $site->get_domain(); - - // API call to SSL provider - $ssl_service = new SSL_Provider_API(); - $result = $ssl_service->request_certificate($domain); - - if ($result->success) { - $site->add_meta('ssl_certificate_id', $result->certificate_id); - $site->add_meta('ssl_status', 'active'); - } - } - - private function configure_cdn($site) { - $cdn_service = new CDN_Provider_API(); - - $zone = $cdn_service->create_zone([ - 'name' => $site->get_domain(), - 'type' => 'full' - ]); - - if ($zone->success) { - $site->add_meta('cdn_zone_id', $zone->id); - - // Update DNS records - $this->update_cdn_dns($site, $zone); - } - } - - private function setup_automated_backups($site, $frequency) { - $backup_service = new Backup_Provider_API(); - - $schedule = $backup_service->create_schedule([ - 'site_id' => $site->get_id(), - 'frequency' => $frequency, - 'retention' => 30 // days - ]); - - $site->add_meta('backup_schedule_id', $schedule->id); - } - - private function setup_site_monitoring($site, $customer) { - $monitoring_service = new Monitoring_API(); - - $monitor = $monitoring_service->create_monitor([ - 'url' => $site->get_domain(), - 'customer_email' => $customer->get_email(), - 'check_interval' => 300 // 5 minutes - ]); - - $site->add_meta('monitoring_id', $monitor->id); - } -} - -new Advanced_Site_Provisioner(); -``` - -#### 4. Custom Limitations System - -```php -/** - * Advanced limitations with usage tracking - */ -class Advanced_Limitations { - - public function __construct() { - add_filter('wu_limitation_plugins_allowed', [$this, 'check_plugin_limit'], 10, 3); - add_filter('wu_limitation_storage_allowed', [$this, 'check_storage_limit'], 10, 3); - add_action('activated_plugin', [$this, 'track_plugin_activation'], 10, 2); - } - - public function check_plugin_limit($allowed, $site_id, $membership) { - $plan = $membership->get_plan(); - $max_plugins = $plan->get_limit('max_plugins', 10); - - // Count active plugins - switch_to_blog($site_id); - $active_plugins = count(get_option('active_plugins', [])); - restore_current_blog(); - - if ($active_plugins >= $max_plugins) { - // Send warning notification - $this->send_limit_warning($membership->get_customer(), 'plugins', $max_plugins); - return false; - } - - return true; - } - - public function check_storage_limit($allowed, $site_id, $membership) { - $plan = $membership->get_plan(); - $max_storage = $plan->get_limit('max_storage_mb', 1000); // MB - - $current_usage = $this->get_site_storage_usage($site_id); - - if ($current_usage >= $max_storage) { - // Log limit reached - wu_log_add('limitations', sprintf( - 'Site %d reached storage limit: %dMB/%dMB', - $site_id, - $current_usage, - $max_storage - )); - - return false; - } - - // Warn at 80% usage - if ($current_usage >= ($max_storage * 0.8)) { - $this->send_storage_warning($membership->get_customer(), $current_usage, $max_storage); - } - - return true; - } - - private function get_site_storage_usage($site_id) { - // Calculate actual storage usage - $upload_dir = wp_upload_dir(); - $size = $this->get_directory_size($upload_dir['basedir']); - - // Convert to MB - return round($size / 1024 / 1024, 2); - } - - private function get_directory_size($directory) { - $size = 0; - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory) - ); - - foreach ($files as $file) { - if ($file->isFile()) { - $size += $file->getSize(); - } - } - - return $size; - } - - private function send_limit_warning($customer, $limit_type, $limit_value) { - wu_mail_customer($customer, 'limit_warning', [ - 'limit_type' => $limit_type, - 'limit_value' => $limit_value, - 'upgrade_url' => wu_get_current_url('upgrade') - ]); - } -} - -new Advanced_Limitations(); -``` - -### Testing Your Integration - -#### Unit Test Example - -```php -customer = wu_create_customer([ - 'user_id' => $this->factory->user->create(), - 'type' => 'customer' - ]); - - // Create test membership - $this->membership = wu_create_membership([ - 'customer_id' => $this->customer->get_id(), - 'plan_id' => $this->create_test_plan() - ]); - } - - public function test_custom_field_saves_correctly() { - $checkout = new WP_Ultimo\Checkout\Checkout(); - - // Simulate form submission - $_POST['company_size'] = 'medium'; - - $result = $checkout->process_step_data([ - 'company_size' => 'medium' - ]); - - $this->assertTrue($result); - - // Verify data was saved - $saved_value = $this->customer->get_meta('company_size'); - $this->assertEquals('medium', $saved_value); - } - - public function test_pricing_rule_applies() { - $engine = new Dynamic_Pricing_Engine(); - - $cart = new WP_Ultimo\Checkout\Cart([ - 'customer_id' => $this->customer->get_id(), - 'products' => ['test-plan'] - ]); - - $cart->set_total(150); // Above $100 threshold - - $new_total = $engine->apply_dynamic_pricing(150, $cart); - - // Should have 10% discount - $this->assertEquals(135, $new_total); - } - - private function create_test_plan() { - return wu_create_product([ - 'name' => 'Test Plan', - 'type' => 'plan', - 'price' => 50, - 'duration' => 1, - 'duration_unit' => 'month' - ])->get_id(); - } -} -``` - -This comprehensive documentation provides developers with all the tools and knowledge needed to integrate with, extend, and build upon WP Ultimo's powerful platform. The extensive API, hook system, and examples enable creation of sophisticated SaaS solutions and custom integrations. +See [Official Ultimate Multisite Documentation Site](https://ultimatemultisite.com/docs/developer) diff --git a/assets/js/checkout.js b/assets/js/checkout.js index a202f269..1c2fa7ec 100644 --- a/assets/js/checkout.js +++ b/assets/js/checkout.js @@ -1,1191 +1,1220 @@ -/* global Vue, moment, _, wu_checkout, wu_checkout_form, wu_create_cookie, wu_listen_to_cookie_change */ +/* global Vue, moment, _, wu_checkout, wu_checkout_form, wu_create_cookie, wu_listen_to_cookie_change, wu_initialize_tooltip */ (function ($, hooks, _) { - /* + /* * Remove the pre-flight parameter. */ - if (window.history.replaceState) { + if (window.history.replaceState) { - window.history.replaceState(null, null, wu_checkout.baseurl); + window.history.replaceState(null, null, wu_checkout.baseurl); - } // end if; + } // end if; - /* + /* * Sets default template. */ - hooks.addAction('wu_on_create_order', 'nextpress/wp-ultimo', function (checkout, data) { + hooks.addAction('wu_on_create_order', 'nextpress/wp-ultimo', function (checkout, data) { - if (typeof data.order.extra.template_id !== 'undefined' && data.order.extra.template_id) { + if (typeof data.order.extra.template_id !== 'undefined' && data.order.extra.template_id) { - checkout.template_id = data.order.extra.template_id; + checkout.template_id = data.order.extra.template_id; - } // end if; + } // end if; - }); + }); - /* + /* * Handle auto-submittable fields. * * Some fields are auto-submittable if they are the one relevant * field on a checkout step. */ - hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function (checkout) { + hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function (checkout) { - /* + /* * The checkout sets the auto submittable field as a global variable */ - if (typeof window.wu_auto_submittable_field !== 'undefined' && window.wu_auto_submittable_field) { + if (typeof window.wu_auto_submittable_field !== 'undefined' && window.wu_auto_submittable_field) { - const options = { - deep: true, - }; + const options = { + deep: true, + }; - checkout.$watch(window.wu_auto_submittable_field, function () { + checkout.$watch(window.wu_auto_submittable_field, function () { - jQuery(this.$el).submit(); + jQuery(this.$el).submit(); - }, options); + }, options); - } // end if; + } // end if; - }); + }); - /* + /* * Sets up the cookie listener for template selection. */ - hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function (checkout) { + hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function (checkout) { - /* + /* * Resets the template selection cookie. */ - wu_create_cookie('wu_template', ''); + wu_create_cookie('wu_template', ''); - /* + /* * Resets the selected products cookie. */ - wu_create_cookie('wu_selected_products', ''); - /* + wu_create_cookie('wu_selected_products', ''); + /* * Listens for changes and set the template if one is detected. */ - wu_listen_to_cookie_change('wu_template', function (value) { - if (value) { - checkout.template_id = value; - } - }); + wu_listen_to_cookie_change('wu_template', function (value) { + if (value) { + checkout.template_id = value; + } + }); - }); + }); - /** - * Allows for cross-sells - */ - $(document).on('click', '[href|="#wu-checkout-add"]', function (event) { + /** + * Allows for cross-sells + */ + $(document).on('click', '[href|="#wu-checkout-add"]', function (event) { - event.preventDefault(); + event.preventDefault(); - const el = $(this); + const el = $(this); - const product_slug = el.attr('href').split('#').pop().replace('wu-checkout-add-', ''); + const product_slug = el.attr('href').split('#').pop().replace('wu-checkout-add-', ''); - if (typeof wu_checkout_form !== 'undefined') { + if (typeof wu_checkout_form !== 'undefined') { - if (wu_checkout_form.products.indexOf(product_slug) === -1) { + if (wu_checkout_form.products.indexOf(product_slug) === -1) { - wu_checkout_form.add_product(product_slug); + wu_checkout_form.add_product(product_slug); - el.html(wu_checkout.i18n.added_to_order); + el.html(wu_checkout.i18n.added_to_order); - } // end if; + } // end if; - } // end if; + } // end if; - }); + }); - /** - * Reload page when history back button was pressed - */ - window.addEventListener('pageshow', function (event) { + /** + * Reload page when history back button was pressed + */ + window.addEventListener('pageshow', function (event) { - if (event.persisted && this.window.wu_checkout_form) { + if (event.persisted && this.window.wu_checkout_form) { - this.window.wu_checkout_form.unblock(); + this.window.wu_checkout_form.unblock(); - } // end if; + } // end if; - }); + }); - /** - * Setup - */ - $(document).ready(function () { + /** + * Setup + */ + $(document).ready(function () { - /* + /* * Prevent app creation when vue is not available. */ - if (typeof window.Vue === 'undefined') { - - return; - - } // end if; - - Object.defineProperty(Vue.prototype, '$moment', { value: moment }); - - const maybe_cast_to_int = function (value) { - - return isNaN(value) ? value : parseInt(value, 10); - - }; - - const initial_data = { - plan: maybe_cast_to_int(wu_checkout.plan), - errors: [], - order: wu_checkout.order, - products: _.map(wu_checkout.products, maybe_cast_to_int), - template_id: wu_checkout.template_id, - template_category: '', - gateway: wu_checkout.gateway, - request_billing_address: wu_checkout.request_billing_address, - country: wu_checkout.country, - state: '', - city: '', - site_title: wu_checkout.site_title || '', - site_url: wu_checkout.site_url, - site_domain: wu_checkout.site_domain, - is_subdomain: wu_checkout.is_subdomain, - discount_code: wu_checkout.discount_code, - toggle_discount_code: 0, - payment_method: '', - username: '', - email_address: '', - payment_id: wu_checkout.payment_id, - membership_id: wu_checkout.membership_id, - cart_type: 'new', - auto_renew: 1, - duration: wu_checkout.duration, - duration_unit: wu_checkout.duration_unit, - prevent_submission: false, - valid_password: true, - stored_templates: {}, - state_list: [], - city_list: [], - labels: {}, - show_login_prompt: false, - login_prompt_field: '', - checking_user_exists: false, - logging_in: false, - login_error: '', - inline_login_password: '', - }; - - hooks.applyFilters('wu_before_form_init', initial_data); - - if (!jQuery('#wu_form').length) { - - return; - - } // end if; - - /** - * ColorPicker Component - */ - Vue.component('colorPicker', { - props: ['value'], - template: '', - mounted() { + if (typeof window.Vue === 'undefined') { + + return; + + } // end if; + + Object.defineProperty(Vue.prototype, '$moment', { value: moment }); + + const maybe_cast_to_int = function (value) { + + return isNaN(value) ? value : parseInt(value, 10); + + }; + + const initial_data = { + plan: maybe_cast_to_int(wu_checkout.plan), + errors: [], + order: wu_checkout.order, + products: _.map(wu_checkout.products, maybe_cast_to_int), + template_id: wu_checkout.template_id, + template_category: '', + gateway: wu_checkout.gateway, + request_billing_address: wu_checkout.request_billing_address, + country: wu_checkout.country, + state: '', + city: '', + site_title: wu_checkout.site_title || '', + site_url: wu_checkout.site_url, + site_domain: wu_checkout.site_domain, + is_subdomain: wu_checkout.is_subdomain, + discount_code: wu_checkout.discount_code, + toggle_discount_code: 0, + payment_method: '', + username: '', + email_address: '', + payment_id: wu_checkout.payment_id, + membership_id: wu_checkout.membership_id, + cart_type: 'new', + auto_renew: 1, + duration: wu_checkout.duration, + duration_unit: wu_checkout.duration_unit, + prevent_submission: false, + valid_password: true, + stored_templates: {}, + state_list: [], + city_list: [], + labels: {}, + show_login_prompt: false, + login_prompt_field: '', + checking_user_exists: false, + logging_in: false, + login_error: '', + inline_login_password: '', + custom_amounts: wu_checkout.custom_amounts || {}, + pwyw_recurring: wu_checkout.pwyw_recurring || {}, + }; + + hooks.applyFilters('wu_before_form_init', initial_data); + + if (! jQuery('#wu_form').length) { + + return; + + } // end if; + + /** + * ColorPicker Component + */ + Vue.component('colorPicker', { + props: [ 'value' ], + template: '', + mounted() { + + const vm = this; - const vm = this; + $(this.$el) + .val(this.value) + // WordPress color picker + .wpColorPicker({ + width: 200, + defaultColor: this.value, + change(event, ui) { - $(this.$el) - .val(this.value) - // WordPress color picker - .wpColorPicker({ - width: 200, - defaultColor: this.value, - change(event, ui) { + // emit change event on color change using mouse + vm.$emit('input', ui.color.toString()); - // emit change event on color change using mouse - vm.$emit('input', ui.color.toString()); + }, + }); - }, - }); + }, + watch: { + value(value) { - }, - watch: { - value(value) { + // update value + $(this.$el).wpColorPicker('color', value); - // update value - $(this.$el).wpColorPicker('color', value); + }, + }, + destroyed() { - }, - }, - destroyed() { + $(this.$el).off().wpColorPicker('destroy'); // (!) Not tested - $(this.$el).off().wpColorPicker('destroy'); // (!) Not tested + }, + }); - }, - }); + /** + * Declare the dynamic content for Vue. + */ + const dynamic = { + functional: true, + template: '#dynamic', + props: [ 'template' ], + render(h, context) { - /** - * Declare the dynamic content for Vue. - */ - const dynamic = { - functional: true, - template: '#dynamic', - props: ['template'], - render(h, context) { + const template = context.props.template; + + const component = template ? { template } : '%s. The current time is %s', date_i18n('e'), date_i18n('r'));
$options = [
- 'general' => [
+ 'general' => [
'title' => __('Limit Uses', 'ultimate-multisite'),
'icon' => 'dashicons-wu-lock',
'desc' => __('Rules and limitations to the applicability of this discount code.', 'ultimate-multisite'),
@@ -135,7 +135,7 @@ public function register_widgets(): void {
],
],
],
- 'time' => [
+ 'time' => [
'title' => __('Start & Expiration Dates', 'ultimate-multisite'),
'desc' => __('Define a start and end date for this discount code. Useful when running campaigns for a pre-determined period.', 'ultimate-multisite'),
'icon' => 'dashicons-wu-calendar',
@@ -200,7 +200,7 @@ public function register_widgets(): void {
],
],
],
- 'products' => [
+ 'products' => [
'title' => __('Limit Products', 'ultimate-multisite'),
'desc' => __('Determine if you want this discount code to apply to all discountable products or not.', 'ultimate-multisite'),
'icon' => 'dashicons-wu-price-tag',
@@ -222,6 +222,28 @@ public function register_widgets(): void {
$this->get_product_field_list()
),
],
+ 'billing_periods' => [
+ 'title' => __('Limit Billing Periods', 'ultimate-multisite'),
+ 'desc' => __('Restrict this discount code to specific billing periods (e.g., only monthly or only annual plans).', 'ultimate-multisite'),
+ 'icon' => 'dashicons-wu-calendar',
+ 'state' => [
+ 'limit_billing_periods' => $this->get_object()->get_limit_billing_periods(),
+ ],
+ 'fields' => array_merge(
+ [
+ 'limit_billing_periods' => [
+ 'type' => 'toggle',
+ 'title' => __('Select Billing Periods', 'ultimate-multisite'),
+ 'desc' => __('Manually select which billing periods this discount code should be applicable to.', 'ultimate-multisite'),
+ 'value' => 1,
+ 'html_attr' => [
+ 'v-model' => 'limit_billing_periods',
+ ],
+ ],
+ ],
+ $this->get_billing_period_field_list()
+ ),
+ ],
];
$this->add_tabs_widget(
@@ -443,6 +465,190 @@ protected function get_product_field_list() {
return $fields;
}
+ /**
+ * List of billing periods to apply this coupon to.
+ *
+ * @since 2.0.0
+ * @return array
+ */
+ protected function get_billing_period_field_list() {
+
+ $fields = [];
+ $billing_periods = $this->get_available_billing_periods();
+ $allowed_periods = $this->get_object()->get_allowed_billing_periods();
+ $limit_periods = $this->get_object()->get_limit_billing_periods();
+
+ foreach ($billing_periods as $period_key => $period_label) {
+ $fields[ "allowed_billing_periods_{$period_key}" ] = [
+ 'type' => 'toggle',
+ 'title' => $period_label,
+ 'desc' => __('Make applicable to this billing period.', 'ultimate-multisite'),
+ 'tooltip' => '',
+ 'wrapper_classes' => '',
+ 'html_attr' => [
+ ':name' => "'allowed_billing_periods[]'",
+ ':checked' => wp_json_encode(! $limit_periods || in_array($period_key, $allowed_periods, true)),
+ ':value' => wp_json_encode($period_key),
+ ],
+ 'wrapper_html_attr' => [
+ 'v-cloak' => 1,
+ 'v-show' => 'limit_billing_periods',
+ ],
+ ];
+ }
+
+ // Hidden field to ensure at least one value is submitted
+ $fields['allowed_billing_periods_none'] = [
+ 'type' => 'hidden',
+ 'value' => '__none',
+ 'html_attr' => [
+ ':name' => "'allowed_billing_periods[]'",
+ ],
+ ];
+
+ if (empty($billing_periods)) {
+ $fields['allowed_billing_periods_no_periods'] = [
+ 'type' => 'note',
+ 'title' => '',
+ 'desc' => __('No billing periods found. Create products with different billing periods first.', 'ultimate-multisite'),
+ 'wrapper_html_attr' => [
+ 'v-cloak' => 1,
+ 'v-show' => 'limit_billing_periods',
+ ],
+ ];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Get all available billing periods from products.
+ *
+ * Scans all products to find unique billing period combinations.
+ *
+ * @since 2.0.0
+ * @return array Associative array of period_key => label.
+ */
+ protected function get_available_billing_periods() {
+
+ $periods = [];
+
+ foreach (wu_get_products() as $product) {
+ if ( ! $product->is_recurring()) {
+ continue;
+ }
+
+ $duration = $product->get_duration();
+ $duration_unit = $product->get_duration_unit();
+ $period_key = Discount_Code::get_billing_period_key($duration, $duration_unit);
+
+ if ( ! isset($periods[ $period_key ])) {
+ $periods[ $period_key ] = $this->format_billing_period_label($duration, $duration_unit);
+ }
+
+ // Also check for price variations
+ $price_variations = $product->get_price_variations();
+
+ if ( ! empty($price_variations)) {
+ foreach ($price_variations as $variation) {
+ $var_duration = isset($variation['duration']) ? (int) $variation['duration'] : 0;
+ $var_duration_unit = isset($variation['duration_unit']) ? $variation['duration_unit'] : '';
+
+ if ($var_duration > 0 && ! empty($var_duration_unit)) {
+ $var_period_key = Discount_Code::get_billing_period_key($var_duration, $var_duration_unit);
+
+ if ( ! isset($periods[ $var_period_key ])) {
+ $periods[ $var_period_key ] = $this->format_billing_period_label($var_duration, $var_duration_unit);
+ }
+ }
+ }
+ }
+ }
+
+ // Sort by duration for consistent display
+ uksort(
+ $periods,
+ function ($a, $b) {
+ $a_parts = Discount_Code::parse_billing_period_key($a);
+ $b_parts = Discount_Code::parse_billing_period_key($b);
+
+ if ( ! $a_parts || ! $b_parts) {
+ return 0;
+ }
+
+ // Convert to days for comparison
+ $a_days = $this->get_period_in_days($a_parts['duration'], $a_parts['duration_unit']);
+ $b_days = $this->get_period_in_days($b_parts['duration'], $b_parts['duration_unit']);
+
+ return $a_days <=> $b_days;
+ }
+ );
+
+ return $periods;
+ }
+
+ /**
+ * Format a billing period label for display.
+ *
+ * @since 2.0.0
+ * @param int $duration The billing duration.
+ * @param string $duration_unit The billing duration unit.
+ * @return string Human-readable label.
+ */
+ protected function format_billing_period_label(int $duration, string $duration_unit): string {
+
+ $unit_labels = [
+ 'day' => [
+ 'singular' => __('Day', 'ultimate-multisite'),
+ 'plural' => __('Days', 'ultimate-multisite'),
+ ],
+ 'week' => [
+ 'singular' => __('Week', 'ultimate-multisite'),
+ 'plural' => __('Weeks', 'ultimate-multisite'),
+ ],
+ 'month' => [
+ 'singular' => __('Month', 'ultimate-multisite'),
+ 'plural' => __('Months', 'ultimate-multisite'),
+ ],
+ 'year' => [
+ 'singular' => __('Year', 'ultimate-multisite'),
+ 'plural' => __('Years', 'ultimate-multisite'),
+ ],
+ ];
+
+ $unit_label = isset($unit_labels[ $duration_unit ])
+ ? (1 === $duration ? $unit_labels[ $duration_unit ]['singular'] : $unit_labels[ $duration_unit ]['plural'])
+ : $duration_unit;
+
+ if (1 === $duration) {
+ return $unit_label;
+ }
+
+ return sprintf('%d %s', $duration, $unit_label);
+ }
+
+ /**
+ * Convert a billing period to days for sorting purposes.
+ *
+ * @since 2.0.0
+ * @param int $duration The billing duration.
+ * @param string $duration_unit The billing duration unit.
+ * @return int Approximate number of days.
+ */
+ protected function get_period_in_days(int $duration, string $duration_unit): int {
+
+ $multipliers = [
+ 'day' => 1,
+ 'week' => 7,
+ 'month' => 30,
+ 'year' => 365,
+ ];
+
+ $multiplier = isset($multipliers[ $duration_unit ]) ? $multipliers[ $duration_unit ] : 1;
+
+ return $duration * $multiplier;
+ }
+
/**
* Handles legacy advanced options for coupons.
*
@@ -655,6 +861,22 @@ public function handle_save(): void {
$_POST['limit_products'] = false;
}
+ /*
+ * Set the limit billing periods value.
+ */
+ if ( ! wu_request('limit_billing_periods')) {
+ $_POST['limit_billing_periods'] = false;
+ }
+
+ /*
+ * Filter out the placeholder value from allowed_billing_periods.
+ */
+ $allowed_billing_periods = wu_request('allowed_billing_periods', []);
+
+ if (is_array($allowed_billing_periods)) {
+ $_POST['allowed_billing_periods'] = array_filter($allowed_billing_periods, fn($value) => '__none' !== $value);
+ }
+
/*
* Set the setup fee value to zero if the toggle is disabled.
*/
diff --git a/inc/admin-pages/class-membership-list-admin-page.php b/inc/admin-pages/class-membership-list-admin-page.php
index 086f5ab9..882260e4 100644
--- a/inc/admin-pages/class-membership-list-admin-page.php
+++ b/inc/admin-pages/class-membership-list-admin-page.php
@@ -113,6 +113,23 @@ public function render_add_new_membership_modal(): void {
'data-max-items' => 99,
],
],
+ 'billing_period' => [
+ 'type' => 'select',
+ 'title' => __('Billing Period', 'ultimate-multisite'),
+ 'desc' => __('Select the billing period for this membership. Must match a price variation in the selected product.', 'ultimate-multisite'),
+ 'tooltip' => '',
+ 'value' => '1-month',
+ 'options' => [
+ '1-day' => __('Daily', 'ultimate-multisite'),
+ '1-week' => __('Weekly', 'ultimate-multisite'),
+ '1-month' => __('Monthly', 'ultimate-multisite'),
+ '3-month' => __('Quarterly (3 months)', 'ultimate-multisite'),
+ '6-month' => __('Semi-annually (6 months)', 'ultimate-multisite'),
+ '1-year' => __('Yearly', 'ultimate-multisite'),
+ '2-year' => __('Every 2 years', 'ultimate-multisite'),
+ '3-year' => __('Every 3 years', 'ultimate-multisite'),
+ ],
+ ],
'status' => [
'type' => 'select',
'title' => __('Status', 'ultimate-multisite'),
@@ -213,13 +230,28 @@ public function handle_add_new_membership_modal(): void {
);
}
+ // Parse the billing period into duration and duration_unit.
+ $billing_period = wu_request('billing_period', '1-month');
+ $billing_parts = explode('-', $billing_period, 2);
+ $duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
+ $duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
+
$cart = new \WP_Ultimo\Checkout\Cart(
[
- 'products' => $products,
- 'country' => $customer->get_country(),
+ 'products' => $products,
+ 'country' => $customer->get_country(),
+ 'duration' => $duration,
+ 'duration_unit' => $duration_unit,
]
);
+ // Check for cart errors (e.g., missing price variations).
+ $cart_errors = $cart->get_errors();
+
+ if ($cart_errors->has_errors()) {
+ wp_send_json_error($cart_errors);
+ }
+
$data = $cart->to_membership_data();
$data['customer_id'] = $customer->get_id();
diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php
index 6a4a1f2c..9a8e0618 100644
--- a/inc/admin-pages/class-product-edit-admin-page.php
+++ b/inc/admin-pages/class-product-edit-admin-page.php
@@ -274,30 +274,32 @@ public function register_widgets(): void {
'data-wu-app' => 'product_pricing',
'data-state' => wp_json_encode(
[
- 'is_recurring' => $this->get_object()->is_recurring(),
- 'pricing_type' => $this->get_object()->get_pricing_type(),
- 'has_trial' => $this->get_object()->get_trial_duration() > 0,
- 'has_setup_fee' => $this->get_object()->has_setup_fee(),
- 'setup_fee' => $this->get_object()->get_setup_fee(),
- 'amount' => $this->get_object()->get_formatted_amount(),
- 'duration' => $this->get_object()->get_duration(),
- 'duration_unit' => $this->get_object()->get_duration_unit(),
+ 'is_recurring' => $this->get_object()->is_recurring(),
+ 'pricing_type' => $this->get_object()->get_pricing_type(),
+ 'has_trial' => $this->get_object()->get_trial_duration() > 0,
+ 'has_setup_fee' => $this->get_object()->has_setup_fee(),
+ 'setup_fee' => $this->get_object()->get_setup_fee(),
+ 'amount' => $this->get_object()->get_formatted_amount(),
+ 'duration' => $this->get_object()->get_duration(),
+ 'duration_unit' => $this->get_object()->get_duration_unit(),
+ 'pwyw_recurring_mode' => $this->get_object()->get_pwyw_recurring_mode() ?: 'customer_choice',
]
),
],
'fields' => [
// Fields for price
- 'pricing_type' => [
+ 'pricing_type' => [
'type' => 'select',
'title' => __('Pricing Type', 'ultimate-multisite'),
'placeholder' => __('Select Pricing Type', 'ultimate-multisite'),
- 'desc' => __('Products can be free, paid, or require further contact for pricing.', 'ultimate-multisite'),
+ 'desc' => __('Products can be free, paid, pay what you want, or require further contact for pricing.', 'ultimate-multisite'),
'value' => $this->get_object()->get_pricing_type(),
'tooltip' => '',
'options' => [
- 'paid' => __('Paid', 'ultimate-multisite'),
- 'free' => __('Free', 'ultimate-multisite'),
- 'contact_us' => __('Contact Us', 'ultimate-multisite'),
+ 'paid' => __('Paid', 'ultimate-multisite'),
+ 'free' => __('Free', 'ultimate-multisite'),
+ 'pay_what_you_want' => __('Pay What You Want', 'ultimate-multisite'),
+ 'contact_us' => __('Contact Us', 'ultimate-multisite'),
],
'wrapper_html_attr' => [
'v-cloak' => '1',
@@ -306,7 +308,7 @@ public function register_widgets(): void {
'v-model' => 'pricing_type',
],
],
- 'contact_us_label' => [
+ 'contact_us_label' => [
'type' => 'text',
'title' => __('Button Label', 'ultimate-multisite'),
'placeholder' => __('E.g. Contact us', 'ultimate-multisite'),
@@ -317,7 +319,7 @@ public function register_widgets(): void {
'v-cloak' => '1',
],
],
- 'contact_us_link' => [
+ 'contact_us_link' => [
'type' => 'url',
'title' => __('Button Link', 'ultimate-multisite'),
'placeholder' => __('E.g. https://contactus.page.com', 'ultimate-multisite'),
@@ -328,7 +330,86 @@ public function register_widgets(): void {
'v-cloak' => '1',
],
],
- 'recurring' => [
+ 'pwyw_minimum_amount' => [
+ 'type' => 'text',
+ 'title' => __('Minimum Price', 'ultimate-multisite'),
+ 'placeholder' => wu_format_currency('0'),
+ 'desc' => __('The minimum amount customers can pay. Leave at 0 for truly "pay what you want".', 'ultimate-multisite'),
+ 'value' => $this->get_object()->get_pwyw_minimum_amount(),
+ 'money' => true,
+ 'wrapper_html_attr' => [
+ 'v-show' => "pricing_type == 'pay_what_you_want'",
+ 'v-cloak' => '1',
+ ],
+ ],
+ 'pwyw_suggested_amount' => [
+ 'type' => 'text',
+ 'title' => __('Suggested Price', 'ultimate-multisite'),
+ 'placeholder' => wu_format_currency('0'),
+ 'desc' => __('A suggested price shown as the default value in the price input.', 'ultimate-multisite'),
+ 'value' => $this->get_object()->get_pwyw_suggested_amount(),
+ 'money' => true,
+ 'wrapper_html_attr' => [
+ 'v-show' => "pricing_type == 'pay_what_you_want'",
+ 'v-cloak' => '1',
+ ],
+ ],
+ 'pwyw_recurring_mode' => [
+ 'type' => 'select',
+ 'title' => __('Recurring Mode', 'ultimate-multisite'),
+ 'desc' => __('Control whether customers can choose between one-time and recurring payments.', 'ultimate-multisite'),
+ 'value' => $this->get_object()->get_pwyw_recurring_mode(),
+ 'options' => [
+ 'customer_choice' => __('Customer Chooses (One-time or Recurring)', 'ultimate-multisite'),
+ 'force_recurring' => __('Force Recurring Only', 'ultimate-multisite'),
+ 'force_one_time' => __('Force One-time Only', 'ultimate-multisite'),
+ ],
+ 'wrapper_html_attr' => [
+ 'v-show' => "pricing_type == 'pay_what_you_want'",
+ 'v-cloak' => '1',
+ ],
+ 'html_attr' => [
+ 'v-model' => 'pwyw_recurring_mode',
+ ],
+ ],
+ 'pwyw_duration_group' => [
+ 'type' => 'group',
+ 'title' => __('Billing Period', 'ultimate-multisite'),
+ 'desc' => __('The billing period for recurring PWYW payments. Only applies when recurring is enabled.', 'ultimate-multisite'),
+ 'wrapper_html_attr' => [
+ 'v-show' => "pricing_type == 'pay_what_you_want' && pwyw_recurring_mode != 'force_one_time'",
+ 'v-cloak' => '1',
+ ],
+ 'fields' => [
+ 'duration' => [
+ 'type' => 'number',
+ 'value' => $this->get_object()->get_duration(),
+ 'placeholder' => 1,
+ 'wrapper_classes' => 'wu-w-1/2',
+ 'min' => 1,
+ 'html_attr' => [
+ 'v-model' => 'duration',
+ 'steps' => 1,
+ ],
+ ],
+ 'duration_unit' => [
+ 'type' => 'select',
+ 'value' => $this->get_object()->get_duration_unit(),
+ 'placeholder' => '',
+ 'wrapper_classes' => 'wu-w-1/2 wu-ml-2',
+ 'html_attr' => [
+ 'v-model' => 'duration_unit',
+ ],
+ 'options' => [
+ 'day' => __('Day(s)', 'ultimate-multisite'),
+ 'week' => __('Week(s)', 'ultimate-multisite'),
+ 'month' => __('Month(s)', 'ultimate-multisite'),
+ 'year' => __('Year(s)', 'ultimate-multisite'),
+ ],
+ ],
+ ],
+ ],
+ 'recurring' => [
'type' => 'toggle',
'title' => __('Is Recurring?', 'ultimate-multisite'),
'desc' => __('Check this if this product has a recurring charge.', 'ultimate-multisite'),
@@ -341,13 +422,13 @@ public function register_widgets(): void {
'v-model' => 'is_recurring',
],
],
- 'amount' => [
+ 'amount' => [
'type' => 'hidden',
'html_attr' => [
'v-model' => 'amount',
],
],
- '_amount' => [
+ '_amount' => [
'type' => 'text',
'title' => __('Price', 'ultimate-multisite'),
'placeholder' => __('Price', 'ultimate-multisite'),
@@ -363,7 +444,7 @@ public function register_widgets(): void {
'v-model' => 'amount',
],
],
- 'amount_group' => [
+ 'amount_group' => [
'type' => 'group',
'title' => __('Price', 'ultimate-multisite'),
// translators: placeholder %1$s is the amount, %2$s is the duration (such as 1, 2, 3), and %3$s is the unit (such as month, year, week)
@@ -413,7 +494,7 @@ public function register_widgets(): void {
],
],
],
- 'billing_cycles' => [
+ 'billing_cycles' => [
'type' => 'number',
'title' => __('Billing Cycles', 'ultimate-multisite'),
'placeholder' => __('E.g. 1', 'ultimate-multisite'),
@@ -425,7 +506,7 @@ public function register_widgets(): void {
'v-cloak' => '1',
],
],
- 'has_trial' => [
+ 'has_trial' => [
'type' => 'toggle',
'title' => __('Offer Trial', 'ultimate-multisite'),
'desc' => __('Check if you want to add a trial period to this product.', 'ultimate-multisite'),
@@ -438,7 +519,7 @@ public function register_widgets(): void {
'v-model' => 'has_trial',
],
],
- 'trial_group' => [
+ 'trial_group' => [
'type' => 'group',
'title' => __('Trial', 'ultimate-multisite'),
'tooltip' => '',
@@ -467,7 +548,7 @@ public function register_widgets(): void {
],
],
],
- 'has_setup_fee' => [
+ 'has_setup_fee' => [
'type' => 'toggle',
'title' => __('Add Setup Fee?', 'ultimate-multisite'),
'desc' => __('Check if you want to add a setup fee.', 'ultimate-multisite'),
@@ -480,13 +561,13 @@ public function register_widgets(): void {
'v-model' => 'has_setup_fee',
],
],
- 'setup_fee' => [
+ 'setup_fee' => [
'type' => 'hidden',
'html_attr' => [
'v-model' => 'setup_fee',
],
],
- '_setup_fee' => [
+ '_setup_fee' => [
'type' => 'text',
'money' => true,
'title' => __('Setup Fee', 'ultimate-multisite'),
@@ -701,7 +782,7 @@ protected function get_product_option_sections() {
'type' => 'model',
'title' => __('Offer Add-ons', 'ultimate-multisite'),
'placeholder' => __('Search for a package or service', 'ultimate-multisite'),
- 'desc' => __('This products will be offered inside upgrade/downgrade forms as order bumps.', 'ultimate-multisite'),
+ 'desc' => __('These products will be offered inside upgrade/downgrade forms as order bumps.', 'ultimate-multisite'),
'html_attr' => [
'data-exclude' => implode(',', array_keys($plans_as_options)),
'data-model' => 'product',
diff --git a/inc/apis/class-settings-endpoint.php b/inc/apis/class-settings-endpoint.php
new file mode 100644
index 00000000..ae21ca14
--- /dev/null
+++ b/inc/apis/class-settings-endpoint.php
@@ -0,0 +1,525 @@
+get_namespace();
+
+ // GET /settings - Retrieve all settings
+ register_rest_route(
+ $namespace,
+ '/settings',
+ [
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => [$this, 'get_settings'],
+ 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']),
+ ]
+ );
+
+ // GET /settings/{setting_key} - Retrieve a specific setting
+ register_rest_route(
+ $namespace,
+ '/settings/(?P%2$sCANCEL to confirm this membership cancellation."
msgstr ""
#. translators: %s: Next charge date.
-#: inc/ui/class-site-actions-element.php:1083
+#: inc/ui/class-site-actions-element.php:1255
#, php-format
msgid "Your sites will stay working until %s."
msgstr ""
-#: inc/ui/class-site-actions-element.php:1095
+#: inc/ui/class-site-actions-element.php:1267
msgid "CANCEL"
msgstr ""
@@ -20603,6 +20863,14 @@ msgstr ""
msgid "Select Plan"
msgstr ""
+#: views/checkout/templates/pricing-table/list.php:84
+msgid "Make this a recurring payment"
+msgstr ""
+
+#: views/checkout/templates/pricing-table/list.php:87
+msgid "(Recurring subscription)"
+msgstr ""
+
#: views/checkout/templates/steps/clean.php:25
#: views/checkout/templates/steps/minimal.php:25
msgid "Progress"
diff --git a/mu-plugins/email-smtp-test/email-smtp-test.php b/mu-plugins/email-smtp-test/email-smtp-test.php
index 041c421e..2e88785c 100644
--- a/mu-plugins/email-smtp-test/email-smtp-test.php
+++ b/mu-plugins/email-smtp-test/email-smtp-test.php
@@ -21,7 +21,7 @@ function TestConfigMailpit($phpmailer) {
$phpmailer->From = 'test@example.local';
$phpmailer->FromName = 'Test Site';
- // Uncomment to enable SMTP debug output (helpful for troubleshooting)
- $phpmailer->SMTPDebug = 2;
+ // SMTP debug output disabled to prevent corruption of AJAX responses
+ $phpmailer->SMTPDebug = 0;
}
add_action('phpmailer_init', 'TestConfigMailpit', 10, 1);
diff --git a/package.json b/package.json
index 33e2f3ac..90c2df75 100644
--- a/package.json
+++ b/package.json
@@ -36,9 +36,10 @@
"prebuild": "npm run makepot && composer install -o --no-dev",
"prebuild:dev": "composer install",
"copylibs": "node scripts/copy-libs.js",
- "build": "npm-run-all --parallel copylibs uglify cleancss",
+ "build": "npm-run-all --parallel copylibs uglify cleancss generate:hooks",
"postbuild": "npm run archive",
- "build:dev": "npm run copylibs && npm run uglify && npm run cleancss && npm run makepot",
+ "generate:hooks": "php inc/documentation/generate-berlindb-hooks.php",
+ "build:dev": "npm run copylibs && npm run uglify && npm run cleancss && npm run makepot && npm run generate:hooks",
"build:translate": "npm run copylibs && npm run uglify && npm run cleancss && npm run makepot && npm run translate",
"prearchive": "php encrypt-secrets.php",
"archive": "node scripts/archive.js",
@@ -73,7 +74,8 @@
"clean:cache": "rm -f .phpunit.result.cache && rm -rf node_modules/.cache",
"translate": "php scripts/translate.php",
"translate:force": "php scripts/translate.php --force",
- "env:start": "wp-env start",
+ "env:fix-perms": "docker run --rm -v \"$HOME/.wp-env:/wp-env\" alpine chown -R \"$(id -u):$(id -g)\" /wp-env || true",
+ "env:start": "npm run env:fix-perms && wp-env start",
"env:stop": "wp-env stop",
"env:clean": "wp-env clean all",
"env:destroy": "wp-env destroy all",
diff --git a/readme.txt b/readme.txt
index 1f72355c..4c0790b0 100644
--- a/readme.txt
+++ b/readme.txt
@@ -240,6 +240,13 @@ We recommend running this in a staging environment before updating your producti
== Changelog ==
+Version [2.4.11] - Released on 2026-XX-XX
+- New: Settings API for remote settings management.
+- New: Pay-What-You-Want (PWYW) pricing with per-product custom amounts and recurring options.
+- New: Billing-period controls for discount codes and membership creation.
+- New: Better error page for customers and admins.
+- Fix: Problems with choosing country and state in checkout.
+
Version [2.4.10] - Released on 2026-01-23
- New: Configurable minimum password strength setting with Medium, Strong, and Super Strong options.
- New: Super Strong password requirements include 12+ characters, uppercase, lowercase, numbers, and special characters - compatible with WPMU DEV Defender Pro rules.
diff --git a/tests/e2e/cypress/fixtures/setup-checkout-form.php b/tests/e2e/cypress/fixtures/setup-checkout-form.php
new file mode 100644
index 00000000..def428a4
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-checkout-form.php
@@ -0,0 +1,49 @@
+ 'main-form',
+ 'number' => 1,
+ ]
+);
+
+if ( $existing ) {
+ $form = $existing[0];
+ $page_id = wu_get_setting('default_registration_page', 0);
+ echo 'form:' . esc_html($form->get_id()) . ',page:' . esc_html($page_id);
+ return;
+}
+
+$form_data = [
+ 'name' => 'Registration Form',
+ 'slug' => 'main-form',
+ 'settings' => [],
+];
+
+$form = wu_create_checkout_form($form_data);
+
+if ( is_wp_error($form) ) {
+ echo 'error:' . esc_html($form->get_error_message());
+ return;
+}
+
+$form->use_template('single-step');
+$form->save();
+
+$page_id = wp_insert_post(
+ [
+ 'post_name' => 'register',
+ 'post_title' => 'Register',
+ 'post_content' => '[wu_checkout slug="main-form"]',
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ 'post_author' => 1,
+ ]
+);
+
+wu_save_setting('default_registration_page', $page_id);
+
+echo 'form:' . esc_html($form->get_id()) . ',page:' . esc_html($page_id);
diff --git a/tests/e2e/cypress/fixtures/setup-product.php b/tests/e2e/cypress/fixtures/setup-product.php
new file mode 100644
index 00000000..963dc73d
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-product.php
@@ -0,0 +1,15 @@
+set_name('Test Plan');
+$product->set_slug('test-plan');
+$product->set_amount(29.99);
+$product->set_duration(1);
+$product->set_duration_unit('month');
+$product->set_type('plan');
+$product->set_active(true);
+$product->save();
+
+echo esc_html($product->get_id());
diff --git a/tests/e2e/cypress/fixtures/setup-tables.php b/tests/e2e/cypress/fixtures/setup-tables.php
new file mode 100644
index 00000000..d7d7f707
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-tables.php
@@ -0,0 +1,15 @@
+init();
+
+if ( ! $loader->is_installed() ) {
+ $installer = WP_Ultimo\Installers\Core_Installer::get_instance();
+ $installer->_install_database_tables();
+}
+
+update_network_option(null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, time());
+
+echo $loader->is_installed() ? 'installed' : 'failed';
diff --git a/tests/e2e/cypress/fixtures/setup-trial-product.php b/tests/e2e/cypress/fixtures/setup-trial-product.php
new file mode 100644
index 00000000..508190d1
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-trial-product.php
@@ -0,0 +1,44 @@
+set_name('Trial Plan');
+$product->set_slug('trial-plan');
+$product->set_amount(19.99);
+$product->set_duration(1);
+$product->set_duration_unit('month');
+$product->set_trial_duration(14);
+$product->set_trial_duration_unit('day');
+$product->set_type('plan');
+$product->set_active(true);
+$product->save();
+
+$product_id = $product->get_id();
+
+// Add the trial product to the checkout form's pricing table.
+$form = WP_Ultimo\Models\Checkout_Form::query(['number' => 1]);
+
+if ( $form ) {
+ $form = $form[0];
+ $settings = $form->get_settings();
+
+ foreach ( $settings as &$step ) {
+ if ( ! isset($step['fields']) ) {
+ continue;
+ }
+
+ foreach ( $step['fields'] as &$field ) {
+ if ( isset($field['id']) && 'pricing_table' === $field['id'] ) {
+ $existing = $field['pricing_table_products'] ?? '';
+ $field['pricing_table_products'] = $existing ? $existing . ',' . $product_id : (string) $product_id;
+ }
+ }
+ }
+
+ $form->set_settings($settings);
+ $form->save();
+}
+
+echo esc_html($product_id);
diff --git a/tests/e2e/cypress/fixtures/verify-manual-checkout-results.php b/tests/e2e/cypress/fixtures/verify-manual-checkout-results.php
new file mode 100644
index 00000000..b3a22127
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/verify-manual-checkout-results.php
@@ -0,0 +1,43 @@
+ 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_payment_status = $payments ? $payments[0]->get_status() : 'no-payments';
+$um_payment_gateway = $payments ? $payments[0]->get_gateway() : 'none';
+$um_payment_total = $payments ? (float) $payments[0]->get_total() : 0;
+
+// UM membership
+$memberships = WP_Ultimo\Models\Membership::query(
+ [
+ 'number' => 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_membership_status = $memberships ? $memberships[0]->get_status() : 'no-memberships';
+
+// UM sites
+$sites = WP_Ultimo\Models\Site::query(['type__in' => ['customer_owned']]);
+$um_site_count = count($sites);
+$um_site_type = $sites ? $sites[0]->get_type() : 'no-sites';
+
+echo wp_json_encode(
+ [
+ 'um_payment_status' => $um_payment_status,
+ 'um_payment_gateway' => $um_payment_gateway,
+ 'um_payment_total' => $um_payment_total,
+ 'um_membership_status' => $um_membership_status,
+ 'um_site_count' => $um_site_count,
+ 'um_site_type' => $um_site_type,
+ ]
+);
diff --git a/tests/e2e/cypress/fixtures/verify-trial-results.php b/tests/e2e/cypress/fixtures/verify-trial-results.php
new file mode 100644
index 00000000..e8cc3188
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/verify-trial-results.php
@@ -0,0 +1,39 @@
+ 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_payment_status = $payments ? $payments[0]->get_status() : 'no-payments';
+
+// UM membership
+$memberships = WP_Ultimo\Models\Membership::query(
+ [
+ 'number' => 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_membership_status = $memberships ? $memberships[0]->get_status() : 'no-memberships';
+$um_membership_trial_end = $memberships ? (string) $memberships[0]->get_date_trial_end() : '';
+
+// UM sites
+$sites = WP_Ultimo\Models\Site::query(['type__in' => ['customer_owned']]);
+$um_site_type = $sites ? $sites[0]->get_type() : 'no-sites';
+
+echo wp_json_encode(
+ [
+ 'um_payment_status' => $um_payment_status,
+ 'um_membership_status' => $um_membership_status,
+ 'um_membership_trial_end' => $um_membership_trial_end,
+ 'um_site_type' => $um_site_type,
+ ]
+);
diff --git a/tests/e2e/cypress/integration/000-setup.spec.js b/tests/e2e/cypress/integration/000-setup.spec.js
new file mode 100644
index 00000000..1189109b
--- /dev/null
+++ b/tests/e2e/cypress/integration/000-setup.spec.js
@@ -0,0 +1,65 @@
+describe("Ultimate Multisite Setup", () => {
+ before(() => {
+ // Disable custom login page if a previous wizard run enabled it,
+ // otherwise /wp-login.php redirects to /login/ and loginByForm breaks.
+ cy.wpCli(
+ 'eval "if (function_exists(\'wu_save_setting\')) { wu_save_setting(\'enable_custom_login_page\', 0); }"',
+ { failOnNonZeroExit: false }
+ );
+
+ cy.loginByForm(
+ Cypress.env("admin").username,
+ Cypress.env("admin").password
+ );
+ });
+
+ it("Should install Ultimate Multisite database tables and mark setup complete", () => {
+ cy.wpCliFile("tests/e2e/cypress/fixtures/setup-tables.php").then(
+ (result) => {
+ expect(result.stdout).to.contain("installed");
+ }
+ );
+ });
+
+ it("Should create a test product/plan", () => {
+ cy.wpCliFile("tests/e2e/cypress/fixtures/setup-product.php", {
+ failOnNonZeroExit: false,
+ }).then((result) => {
+ const productId = result.stdout.trim();
+ cy.log(`Created test product with ID: ${productId}`);
+ });
+ });
+
+ it("Should create a checkout form and registration page", () => {
+ cy.wpCliFile(
+ "tests/e2e/cypress/fixtures/setup-checkout-form.php"
+ ).then((result) => {
+ expect(result.stdout).to.contain("form:");
+ expect(result.stdout).to.not.contain("error:");
+ });
+ });
+
+ it("Should enable the manual gateway in Ultimate Multisite", () => {
+ cy.wpCli(
+ "eval \"wu_save_setting('active_gateways', ['manual']);\""
+ );
+
+ cy.wpCli(
+ "eval \"echo json_encode(wu_get_setting('active_gateways', []));\""
+ ).then((result) => {
+ expect(result.stdout).to.contain("manual");
+ });
+ });
+
+ it("Should disable email verification and enable sync site publish", () => {
+ cy.wpCli(
+ "eval \"wu_save_setting('enable_email_verification', 'never'); wu_save_setting('force_publish_sites_sync', true);\""
+ );
+
+ cy.wpCli(
+ "eval \"echo wu_get_setting('enable_email_verification', 'always');\""
+ ).then((result) => {
+ expect(result.stdout).to.contain("never");
+ });
+ });
+});
diff --git a/tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js b/tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js
new file mode 100644
index 00000000..109b9a07
--- /dev/null
+++ b/tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js
@@ -0,0 +1,98 @@
+describe("Manual Gateway Checkout Flow", () => {
+ const timestamp = Date.now();
+ const customerData = {
+ username: `manualcust${timestamp}`,
+ email: `manualcust${timestamp}@test.com`,
+ password: "TestPassword123!",
+ };
+ const siteData = {
+ title: "Manual Test Site",
+ path: `manualsite${timestamp}`,
+ };
+
+ it("Should complete the UM checkout form with manual gateway", {
+ retries: 0,
+ }, () => {
+ cy.clearCookies();
+ cy.visit("/register", { failOnStatusCode: false });
+
+ // Wait for checkout form to render
+ cy.get("#field-email_address", { timeout: 30000 }).should(
+ "be.visible"
+ );
+ cy.wait(3000);
+
+ // Select the plan
+ cy.get('#wrapper-field-pricing_table label[id^="wu-product-"]', {
+ timeout: 15000,
+ })
+ .first()
+ .click();
+
+ cy.wait(3000);
+
+ // Fill account details
+ cy.get("#field-email_address").clear().type(customerData.email);
+ cy.get("#field-username").should("be.visible").clear().type(customerData.username);
+ cy.get("#field-password").should("be.visible").clear().type(customerData.password);
+
+ cy.get("body").then(($body) => {
+ if ($body.find("#field-password_conf").length > 0) {
+ cy.get("#field-password_conf").clear().type(customerData.password);
+ }
+ });
+
+ // Fill site details
+ cy.get("#field-site_title").should("be.visible").clear().type(siteData.title);
+ cy.get("#field-site_url").should("be.visible").clear().type(siteData.path);
+
+ // Select manual gateway if visible radio
+ cy.get("body").then(($body) => {
+ const radioSelector = 'input[type="radio"][name="gateway"][value="manual"]';
+ if ($body.find(radioSelector).length > 0) {
+ cy.get(radioSelector).check({ force: true });
+ }
+ });
+
+ // Fill billing address if present
+ cy.get("body").then(($body) => {
+ if ($body.find("#field-billing_country").length > 0) {
+ cy.get("#field-billing_country").select("US");
+ } else if ($body.find('[name="billing_address[billing_country]"]').length > 0) {
+ cy.get('[name="billing_address[billing_country]"]').select("US");
+ }
+
+ if ($body.find("#field-billing_zip_code").length > 0) {
+ cy.get("#field-billing_zip_code").clear().type("94105");
+ } else if ($body.find('[name="billing_address[billing_zip_code]"]').length > 0) {
+ cy.get('[name="billing_address[billing_zip_code]"]').clear().type("94105");
+ }
+ });
+
+ // Submit the UM checkout form
+ cy.get(
+ '#wrapper-field-checkout button[type="submit"], button.wu-checkout-submit, #field-checkout, button[type="submit"]',
+ { timeout: 10000 }
+ )
+ .filter(":visible")
+ .last()
+ .click();
+
+ // Should redirect to status=done
+ cy.url({ timeout: 60000 }).should("include", "status=done");
+ });
+
+ it("Should verify checkout state via WP-CLI", () => {
+ cy.wpCliFile("tests/e2e/cypress/fixtures/verify-manual-checkout-results.php").then(
+ (result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Results: ${JSON.stringify(data)}`);
+
+ // Manual gateway: payment should be pending
+ expect(data.um_payment_status).to.equal("pending");
+ expect(data.um_payment_gateway).to.equal("manual");
+ expect(data.um_membership_status).to.equal("pending");
+ }
+ );
+ });
+});
diff --git a/tests/e2e/cypress/integration/020-free-trial-flow.spec.js b/tests/e2e/cypress/integration/020-free-trial-flow.spec.js
new file mode 100644
index 00000000..d932b1b6
--- /dev/null
+++ b/tests/e2e/cypress/integration/020-free-trial-flow.spec.js
@@ -0,0 +1,97 @@
+describe("Free Trial Checkout Flow", () => {
+ const timestamp = Date.now();
+ const customerData = {
+ username: `trialcust${timestamp}`,
+ email: `trialcust${timestamp}@test.com`,
+ password: "TestPassword123!",
+ };
+ const siteData = {
+ title: "Trial Test Site",
+ path: `trialsite${timestamp}`,
+ };
+
+ before(() => {
+ cy.loginByForm(
+ Cypress.env("admin").username,
+ Cypress.env("admin").password
+ );
+
+ // Enable trial without payment method
+ cy.wpCli(
+ "eval \"wu_save_setting('allow_trial_without_payment_method', true);\""
+ );
+
+ // Create the trial product
+ cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
+ failOnNonZeroExit: false,
+ }).then((result) => {
+ const productId = result.stdout.trim();
+ cy.log(`Created trial product with ID: ${productId}`);
+ });
+ });
+
+ it("Should complete free trial checkout without payment", {
+ retries: 0,
+ }, () => {
+ cy.clearCookies();
+ cy.visit("/register", { failOnStatusCode: false });
+
+ // Wait for checkout form to render
+ cy.get("#field-email_address", { timeout: 30000 }).should(
+ "be.visible"
+ );
+ cy.wait(3000);
+
+ // Select the trial plan by name
+ cy.get('#wrapper-field-pricing_table label[id^="wu-product-"]', {
+ timeout: 15000,
+ })
+ .contains("Trial Plan")
+ .click();
+
+ cy.wait(3000);
+
+ // Fill account details
+ cy.get("#field-email_address").clear().type(customerData.email);
+ cy.get("#field-username").should("be.visible").clear().type(customerData.username);
+ cy.get("#field-password").should("be.visible").clear().type(customerData.password);
+
+ cy.get("body").then(($body) => {
+ if ($body.find("#field-password_conf").length > 0) {
+ cy.get("#field-password_conf").clear().type(customerData.password);
+ }
+ });
+
+ // Fill site details
+ cy.get("#field-site_title").should("be.visible").clear().type(siteData.title);
+ cy.get("#field-site_url").should("be.visible").clear().type(siteData.path);
+
+ // No gateway or billing fields should be required for free trial
+
+ // Submit the checkout form
+ cy.get(
+ '#wrapper-field-checkout button[type="submit"], button.wu-checkout-submit, #field-checkout, button[type="submit"]',
+ { timeout: 10000 }
+ )
+ .filter(":visible")
+ .last()
+ .click();
+
+ // Should redirect to status=done without any payment page
+ cy.url({ timeout: 60000 }).should("include", "status=done");
+ });
+
+ it("Should verify trial membership state via WP-CLI", () => {
+ cy.wpCliFile(
+ "tests/e2e/cypress/fixtures/verify-trial-results.php"
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Trial results: ${JSON.stringify(data)}`);
+
+ expect(data.um_membership_status).to.equal("trialing");
+ expect(data.um_membership_trial_end).to.not.equal("");
+ expect(data.um_payment_status).to.equal("completed");
+ expect(data.um_site_type).to.equal("customer_owned");
+ });
+ });
+});
diff --git a/tests/e2e/cypress/integration/checkout-confirmation.spec.js b/tests/e2e/cypress/integration/checkout-confirmation.spec.js
deleted file mode 100644
index 6970948e..00000000
--- a/tests/e2e/cypress/integration/checkout-confirmation.spec.js
+++ /dev/null
@@ -1,373 +0,0 @@
-/**
- * E2E tests for checkout confirmation and post-registration flow
- *
- * This test suite covers the confirmation page, email verification,
- * and post-registration user experience.
- */
-
-describe("Checkout Confirmation & Post-Registration", () => {
- const testCustomer = {
- username: `confirmuser_${Date.now()}`,
- email: `confirmuser_${Date.now()}@example.com`,
- password: 'ConfirmPass123!',
- firstName: 'Sarah',
- lastName: 'Wilson'
- };
-
- const testSite = {
- title: 'Confirmation Test Site',
- path: `confirmsite_${Date.now()}`
- };
-
- describe("Successful Registration Confirmation", () => {
- before(() => {
- // Complete a full registration to test confirmation
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testCustomer);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testSite);
- cy.proceedToNextStep();
-
- // Handle payment/completion
- cy.get('body').then($body => {
- if ($body.find('[name*="billing"]').length > 0) {
- cy.fillBillingAddress();
- cy.selectPaymentGateway('manual');
- }
- });
-
- cy.completeCheckout();
- });
-
- it("Should display confirmation page with correct information", () => {
- // Verify we're on confirmation page
- cy.url({ timeout: 30000 }).should('satisfy', url =>
- url.includes('/confirmation') ||
- url.includes('/thank') ||
- url.includes('/success') ||
- url.includes('/complete')
- );
-
- // Verify page title/heading
- cy.get('h1, h2, .wu-title, [data-testid="page-title"]')
- .should('be.visible')
- .and('contain.text', /success|complete|welcome|thank|congratulations/i);
-
- // Verify success message
- cy.get('[data-testid="success-message"], .wu-success, .notice-success, [class*="success"]')
- .should('be.visible')
- .and('contain.text', /success|complete|registered|created/i);
- });
-
- it("Should display customer information correctly", () => {
- // Check customer email
- cy.get('[data-testid="customer-email"], .customer-email, .user-email')
- .should('contain.text', testCustomer.email);
-
- // Check username if displayed
- cy.get('body').then($body => {
- if ($body.find('[data-testid="customer-username"], .customer-username').length > 0) {
- cy.get('[data-testid="customer-username"], .customer-username')
- .should('contain.text', testCustomer.username);
- }
- });
-
- // Check full name if displayed
- cy.get('body').then($body => {
- if ($body.find('[data-testid="customer-name"], .customer-name').length > 0) {
- cy.get('[data-testid="customer-name"], .customer-name')
- .should('contain.text', testCustomer.firstName)
- .or('contain.text', testCustomer.lastName);
- }
- });
- });
-
- it("Should display site information correctly", () => {
- // Check site title
- cy.get('[data-testid="site-title"], .site-title, .site-name')
- .should('contain.text', testSite.title);
-
- // Check site URL
- cy.get('[data-testid="site-url"], .site-url, .site-address')
- .should('contain.text', testSite.path);
-
- // Check site status
- cy.get('[data-testid="site-status"], .site-status')
- .should('contain.text', /active|ready|live|created/i);
- });
-
- it("Should provide navigation options", () => {
- // Check for dashboard link
- cy.get('body').then($body => {
- if ($body.find('[data-testid="dashboard-link"], .dashboard-link, a:contains("Dashboard")').length > 0) {
- cy.get('[data-testid="dashboard-link"], .dashboard-link, a:contains("Dashboard")')
- .should('be.visible')
- .and('have.attr', 'href')
- .and('contain', '/wp-admin');
- }
- });
-
- // Check for site visit link
- cy.get('body').then($body => {
- if ($body.find('[data-testid="visit-site"], .visit-site, a:contains("Visit")').length > 0) {
- cy.get('[data-testid="visit-site"], .visit-site, a:contains("Visit")')
- .should('be.visible')
- .and('have.attr', 'href');
- }
- });
-
- // Check for login link
- cy.get('body').then($body => {
- if ($body.find('[data-testid="login-link"], .login-link, a:contains("Login")').length > 0) {
- cy.get('[data-testid="login-link"], .login-link, a:contains("Login")')
- .should('be.visible')
- .and('have.attr', 'href')
- .and('contain', 'login');
- }
- });
- });
- });
-
- describe("Email Verification Process", () => {
- it("Should display email verification notice if required", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="email-verification"], .email-verification, :contains("verify")').length > 0) {
- cy.get('[data-testid="email-verification"], .email-verification')
- .should('be.visible')
- .and('contain.text', /verify.*email|check.*email|activation/i);
-
- // Should show email address
- cy.get('[data-testid="verification-email"], .verification-email')
- .should('contain.text', testCustomer.email);
-
- // Should have resend option
- cy.get('body').then($resendBody => {
- if ($resendBody.find('[data-testid="resend-verification"], .resend-verification').length > 0) {
- cy.get('[data-testid="resend-verification"], .resend-verification')
- .should('be.visible')
- .and('contain.text', /resend|send.*again/i);
- }
- });
- }
- });
- });
-
- it("Should handle email verification link clicks", () => {
- // Test resend functionality if available
- cy.get('body').then($body => {
- if ($body.find('[data-testid="resend-verification"], .resend-verification').length > 0) {
- cy.get('[data-testid="resend-verification"], .resend-verification').click();
-
- // Should show confirmation message
- cy.get('[data-testid="resend-success"], .resend-success, [class*="success"]')
- .should('be.visible')
- .and('contain.text', /sent|resent|check.*email/i);
- }
- });
- });
- });
-
- describe("Site Access and Functionality", () => {
- it("Should allow access to site dashboard", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="dashboard-link"], .dashboard-link').length > 0) {
- cy.get('[data-testid="dashboard-link"], .dashboard-link').then($link => {
- const href = $link.attr('href');
- if (href) {
- // Visit the dashboard link
- cy.visit(href);
-
- // Should be on a dashboard page
- cy.url().should('contain', '/wp-admin');
-
- // Should show dashboard elements
- cy.get('#wpadminbar, .wp-admin, #adminmenu').should('exist');
- }
- });
- } else {
- cy.log('No dashboard link found on confirmation page');
- }
- });
- });
-
- it("Should allow access to frontend site", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="visit-site"], .visit-site').length > 0) {
- cy.get('[data-testid="visit-site"], .visit-site').then($link => {
- const href = $link.attr('href');
- if (href) {
- // Visit the site
- cy.visit(href);
-
- // Should show site title somewhere
- cy.get('title, h1, .site-title, .site-name')
- .should('contain.text', testSite.title);
- }
- });
- } else {
- // Try to construct site URL manually
- cy.visit(`//${testSite.path}.localhost:8889`);
- cy.get('title, h1, .site-title').should('contain.text', testSite.title);
- }
- });
- });
- });
-
- describe("Payment Confirmation", () => {
- it("Should display payment information for paid plans", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="payment-info"], .payment-info, .order-summary').length > 0) {
- // Check payment status
- cy.get('[data-testid="payment-status"], .payment-status')
- .should('contain.text', /paid|complete|pending|manual/i);
-
- // Check payment method
- cy.get('[data-testid="payment-method"], .payment-method')
- .should('be.visible');
-
- // Check amount if displayed
- cy.get('body').then($amountBody => {
- if ($amountBody.find('[data-testid="payment-amount"], .payment-amount').length > 0) {
- cy.get('[data-testid="payment-amount"], .payment-amount')
- .should('match', /\$[\d.,]+/);
- }
- });
- }
- });
- });
-
- it("Should show next payment date for recurring plans", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="next-payment"], .next-payment, :contains("next payment")').length > 0) {
- cy.get('[data-testid="next-payment"], .next-payment')
- .should('be.visible')
- .and('contain.text', /next.*payment|renewal/i);
- }
- });
- });
- });
-
- describe("Plan and Limitation Information", () => {
- it("Should display selected plan details", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="plan-info"], .plan-info, .membership-info').length > 0) {
- cy.get('[data-testid="plan-info"], .plan-info, .membership-info')
- .should('be.visible');
-
- // Check plan name
- cy.get('[data-testid="plan-name"], .plan-name')
- .should('be.visible');
-
- // Check limitations if displayed
- cy.get('body').then($limitBody => {
- if ($limitBody.find('[data-testid="plan-limits"], .plan-limits').length > 0) {
- cy.get('[data-testid="plan-limits"], .plan-limits')
- .should('be.visible');
- }
- });
- }
- });
- });
- });
-
- describe("Next Steps and Onboarding", () => {
- it("Should provide getting started information", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="getting-started"], .getting-started, :contains("getting started")').length > 0) {
- cy.get('[data-testid="getting-started"], .getting-started')
- .should('be.visible')
- .and('contain.text', /getting.*started|next.*steps|what.*next/i);
- }
- });
- });
-
- it("Should show support or help information", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="support-info"], .support-info, :contains("support")').length > 0) {
- cy.get('[data-testid="support-info"], .support-info')
- .should('be.visible');
-
- // Check for support links
- cy.get('[data-testid="support-link"], .support-link, a:contains("support")')
- .should('have.attr', 'href');
- }
- });
- });
- });
-
- describe("Error Handling on Confirmation Page", () => {
- it("Should handle missing registration data gracefully", () => {
- // Visit confirmation page directly without registration
- cy.visit('/confirmation');
-
- // Should either redirect or show appropriate error
- cy.url().then(url => {
- if (url.includes('confirmation')) {
- // If we stay on confirmation page, should show error or empty state
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .or(() => {
- cy.get('[data-testid="no-data"], .no-data, :contains("no registration")')
- .should('be.visible');
- });
- } else {
- // Should redirect to appropriate page (registration, login, etc.)
- cy.url().should('satisfy', redirectUrl =>
- redirectUrl.includes('/checkout') ||
- redirectUrl.includes('/login') ||
- redirectUrl.includes('/register')
- );
- }
- });
- });
- });
-
- describe("Social Sharing and Notifications", () => {
- it("Should provide social sharing options if available", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="social-share"], .social-share').length > 0) {
- cy.get('[data-testid="social-share"], .social-share')
- .should('be.visible');
-
- // Check for common social platforms
- const socialPlatforms = ['facebook', 'twitter', 'linkedin'];
- socialPlatforms.forEach(platform => {
- cy.get(`[data-testid="${platform}-share"], .${platform}-share`).then($social => {
- if ($social.length > 0) {
- cy.wrap($social).should('be.visible');
- }
- });
- });
- }
- });
- });
- });
-
- describe("Accessibility and SEO", () => {
- it("Should have proper accessibility attributes", () => {
- // Check for proper heading hierarchy
- cy.get('h1').should('exist').and('have.length', 1);
-
- // Check for proper form labels if any forms exist
- cy.get('input').each($input => {
- const id = $input.attr('id');
- if (id) {
- cy.get(`label[for="${id}"]`).should('exist');
- }
- });
-
- // Check for alt text on images
- cy.get('img').each($img => {
- cy.wrap($img).should('have.attr', 'alt');
- });
- });
-
- it("Should have proper meta tags and page title", () => {
- cy.title().should('contain.text', /success|complete|confirmation/i);
-
- // Check for meta description
- cy.get('meta[name="description"]').should('exist');
- });
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/integration/checkout-registration.spec.js b/tests/e2e/cypress/integration/checkout-registration.spec.js
deleted file mode 100644
index 6e25ba9d..00000000
--- a/tests/e2e/cypress/integration/checkout-registration.spec.js
+++ /dev/null
@@ -1,422 +0,0 @@
-/**
- * E2E tests for the complete checkout registration flow
- *
- * This test suite covers the happy path for new user registration
- * including all typical checkout steps: product selection, user details,
- * site details, payment processing, and confirmation.
- */
-
-describe("Checkout Registration Flow", () => {
- // Test data for consistent usage across tests
- const testCustomer = {
- username: `testuser_${Date.now()}`,
- email: `testuser_${Date.now()}@example.com`,
- password: 'TestPassword123!',
- firstName: 'John',
- lastName: 'Doe'
- };
-
- const testSite = {
- title: 'Test Site',
- path: `testsite_${Date.now()}`
- };
-
- beforeEach(() => {
- // Visit the registration/checkout page
- // Note: This assumes there's a checkout form with slug 'registration'
- cy.visit('/checkout/registration');
- });
-
- it("Should complete the full registration checkout flow successfully", () => {
- // Step 1: Plan/Product Selection
- cy.log("Starting Step 1: Plan Selection");
-
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .should('be.visible')
- .first()
- .within(() => {
- cy.get('button, .wu-button, [type="submit"]').first().click();
- });
-
- // Verify we moved to the next step
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"]')
- .should('contain.text', '2')
- .or('contain.text', 'Account')
- .or('contain.text', 'Details');
-
- // Step 2: Account/User Details
- cy.log("Starting Step 2: Account Details");
-
- // Fill in username
- cy.get('#username, [name="username"], [data-testid="username"]')
- .should('be.visible')
- .clear()
- .type(testCustomer.username);
-
- // Fill in email
- cy.get('#email, [name="email"], [data-testid="email"]')
- .should('be.visible')
- .clear()
- .type(testCustomer.email);
-
- // Fill in password
- cy.get('#password, [name="password"], [data-testid="password"]')
- .should('be.visible')
- .clear()
- .type(testCustomer.password);
-
- // Fill in confirm password if it exists
- cy.get('body').then(($body) => {
- if ($body.find('#password_confirmation, [name="password_confirmation"], [data-testid="password-confirm"]').length > 0) {
- cy.get('#password_confirmation, [name="password_confirmation"], [data-testid="password-confirm"]')
- .clear()
- .type(testCustomer.password);
- }
- });
-
- // Fill in first name if it exists
- cy.get('body').then(($body) => {
- if ($body.find('#first_name, [name="first_name"], [data-testid="first-name"]').length > 0) {
- cy.get('#first_name, [name="first_name"], [data-testid="first-name"]')
- .clear()
- .type(testCustomer.firstName);
- }
- });
-
- // Fill in last name if it exists
- cy.get('body').then(($body) => {
- if ($body.find('#last_name, [name="last_name"], [data-testid="last-name"]').length > 0) {
- cy.get('#last_name, [name="last_name"], [data-testid="last-name"]')
- .clear()
- .type(testCustomer.lastName);
- }
- });
-
- // Continue to next step
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .should('not.be.disabled')
- .click();
-
- // Step 3: Site Details
- cy.log("Starting Step 3: Site Details");
-
- // Verify we're on the site details step
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"]')
- .should('contain.text', '3')
- .or('contain.text', 'Site')
- .or('contain.text', 'Domain');
-
- // Fill in site title
- cy.get('#site_title, [name="site_title"], [data-testid="site-title"]')
- .should('be.visible')
- .clear()
- .type(testSite.title);
-
- // Fill in site path/URL
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .should('be.visible')
- .clear()
- .type(testSite.path);
-
- // Select template if template selection exists
- cy.get('body').then(($body) => {
- if ($body.find('[data-testid="template-selection"], .wu-template-selection, [class*="template"]').length > 0) {
- cy.get('[data-testid="template-selection"], .wu-template-selection, [class*="template"]')
- .first()
- .click();
- }
- });
-
- // Continue to payment step
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .should('not.be.disabled')
- .click();
-
- // Step 4: Payment Details (if not free)
- cy.log("Starting Step 4: Payment Processing");
-
- // Check if this is a free plan or paid plan
- cy.get('body').then(($body) => {
- const hasFreeIndicator = $body.find('[data-testid="free-plan"], .wu-free-plan, [class*="free"]').length > 0;
- const hasPaymentFields = $body.find('[data-testid="payment-form"], .wu-payment-form, [name*="card"], [name*="billing"]').length > 0;
-
- if (hasFreeIndicator || !hasPaymentFields) {
- cy.log("Free plan detected - skipping payment details");
-
- // For free plans, just click continue/complete
- cy.get('[data-testid="complete-btn"], [data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/complete|finish|continue|create/i)
- .should('not.be.disabled')
- .click();
-
- } else {
- cy.log("Paid plan detected - filling payment details");
-
- // Fill in billing address if required
- cy.get('body').then(($billingBody) => {
- if ($billingBody.find('[name="billing_address"], [data-testid="billing-address"]').length > 0) {
- cy.get('[name="billing_address[address_line_1]"], [name="billing_address_line_1"]')
- .type('123 Test Street');
- cy.get('[name="billing_address[city]"], [name="billing_city"]')
- .type('Test City');
- cy.get('[name="billing_address[state]"], [name="billing_state"]')
- .type('CA');
- cy.get('[name="billing_address[zip_code]"], [name="billing_zip"]')
- .type('12345');
- }
- });
-
- // Select Manual Payment gateway (most reliable for testing)
- cy.get('[data-testid="gateway-manual"], [value="manual"], [data-gateway="manual"]')
- .should('be.visible')
- .click();
-
- // Complete the payment
- cy.get('[data-testid="complete-btn"], .wu-button, button[type="submit"]')
- .contains(/complete|finish|pay/i)
- .should('not.be.disabled')
- .click();
- }
- });
-
- // Step 5: Confirmation/Thank You
- cy.log("Step 5: Verifying Registration Completion");
-
- // Wait for redirect to confirmation page or success message
- cy.url({ timeout: 30000 }).should('contain', '/confirmation')
- .or('contain', '/thank')
- .or('contain', '/success');
-
- // Verify success message
- cy.get('[data-testid="success-message"], .wu-success, .notice-success, [class*="success"]')
- .should('be.visible')
- .and('contain.text', /success|complete|welcome|thank/i);
-
- // Verify customer details are displayed
- cy.get('[data-testid="customer-info"], .wu-customer-info')
- .should('contain.text', testCustomer.email);
-
- // Verify site information is displayed
- cy.get('[data-testid="site-info"], .wu-site-info')
- .should('contain.text', testSite.title);
- });
-
- it("Should validate required fields on each step", () => {
- cy.log("Testing field validation across checkout steps");
-
- // Step 1: Try to proceed without selecting a plan
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should show validation error or still be on the same step
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .or(() => {
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"]')
- .should('contain.text', '1');
- });
-
- // Select a plan to proceed
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .should('be.visible')
- .first()
- .within(() => {
- cy.get('button, .wu-button, [type="submit"]').first().click();
- });
-
- // Step 2: Try to proceed with empty required fields
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should show validation errors
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Verify specific field errors
- cy.get('#username, [name="username"]').then(($username) => {
- if ($username.length > 0) {
- cy.wrap($username).should('have.attr', 'required')
- .or('have.class', 'error')
- .or('have.class', 'invalid');
- }
- });
-
- cy.get('#email, [name="email"]').then(($email) => {
- if ($email.length > 0) {
- cy.wrap($email).should('have.attr', 'required')
- .or('have.class', 'error')
- .or('have.class', 'invalid');
- }
- });
- });
-
- it("Should handle email validation correctly", () => {
- cy.log("Testing email field validation");
-
- // Navigate to account step
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .should('be.visible')
- .first()
- .within(() => {
- cy.get('button, .wu-button, [type="submit"]').first().click();
- });
-
- // Test invalid email formats
- const invalidEmails = ['invalid', 'invalid@', '@invalid.com', 'invalid.com'];
-
- invalidEmails.forEach((invalidEmail) => {
- cy.get('#email, [name="email"], [data-testid="email"]')
- .should('be.visible')
- .clear()
- .type(invalidEmail);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
- });
-
- // Test valid email
- cy.get('#email, [name="email"], [data-testid="email"]')
- .clear()
- .type(testCustomer.email);
-
- // Fill other required fields
- cy.get('#username, [name="username"], [data-testid="username"]')
- .clear()
- .type(testCustomer.username);
-
- cy.get('#password, [name="password"], [data-testid="password"]')
- .clear()
- .type(testCustomer.password);
-
- // Should be able to proceed
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .should('not.be.disabled')
- .click();
-
- // Should advance to next step
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"]')
- .should('contain.text', '3')
- .or('contain.text', 'Site')
- .or('contain.text', 'Domain');
- });
-
- it("Should handle username availability checking", () => {
- cy.log("Testing username availability");
-
- // Navigate to account step
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .should('be.visible')
- .first()
- .within(() => {
- cy.get('button, .wu-button, [type="submit"]').first().click();
- });
-
- // Test with admin username (should be taken)
- cy.get('#username, [name="username"], [data-testid="username"]')
- .should('be.visible')
- .clear()
- .type('admin');
-
- cy.get('#email, [name="email"], [data-testid="email"]')
- .clear()
- .type(testCustomer.email);
-
- cy.get('#password, [name="password"], [data-testid="password"]')
- .clear()
- .type(testCustomer.password);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should show username taken error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /username|taken|exists/i);
-
- // Use unique username
- cy.get('#username, [name="username"], [data-testid="username"]')
- .clear()
- .type(testCustomer.username);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should advance to next step
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"]')
- .should('contain.text', '3')
- .or('contain.text', 'Site')
- .or('contain.text', 'Domain');
- });
-
- it("Should validate site URL availability", () => {
- cy.log("Testing site URL validation");
-
- // Navigate through to site details step
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .first()
- .within(() => {
- cy.get('button, .wu-button, [type="submit"]').first().click();
- });
-
- // Fill account details
- cy.get('#username, [name="username"]').type(testCustomer.username);
- cy.get('#email, [name="email"]').type(testCustomer.email);
- cy.get('#password, [name="password"]').type(testCustomer.password);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Test invalid site URLs
- const invalidSiteUrls = ['', ' ', 'site with spaces', 'UPPERCASE', 'site-with-special!'];
-
- invalidSiteUrls.forEach((invalidUrl) => {
- if (invalidUrl.trim()) {
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .clear()
- .type(invalidUrl);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
- }
- });
-
- // Test valid site URL
- cy.get('#site_title, [name="site_title"]').type(testSite.title);
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .clear()
- .type(testSite.path);
-
- cy.get('[data-testid="continue-btn"], .wu-button, button[type="submit"]')
- .contains(/continue|next|proceed/i)
- .click();
-
- // Should advance to payment/completion step
- cy.url().should('not.contain', 'step=site')
- .and('not.contain', 'step=domain');
- });
-
- // Cleanup: Remove test data after tests (if needed)
- after(() => {
- cy.log("Cleanup: Test data should be cleaned up by WordPress/plugin automatically");
- // Note: In a real scenario, you might want to clean up test users/sites
- // This could be done via WP-CLI commands or API calls
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/integration/checkout-scenarios.spec.js b/tests/e2e/cypress/integration/checkout-scenarios.spec.js
deleted file mode 100644
index 8469482e..00000000
--- a/tests/e2e/cypress/integration/checkout-scenarios.spec.js
+++ /dev/null
@@ -1,421 +0,0 @@
-/**
- * E2E tests for different checkout scenarios and edge cases
- *
- * This test suite covers various checkout scenarios including
- * different plan types, payment methods, and special cases.
- */
-
-describe("Checkout Scenarios", () => {
- const testData = {
- customers: {
- basic: {
- username: `basicuser_${Date.now()}`,
- email: `basicuser_${Date.now()}@example.com`,
- password: 'BasicPass123!',
- firstName: 'Jane',
- lastName: 'Smith'
- },
- premium: {
- username: `premiumuser_${Date.now()}`,
- email: `premiumuser_${Date.now()}@example.com`,
- password: 'PremiumPass123!',
- firstName: 'John',
- lastName: 'Premium'
- }
- },
- sites: {
- basic: {
- title: 'Basic Test Site',
- path: `basicsite_${Date.now()}`
- },
- premium: {
- title: 'Premium Business Site',
- path: `premiumsite_${Date.now()}`
- }
- }
- };
-
- describe("Free Plan Registration", () => {
- it("Should complete registration with free plan", () => {
- cy.visitCheckoutForm('registration');
-
- // Look for and select free plan
- cy.get('body').then($body => {
- // Try to find free plan indicators
- const freePlanSelectors = [
- '[data-testid*="free"]',
- '[class*="free"]',
- '[data-price="0"]',
- ':contains("Free")',
- ':contains("$0")'
- ];
-
- let freePlanFound = false;
-
- freePlanSelectors.forEach(selector => {
- if (!freePlanFound && $body.find(selector).length > 0) {
- cy.get(selector).first().within(() => {
- cy.get('button, .wu-button, [type="submit"]').click();
- });
- freePlanFound = true;
- }
- });
-
- if (!freePlanFound) {
- // Fallback to first plan (might be free)
- cy.selectPricingPlan(0);
- }
- });
-
- // Fill account details
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
-
- // Fill site details
- cy.fillSiteDetails(testData.sites.basic);
- cy.selectSiteTemplate(0);
- cy.proceedToNextStep();
-
- // Complete registration (should skip payment for free plan)
- cy.completeCheckout();
-
- // Verify success
- cy.verifyCheckoutSuccess({
- email: testData.customers.basic.email,
- siteTitle: testData.sites.basic.title
- });
- });
- });
-
- describe("Paid Plan Registration", () => {
- it("Should complete registration with paid plan using manual payment", () => {
- cy.visitCheckoutForm('registration');
-
- // Select a paid plan (try to find one that's not free)
- cy.get('body').then($body => {
- const paidPlanSelectors = [
- '[data-testid*="paid"]',
- '[class*="premium"]',
- '[class*="pro"]',
- ':contains("$") [data-testid*="plan"]',
- ':not(:contains("Free")) [data-testid*="plan"]'
- ];
-
- let paidPlanFound = false;
-
- paidPlanSelectors.forEach(selector => {
- if (!paidPlanFound && $body.find(selector).length > 0) {
- cy.get(selector).first().within(() => {
- cy.get('button, .wu-button, [type="submit"]').click();
- });
- paidPlanFound = true;
- }
- });
-
- if (!paidPlanFound) {
- // Fallback to second plan if available
- cy.get('[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]')
- .then($plans => {
- if ($plans.length > 1) {
- cy.selectPricingPlan(1);
- } else {
- cy.selectPricingPlan(0);
- }
- });
- }
- });
-
- // Fill account details
- cy.fillAccountDetails(testData.customers.premium);
- cy.proceedToNextStep();
-
- // Fill site details
- cy.fillSiteDetails(testData.sites.premium);
- cy.selectSiteTemplate(0);
- cy.proceedToNextStep();
-
- // Handle payment
- cy.fillBillingAddress({
- address: '456 Premium Street',
- city: 'Business City',
- state: 'NY',
- zipCode: '54321'
- });
-
- cy.selectPaymentGateway('manual');
- cy.completeCheckout();
-
- // Verify success
- cy.verifyCheckoutSuccess({
- email: testData.customers.premium.email,
- siteTitle: testData.sites.premium.title
- });
- });
- });
-
- describe("Multi-step Navigation", () => {
- beforeEach(() => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- });
-
- it("Should handle browser back/forward navigation", () => {
- // Fill account details and proceed
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
-
- // Verify we're on step 3 (site details)
- cy.assertCheckoutStep('3');
-
- // Go back using browser navigation
- cy.go('back');
-
- // Should be back on step 2 with form data preserved
- cy.assertCheckoutStep('2');
- cy.get('#username, [name="username"]')
- .should('have.value', testData.customers.basic.username);
-
- // Go forward again
- cy.go('forward');
-
- // Should be on step 3 again
- cy.assertCheckoutStep('3');
- });
-
- it("Should allow step navigation via step indicators", () => {
- // Fill account details
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
-
- // Check if step navigation is available
- cy.get('body').then($body => {
- const hasStepNavigation = $body.find('[data-testid="step-nav"], .wu-step-nav, [class*="step-nav"]').length > 0;
-
- if (hasStepNavigation) {
- // Try to navigate back to step 2
- cy.get('[data-testid="step-2"], [data-step="2"], .step-2').click();
-
- // Should be back on account details step
- cy.assertCheckoutStep('2');
- cy.get('#username, [name="username"]')
- .should('have.value', testData.customers.basic.username);
- } else {
- cy.log('Step navigation not available');
- }
- });
- });
- });
-
- describe("Template Selection Scenarios", () => {
- beforeEach(() => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
- });
-
- it("Should handle sites with template selection", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="template-selection"], .wu-template-selection').length > 0) {
- // Test selecting different templates
- cy.get('[data-testid="template-selection"], .wu-template-selection')
- .should('have.length.at.least', 1);
-
- // Select first template
- cy.selectSiteTemplate(0);
-
- // Fill site details
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
-
- // Should proceed to next step
- cy.url().should('not.contain', 'template');
-
- } else {
- cy.log('No template selection available');
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
- }
- });
- });
-
- it("Should handle blank/custom template option", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="template-blank"], [data-template="blank"], :contains("Blank")').length > 0) {
- cy.get('[data-testid="template-blank"], [data-template="blank"], :contains("Blank")').click();
-
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
-
- // Should proceed successfully
- cy.url().should('not.contain', 'template');
- }
- });
- });
- });
-
- describe("Payment Gateway Scenarios", () => {
- beforeEach(() => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
- });
-
- it("Should handle manual payment gateway", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-gateway="manual"], [value="manual"]').length > 0) {
- cy.selectPaymentGateway('manual');
- cy.completeCheckout();
-
- // Should complete successfully
- cy.verifyCheckoutSuccess({
- email: testData.customers.basic.email,
- siteTitle: testData.sites.basic.title
- });
- }
- });
- });
-
- it("Should handle free gateway for zero-cost orders", () => {
- cy.get('body').then($body => {
- const hasFreeGateway = $body.find('[data-gateway="free"], [value="free"]').length > 0;
- const isFreeOrder = $body.find(':contains("$0"), :contains("Free"), [data-price="0"]').length > 0;
-
- if (hasFreeGateway && isFreeOrder) {
- cy.selectPaymentGateway('free');
- cy.completeCheckout();
-
- cy.verifyCheckoutSuccess({
- email: testData.customers.basic.email,
- siteTitle: testData.sites.basic.title
- });
- }
- });
- });
- });
-
- describe("Discount Code Scenarios", () => {
- beforeEach(() => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- });
-
- it("Should handle discount code application", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="discount-code"], [name*="discount"], [name*="coupon"]').length > 0) {
- // Try applying a discount code
- cy.get('[data-testid="discount-code"], [name*="discount"], [name*="coupon"]')
- .type('TESTCODE');
-
- cy.get('[data-testid="apply-discount"], [data-testid="apply-coupon"], button:contains("Apply")').click();
-
- // Should show either success or error message
- cy.get('[data-testid="discount-message"], .discount-message, .coupon-message')
- .should('be.visible');
-
- // Continue with checkout
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
- cy.completeCheckout();
-
- cy.verifyCheckoutSuccess({
- email: testData.customers.basic.email,
- siteTitle: testData.sites.basic.title
- });
- } else {
- cy.log('No discount code field found');
- }
- });
- });
- });
-
- describe("Error Recovery Scenarios", () => {
- it("Should handle session timeout gracefully", () => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.customers.basic);
-
- // Simulate session timeout by clearing cookies
- cy.clearCookies();
-
- cy.proceedToNextStep();
-
- // Should either redirect to login or show session error
- cy.url().then(url => {
- if (url.includes('login')) {
- cy.log('Redirected to login as expected');
- } else {
- cy.get('[data-testid="error"], .wu-error, .error')
- .should('be.visible')
- .and('contain.text', /session|expired|login/i);
- }
- });
- });
-
- it("Should handle network errors during submission", () => {
- cy.visitCheckoutForm('registration');
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
-
- // Intercept checkout request to simulate network error
- cy.intercept('POST', '**/checkout**', { forceNetworkError: true }).as('checkoutError');
-
- cy.completeCheckout();
-
- // Should handle error gracefully
- cy.wait('@checkoutError');
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /network|connection|try.*again/i);
- });
- });
-
- describe("Mobile Responsiveness", () => {
- beforeEach(() => {
- cy.viewport('iphone-x');
- cy.visitCheckoutForm('registration');
- });
-
- it("Should complete checkout on mobile device", () => {
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.customers.basic);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testData.sites.basic);
- cy.proceedToNextStep();
- cy.completeCheckout();
-
- cy.verifyCheckoutSuccess({
- email: testData.customers.basic.email,
- siteTitle: testData.sites.basic.title
- });
- });
-
- it("Should handle mobile form interactions", () => {
- // Test that form fields are accessible on mobile
- cy.get('#username, [name="username"]').should('be.visible');
- cy.get('#email, [name="email"]').should('be.visible');
-
- // Test mobile-specific interactions
- cy.selectPricingPlan(0);
-
- cy.get('#username, [name="username"]').type(testData.customers.basic.username);
- cy.get('#email, [name="email"]').type(testData.customers.basic.email);
-
- // Should not have horizontal scroll
- cy.window().its('scrollX').should('equal', 0);
- });
- });
-
- afterEach(() => {
- // Reset viewport for subsequent tests
- cy.viewport(1000, 600);
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/integration/checkout-validation.spec.js b/tests/e2e/cypress/integration/checkout-validation.spec.js
deleted file mode 100644
index 95538d28..00000000
--- a/tests/e2e/cypress/integration/checkout-validation.spec.js
+++ /dev/null
@@ -1,417 +0,0 @@
-/**
- * E2E tests for checkout form validation
- *
- * This test suite focuses specifically on field validation,
- * error handling, and form validation across the checkout flow.
- */
-
-describe("Checkout Form Validation", () => {
- const testData = {
- validCustomer: {
- username: `testuser_${Date.now()}`,
- email: `testuser_${Date.now()}@example.com`,
- password: 'ValidPassword123!',
- firstName: 'John',
- lastName: 'Doe'
- },
- validSite: {
- title: 'Valid Test Site',
- path: `validsite_${Date.now()}`
- }
- };
-
- beforeEach(() => {
- cy.visitCheckoutForm('registration');
- });
-
- describe("Product Selection Validation", () => {
- it("Should require plan selection before proceeding", () => {
- // Try to proceed without selecting any plan
- cy.proceedToNextStep();
-
- // Should show error or remain on same step
- cy.hasValidationErrors().then(hasErrors => {
- if (!hasErrors) {
- // If no explicit error, should still be on step 1
- cy.assertCheckoutStep('1');
- } else {
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /select|choose|plan|product/i);
- }
- });
- });
-
- it("Should allow proceeding after plan selection", () => {
- cy.selectPricingPlan(0);
-
- // Should advance to next step
- cy.assertCheckoutStep('2');
- });
- });
-
- describe("Account Details Validation", () => {
- beforeEach(() => {
- // Navigate to account details step
- cy.selectPricingPlan(0);
- });
-
- it("Should validate required fields", () => {
- // Try to proceed with empty fields
- cy.proceedToNextStep();
-
- // Should show validation errors
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Check that required fields are marked as invalid
- cy.get('#username, [name="username"]').then($username => {
- if ($username.length > 0) {
- cy.wrap($username).should('satisfy', $el =>
- $el.attr('required') !== undefined ||
- $el.hasClass('error') ||
- $el.hasClass('invalid') ||
- $el.get(0).checkValidity() === false
- );
- }
- });
-
- cy.get('#email, [name="email"]').then($email => {
- if ($email.length > 0) {
- cy.wrap($email).should('satisfy', $el =>
- $el.attr('required') !== undefined ||
- $el.hasClass('error') ||
- $el.hasClass('invalid') ||
- $el.get(0).checkValidity() === false
- );
- }
- });
- });
-
- it("Should validate email format", () => {
- const invalidEmails = [
- 'invalid-email',
- 'invalid@',
- '@invalid.com',
- 'invalid.email',
- 'spaces @email.com',
- 'email@',
- 'email@.com'
- ];
-
- invalidEmails.forEach(invalidEmail => {
- cy.get('#email, [name="email"], [data-testid="email"]')
- .clear()
- .type(invalidEmail);
-
- cy.get('#username, [name="username"]').type(testData.validCustomer.username);
- cy.get('#password, [name="password"]').type(testData.validCustomer.password);
-
- cy.proceedToNextStep();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Clear fields for next iteration
- cy.get('#email, [name="email"]').clear();
- cy.get('#username, [name="username"]').clear();
- cy.get('#password, [name="password"]').clear();
- });
- });
-
- it("Should validate username format and availability", () => {
- // Test invalid username formats
- const invalidUsernames = [
- '', // empty
- 'ab', // too short
- 'user name', // spaces
- 'user@name', // special characters
- 'UPPERCASE', // case sensitivity
- '123numericstart'
- ];
-
- invalidUsernames.forEach(invalidUsername => {
- if (invalidUsername.trim() !== '') {
- cy.get('#username, [name="username"], [data-testid="username"]')
- .clear()
- .type(invalidUsername);
-
- cy.get('#email, [name="email"]').type(testData.validCustomer.email);
- cy.get('#password, [name="password"]').type(testData.validCustomer.password);
-
- cy.proceedToNextStep();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Clear fields
- cy.get('#username, [name="username"]').clear();
- cy.get('#email, [name="email"]').clear();
- cy.get('#password, [name="password"]').clear();
- }
- });
-
- // Test existing username (admin should exist)
- cy.get('#username, [name="username"]').clear().type('admin');
- cy.get('#email, [name="email"]').clear().type(testData.validCustomer.email);
- cy.get('#password, [name="password"]').clear().type(testData.validCustomer.password);
-
- cy.proceedToNextStep();
-
- // Should show username taken error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /username.*taken|username.*exists|already.*use/i);
- });
-
- it("Should validate password requirements", () => {
- const weakPasswords = [
- '', // empty
- '123', // too short
- 'password', // too simple
- '12345678' // numeric only
- ];
-
- weakPasswords.forEach(weakPassword => {
- if (weakPassword.trim() !== '') {
- cy.get('#password, [name="password"], [data-testid="password"]')
- .clear()
- .type(weakPassword);
-
- cy.get('#username, [name="username"]').type(testData.validCustomer.username);
- cy.get('#email, [name="email"]').type(testData.validCustomer.email);
-
- cy.proceedToNextStep();
-
- // Should show password validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Clear fields
- cy.get('#password, [name="password"]').clear();
- cy.get('#username, [name="username"]').clear();
- cy.get('#email, [name="email"]').clear();
- }
- });
- });
-
- it("Should validate password confirmation match", () => {
- cy.get('body').then($body => {
- // Only test if password confirmation field exists
- if ($body.find('#password_confirmation, [name="password_confirmation"]').length > 0) {
- cy.get('#username, [name="username"]').type(testData.validCustomer.username);
- cy.get('#email, [name="email"]').type(testData.validCustomer.email);
- cy.get('#password, [name="password"]').type(testData.validCustomer.password);
- cy.get('#password_confirmation, [name="password_confirmation"]').type('DifferentPassword123!');
-
- cy.proceedToNextStep();
-
- // Should show password mismatch error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /password.*match|confirm.*password/i);
- }
- });
- });
- });
-
- describe("Site Details Validation", () => {
- beforeEach(() => {
- // Navigate to site details step
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.validCustomer);
- cy.proceedToNextStep();
- });
-
- it("Should validate required site fields", () => {
- // Try to proceed with empty site fields
- cy.proceedToNextStep();
-
- // Should show validation errors
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
- });
-
- it("Should validate site URL format", () => {
- const invalidSiteUrls = [
- '', // empty
- ' ', // only spaces
- 'site with spaces',
- 'UPPERCASE-SITE',
- 'site-with-special!@#',
- 'site..double-dots',
- '-starting-dash',
- 'ending-dash-',
- '123numericstart'
- ];
-
- invalidSiteUrls.forEach(invalidUrl => {
- if (invalidUrl.trim()) {
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .clear()
- .type(invalidUrl);
-
- cy.get('#site_title, [name="site_title"]').type(testData.validSite.title);
-
- cy.proceedToNextStep();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Clear fields
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]').clear();
- cy.get('#site_title, [name="site_title"]').clear();
- }
- });
- });
-
- it("Should validate site title requirements", () => {
- const invalidTitles = [
- '', // empty
- ' ', // only spaces
- 'A', // too short
- 'X'.repeat(256) // too long
- ];
-
- invalidTitles.forEach(invalidTitle => {
- cy.get('#site_title, [name="site_title"], [data-testid="site-title"]')
- .clear()
- .type(invalidTitle);
-
- cy.get('#site_url, [name="site_url"]').type(testData.validSite.path);
-
- cy.proceedToNextStep();
-
- // Should show validation error for empty/invalid titles
- if (invalidTitle.trim() === '' || invalidTitle.length > 255) {
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
- }
-
- // Clear fields
- cy.get('#site_title, [name="site_title"]').clear();
- cy.get('#site_url, [name="site_url"]').clear();
- });
- });
-
- it("Should check site URL availability", () => {
- // Test with existing site URL (main site should exist)
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .clear()
- .type('main'); // or 'blog' or 'www' - common existing paths
-
- cy.get('#site_title, [name="site_title"]').type(testData.validSite.title);
-
- cy.proceedToNextStep();
-
- // Should show site URL taken error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible')
- .and('contain.text', /site.*taken|url.*exists|already.*use|not.*available/i);
- });
- });
-
- describe("Payment Validation", () => {
- beforeEach(() => {
- // Navigate to payment step
- cy.selectPricingPlan(0);
- cy.fillAccountDetails(testData.validCustomer);
- cy.proceedToNextStep();
- cy.fillSiteDetails(testData.validSite);
- cy.proceedToNextStep();
- });
-
- it("Should handle free plan checkout", () => {
- cy.get('body').then($body => {
- if ($body.find('[data-testid="free-plan"], .wu-free-plan, [class*="free"]').length > 0) {
- // For free plans, should be able to complete directly
- cy.completeCheckout();
-
- // Should proceed to confirmation
- cy.verifyCheckoutSuccess({
- email: testData.validCustomer.email,
- siteTitle: testData.validSite.title
- });
- } else {
- cy.log('Not a free plan, skipping free plan test');
- }
- });
- });
-
- it("Should validate billing information for paid plans", () => {
- cy.get('body').then($body => {
- if ($body.find('[name*="billing"], [data-testid*="billing"]').length > 0) {
- // Try to proceed without billing info
- cy.completeCheckout();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Fill billing info and try again
- cy.fillBillingAddress();
- cy.selectPaymentGateway('manual');
- cy.completeCheckout();
-
- // Should proceed successfully
- cy.verifyCheckoutSuccess({
- email: testData.validCustomer.email,
- siteTitle: testData.validSite.title
- });
- } else {
- cy.log('No billing fields found, likely free plan');
- }
- });
- });
-
- it("Should require payment gateway selection", () => {
- cy.get('body').then($body => {
- const hasPaymentGateways = $body.find('[name*="gateway"], [data-testid*="gateway"]').length > 0;
-
- if (hasPaymentGateways) {
- // Try to complete without selecting gateway
- cy.completeCheckout();
-
- // Should show validation error
- cy.get('[data-testid="error"], .wu-error, .error, [class*="error"]')
- .should('be.visible');
-
- // Select payment gateway and try again
- cy.selectPaymentGateway('manual');
- cy.completeCheckout();
-
- // Should proceed successfully
- cy.verifyCheckoutSuccess({
- email: testData.validCustomer.email,
- siteTitle: testData.validSite.title
- });
- }
- });
- });
- });
-
- describe("Cross-field Validation", () => {
- it("Should handle email uniqueness across users", () => {
- // This would require testing with existing user email
- // Skip if no existing users or implement user creation first
- cy.log('Email uniqueness validation would require existing test data');
- });
-
- it("Should maintain form state during validation errors", () => {
- cy.selectPricingPlan(0);
-
- // Fill partial form data
- cy.get('#username, [name="username"]').type(testData.validCustomer.username);
- cy.get('#email, [name="email"]').type('invalid-email');
-
- // Try to proceed
- cy.proceedToNextStep();
-
- // Should show error but maintain username field value
- cy.get('#username, [name="username"]').should('have.value', testData.validCustomer.username);
- });
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/integration/domain-mapping-roles.md b/tests/e2e/cypress/integration/domain-mapping-roles.md
deleted file mode 100644
index 1cc9a4ce..00000000
--- a/tests/e2e/cypress/integration/domain-mapping-roles.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# Domain Mapping Roles E2E Test
-
-## Overview
-
-This test suite verifies the fix for user role loading on domain-mapped sites (commit: `adf85bb`).
-
-## The Problem Being Tested
-
-When a WordPress Multisite site is accessed via a custom/mapped domain, user roles may not be properly loaded if domain mapping occurs early (via sunrise.php). This causes issues with:
-
-- Plugins that check user roles (e.g., If Menu, WooCommerce)
-- User capability checks (`current_user_can()`)
-- Role-based UI elements (admin menus, etc.)
-
-## The Fix
-
-The fix adds a `refresh_user_roles_for_mapped_domain()` method to the `Domain_Mapping` class that:
-
-1. Hooks into `set_current_user` action
-2. Detects when a domain-mapped site is being accessed
-3. Calls `$user->for_site($current_blog_id)` to refresh roles for the correct blog context
-
-## Test Coverage
-
-The test suite includes 6 comprehensive tests:
-
-### 1. Baseline Test - Original Subdomain
-Verifies that roles work correctly on the original subdomain (establishes baseline).
-
-### 2. Critical Test - Mapped Domain
-**This is the main test for the fix.** Verifies that user roles are correctly loaded when accessing a site via a mapped domain.
-
-### 3. Plugin Compatibility Test
-Simulates how plugins check user roles and ensures the fix works for plugin integrations.
-
-### 4. Multiple Users Test
-Verifies the fix works correctly for multiple users with different roles on the same mapped domain.
-
-### 5. Role Change Test
-Ensures that role changes are reflected when accessing via mapped domain.
-
-### 6. Inactive Mapping Test
-Verifies that inactive domain mappings don't interfere with role loading.
-
-## Requirements
-
-### Environment Setup
-
-1. **WordPress Multisite**: Test requires a multisite installation
-2. **Ultimate Multisite Plugin**: Must be active with domain mapping enabled
-3. **Test Environment**: Uses `@wordpress/env` or similar
-
-### Domain Configuration
-
-**IMPORTANT**: For domain mapping tests to work properly, you need to configure domain resolution. There are several approaches:
-
-#### Option 1: Hosts File (Recommended for local testing)
-Add entries to `/etc/hosts`:
-```
-127.0.0.1 test-123456.example.com
-127.0.0.1 test-123457.example.com
-```
-
-#### Option 2: DNS Wildcard (For CI/CD)
-Configure wildcard DNS for `*.example.com` pointing to test server.
-
-#### Option 3: Test Framework Configuration
-Some test environments support custom host headers without DNS/hosts file changes.
-
-### Running the Tests
-
-```bash
-# Open Cypress UI (recommended for debugging)
-npm run cy:open:test
-
-# Run tests headlessly
-npm run cy:run:test
-
-# Run only domain mapping tests
-npx cypress run --spec "tests/e2e/cypress/integration/domain-mapping-roles.spec.js"
-```
-
-## Known Limitations
-
-1. **Domain Resolution**: Tests may need environment-specific configuration for domain resolution
-2. **Timing**: Some tests may need additional wait times depending on server performance
-3. **Cleanup**: Test cleanup depends on wp-cli being available in the test environment
-
-## Troubleshooting
-
-### Test Fails: "Domain not accessible"
-- Check that domain resolution is configured (hosts file or DNS)
-- Verify web server accepts the test domains
-- Check Ultimate Multisite domain mapping is enabled
-
-### Test Fails: "User roles empty"
-- This indicates the fix may not be working correctly
-- Check that the `refresh_user_roles_for_mapped_domain()` method is being called
-- Add debug logging to verify the hook is firing
-
-### Test Fails: "Site creation failed"
-- Ensure wp-cli is available in test environment
-- Check WordPress multisite is properly configured
-- Verify database permissions
-
-## Debugging
-
-To debug test failures:
-
-1. **Run with Cypress UI**: `npm run cy:open:test`
-2. **Check Screenshots**: Failed tests capture screenshots in `tests/e2e/cypress/screenshots/`
-3. **Check Videos**: Test recordings are in `tests/e2e/cypress/videos/`
-4. **Add cy.log()**: Insert additional logging in the test
-5. **Use cy.pause()**: Add breakpoints in the test
-
-## Success Criteria
-
-All 6 tests should pass, demonstrating that:
-- ✓ User roles load correctly on original subdomains (baseline)
-- ✓ User roles load correctly on mapped domains (the fix)
-- ✓ Plugins can check user roles on mapped domains
-- ✓ Multiple users with different roles work correctly
-- ✓ Role changes are reflected on mapped domains
-- ✓ Inactive mappings don't interfere
-
-## Related Code
-
-- **Fix**: `inc/class-domain-mapping.php:refresh_user_roles_for_mapped_domain()`
-- **Commit**: `adf85bb` - "fix roles with custom domain"
-- **Issue**: User roles not loaded when accessing via custom domain
-
-## Future Improvements
-
-- Add tests for more complex role scenarios (custom capabilities)
-- Test with actual plugins (If Menu, WooCommerce)
-- Add performance tests for role loading
-- Test with multiple concurrent users
diff --git a/tests/e2e/cypress/integration/domain-mapping-roles.spec.js b/tests/e2e/cypress/integration/domain-mapping-roles.spec.js
deleted file mode 100644
index 0112f68f..00000000
--- a/tests/e2e/cypress/integration/domain-mapping-roles.spec.js
+++ /dev/null
@@ -1,406 +0,0 @@
-/**
- * E2E tests for domain mapping with user roles
- *
- * This test suite verifies that user roles are correctly loaded when accessing
- * a site via a custom/mapped domain. This is critical for plugins that check
- * user capabilities (like If Menu, WooCommerce, etc.).
- *
- * Background:
- * When domain mapping occurs early (via sunrise.php), WordPress may cache
- * user role data before the correct blog context is established. The fix
- * in inc/class-domain-mapping.php ensures that user roles are properly
- * refreshed for the mapped blog context.
- *
- * @see inc/class-domain-mapping.php:refresh_user_roles_for_mapped_domain()
- */
-
-describe("Domain Mapping - User Roles", () => {
- // Test data
- let testSite = {
- id: null,
- title: 'Domain Mapped Site',
- path: `mapped_${Date.now()}`,
- domain: `test-${Date.now()}.example.com`
- };
-
- let testUser = {
- id: null,
- username: `domainuser_${Date.now()}`,
- email: `domainuser_${Date.now()}@example.com`,
- password: 'TestPassword123!',
- role: 'editor'
- };
-
- before(() => {
- cy.log("Setting up test environment for domain mapping");
-
- // Ensure admin is logged in
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
- });
-
- /**
- * Test 1: Verify user roles are loaded on original domain (baseline)
- *
- * This test establishes the baseline behavior - when accessing a site
- * via its original subdomain, user roles should be properly loaded.
- */
- it("Should load user roles correctly on original subdomain (baseline)", () => {
- cy.log("Creating test site and user");
-
- // Create a new site
- cy.createTestSite(testSite.path, testSite.title).then((siteId) => {
- testSite.id = siteId;
- cy.log(`Created site with ID: ${siteId}`);
-
- // Create a test user on this site with editor role
- cy.createTestUser(
- testUser.username,
- testUser.email,
- testUser.password,
- testUser.role,
- siteId
- ).then((userId) => {
- testUser.id = userId;
- cy.log(`Created user with ID: ${userId} and role: ${testUser.role}`);
- });
- });
-
- // Login as the test user
- cy.loginByApi(testUser.username, testUser.password);
-
- // Switch to the test site
- cy.switchToSite(testSite.id);
-
- // Visit the site admin
- cy.visit(`/wp-admin/`);
-
- // Verify user is logged in and has access to admin
- cy.get('#wpadminbar').should('be.visible');
-
- // Check that user has editor capabilities
- // Editors should see Posts menu
- cy.get('#menu-posts').should('be.visible');
-
- // Editors should NOT see Users menu (admin only)
- cy.get('#menu-users').should('not.exist');
-
- // Verify user role via API/wp-admin
- cy.wpCli(`user get ${testUser.id} --field=roles --format=json`, {
- failOnNonZeroExit: false
- }).then((result) => {
- if (result.code === 0) {
- const roles = JSON.parse(result.stdout);
- expect(roles).to.include(testUser.role);
- }
- });
-
- cy.log("✓ Baseline test passed - roles work on original subdomain");
- });
-
- /**
- * Test 2: Verify user roles are loaded on mapped domain
- *
- * This is the critical test that verifies the fix. When accessing a site
- * via a custom/mapped domain, user roles should still be properly loaded.
- */
- it("Should load user roles correctly when accessing via mapped domain", () => {
- cy.log("Setting up domain mapping for the test site");
-
- // Login as admin to set up domain mapping
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
-
- // Add a custom domain mapping to the test site
- cy.addDomainMapping(testSite.id, testSite.domain, true).then(() => {
- cy.log(`Added domain mapping: ${testSite.domain} → Site ${testSite.id}`);
- });
-
- // Now login as the test user
- cy.loginByApi(testUser.username, testUser.password);
-
- // CRITICAL: Access the site via the mapped domain
- // This simulates a real-world scenario where users access sites via custom domains
- cy.visitMappedDomain(testSite.domain, '/wp-admin/');
-
- // Verify user is logged in
- cy.get('#wpadminbar', { timeout: 10000 }).should('be.visible');
-
- // MAIN TEST: Verify user roles are properly loaded on mapped domain
- // The fix ensures that refresh_user_roles_for_mapped_domain() is called
- // and user capabilities are correctly initialized
-
- // Check that user still has editor capabilities
- cy.get('#menu-posts').should('be.visible');
-
- // Editors should still NOT see Users menu
- cy.get('#menu-users').should('not.exist');
-
- // Verify via JavaScript that user object has correct roles
- cy.window().then((win) => {
- // Access WordPress user data if available
- if (win.wp && win.wp.data && win.wp.data.select) {
- const currentUser = win.wp.data.select('core').getCurrentUser();
- if (currentUser && currentUser.roles) {
- expect(currentUser.roles).to.include(testUser.role);
- cy.log(`✓ User roles verified via JS: ${currentUser.roles.join(', ')}`);
- }
- }
- });
-
- // Test a role-dependent action - creating a post (editors can do this)
- cy.visit('/wp-admin/post-new.php');
- cy.get('#title', { timeout: 10000 }).should('be.visible');
- cy.log("✓ User can access post editor (editor capability confirmed)");
-
- // Try to access Users page (should be blocked for editor role)
- cy.visit('/wp-admin/users.php', { failOnStatusCode: false });
-
- // Should see "You do not have permission" message or redirect
- cy.get('body').then(($body) => {
- const bodyText = $body.text();
- const hasPermissionError = bodyText.includes('permission') ||
- bodyText.includes('not allowed') ||
- bodyText.includes('sufficient');
-
- if (hasPermissionError) {
- cy.log("✓ User correctly denied access to Users page (not an admin)");
- } else {
- // Might have redirected away from users.php
- cy.url().should('not.contain', 'users.php');
- cy.log("✓ User redirected away from Users page (not an admin)");
- }
- });
-
- cy.log("✓ CRITICAL TEST PASSED - User roles work correctly on mapped domain!");
- });
-
- /**
- * Test 3: Verify role-based plugin functionality on mapped domain
- *
- * This test simulates how plugins like "If Menu" check user roles.
- * The fix ensures that plugins checking $current_user->roles get correct data.
- */
- it("Should allow plugins to check user roles correctly on mapped domain", () => {
- cy.log("Testing role-based functionality (simulating plugins like If Menu)");
-
- // Login as test user
- cy.loginByApi(testUser.username, testUser.password);
-
- // Access via mapped domain
- cy.visitMappedDomain(testSite.domain, '/wp-admin/');
-
- // Execute JavaScript to simulate how plugins check user roles
- cy.window().then((win) => {
- // Create a custom command to check roles (simulating a plugin)
- win.testRoleCheck = function() {
- // This simulates what plugins like If Menu do
- if (typeof wpApiSettings !== 'undefined' && wpApiSettings.nonce) {
- return fetch('/wp-json/wp/v2/users/me', {
- headers: {
- 'X-WP-Nonce': wpApiSettings.nonce
- },
- credentials: 'same-origin'
- })
- .then(response => response.json())
- .then(user => user.roles);
- }
- return Promise.resolve([]);
- };
- });
-
- // Execute the role check
- cy.window().then((win) => {
- return cy.wrap(win.testRoleCheck()).then((roles) => {
- if (roles && roles.length > 0) {
- expect(roles).to.include(testUser.role);
- cy.log(`✓ Plugin role check successful: ${roles.join(', ')}`);
- } else {
- cy.log("⚠ Could not verify via REST API (may not be available)");
- }
- });
- });
-
- // Test via current_user_can() equivalent
- // Check if user can edit posts (editor capability)
- cy.visit('/wp-admin/edit.php');
- cy.get('.page-title-action', { timeout: 10000 }).should('contain', 'Add New');
- cy.log("✓ User has edit_posts capability (editor role confirmed)");
-
- // Verify user cannot manage options (admin-only capability)
- cy.visit('/wp-admin/options-general.php', { failOnStatusCode: false });
- cy.get('body').then(($body) => {
- const bodyText = $body.text();
- const hasPermissionError = bodyText.includes('permission') ||
- bodyText.includes('not allowed');
- expect(hasPermissionError).to.be.true;
- cy.log("✓ User correctly denied manage_options capability (not admin)");
- });
- });
-
- /**
- * Test 4: Verify multiple users with different roles on mapped domain
- *
- * This test ensures the fix works for multiple users with different roles.
- */
- it("Should handle multiple users with different roles on mapped domain", () => {
- cy.log("Testing multiple users with different roles");
-
- // Create a subscriber user
- const subscriberUser = {
- username: `subscriber_${Date.now()}`,
- email: `subscriber_${Date.now()}@example.com`,
- password: 'TestPassword123!',
- role: 'subscriber'
- };
-
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
-
- cy.createTestUser(
- subscriberUser.username,
- subscriberUser.email,
- subscriberUser.password,
- subscriberUser.role,
- testSite.id
- );
-
- // Test subscriber on mapped domain
- cy.loginByApi(subscriberUser.username, subscriberUser.password);
- cy.visitMappedDomain(testSite.domain, '/wp-admin/');
-
- // Subscribers should have very limited access
- cy.get('#wpadminbar').should('be.visible');
-
- // Subscribers should NOT see Posts menu
- cy.get('#menu-posts').should('not.exist');
-
- // Subscribers should only see Profile
- cy.get('#menu-users a[href*="profile.php"]').should('be.visible');
-
- cy.log("✓ Subscriber role correctly enforced on mapped domain");
-
- // Now test the editor again to ensure roles are not mixed up
- cy.loginByApi(testUser.username, testUser.password);
- cy.visitMappedDomain(testSite.domain, '/wp-admin/');
-
- // Editor should still have Posts access
- cy.get('#menu-posts').should('be.visible');
- cy.log("✓ Editor role still correct after subscriber login");
- });
-
- /**
- * Test 5: Verify role changes are reflected on mapped domain
- *
- * This ensures that when a user's role changes, the change is
- * correctly reflected when accessing via mapped domain.
- */
- it("Should reflect role changes when accessing via mapped domain", () => {
- cy.log("Testing role changes on mapped domain");
-
- // Login as admin
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
-
- // Change test user's role from editor to author
- cy.wpCli(`user set-role ${testUser.id} author --url=${testSite.path}`);
-
- // Login as test user
- cy.loginByApi(testUser.username, testUser.password);
-
- // Access via mapped domain
- cy.visitMappedDomain(testSite.domain, '/wp-admin/');
-
- // Authors should see Posts menu
- cy.get('#menu-posts').should('be.visible');
-
- // Authors should NOT see others' posts in edit list
- cy.visit('/wp-admin/edit.php');
-
- // Verify author capabilities
- cy.get('.page-title-action').should('contain', 'Add New');
- cy.log("✓ Author role correctly applied on mapped domain after role change");
-
- // Change back to editor for cleanup
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
- cy.wpCli(`user set-role ${testUser.id} editor --url=${testSite.path}`);
- });
-
- /**
- * Test 6: Verify inactive domain mappings don't affect roles
- *
- * This ensures that inactive mappings don't interfere with role loading.
- */
- it("Should not interfere with roles when domain mapping is inactive", () => {
- cy.log("Testing with inactive domain mapping");
-
- // Login as admin
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
-
- // Deactivate the domain mapping
- cy.deactivateDomainMapping(testSite.id, testSite.domain);
-
- // Login as test user
- cy.loginByApi(testUser.username, testUser.password);
-
- // Access via original subdomain (not mapped domain)
- cy.switchToSite(testSite.id);
- cy.visit('/wp-admin/');
-
- // Roles should still work correctly
- cy.get('#menu-posts').should('be.visible');
- cy.log("✓ Roles work correctly when domain mapping is inactive");
-
- // Reactivate for cleanup
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
- cy.addDomainMapping(testSite.id, testSite.domain, true);
- });
-
- /**
- * Cleanup after all tests
- */
- after(() => {
- cy.log("Cleaning up test data");
-
- cy.loginByApi(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
-
- // Clean up domain mapping
- if (testSite.id && testSite.domain) {
- cy.deleteDomainMapping(testSite.id, testSite.domain).then(() => {
- cy.log(`Deleted domain mapping: ${testSite.domain}`);
- });
- }
-
- // Clean up test users
- if (testUser.id) {
- cy.wpCli(`user delete ${testUser.id} --yes`, { failOnNonZeroExit: false });
- cy.log(`Deleted test user: ${testUser.username}`);
- }
-
- // Clean up test site
- if (testSite.id) {
- cy.wpCli(`site delete ${testSite.id} --yes`, { failOnNonZeroExit: false });
- cy.log(`Deleted test site: ${testSite.path}`);
- }
- });
-});
diff --git a/tests/e2e/cypress/integration/plugin.spec.js b/tests/e2e/cypress/integration/plugin.spec.js
deleted file mode 100644
index cf8c76d8..00000000
--- a/tests/e2e/cypress/integration/plugin.spec.js
+++ /dev/null
@@ -1,23 +0,0 @@
-describe("Plugin", () => {
- beforeEach(() => {
- cy.loginByForm(
- Cypress.env("admin").username,
- Cypress.env("admin").password
- );
- });
-
- it("Should be able to deactivate the plugin", () => {
- cy.visit("/wp-admin/network/plugins.php");
- cy.location("pathname").should("equal", "/wp-admin/network/plugins.php");
- cy.get("#deactivate-ultimate-multisite").scrollIntoView().should("be.visible").click();
- cy.get("#activate-ultimate-multisite").scrollIntoView().should("be.visible");
- });
-
- it("Should be able to activate the plugin", () => {
- cy.visit("/wp-admin/network/plugins.php");
- cy.location("pathname").should("equal", "/wp-admin/network/plugins.php");
- cy.get("#activate-ultimate-multisite").scrollIntoView().should("be.visible").click();
- cy.location("pathname").should("eq", "/wp-admin/network/admin.php");
- cy.location("search").should("include", "page=wp-ultimo-setup");
- });
-});
diff --git a/tests/e2e/cypress/integration/setup-wizard-complete.spec.js b/tests/e2e/cypress/integration/setup-wizard-complete.spec.js
deleted file mode 100644
index 56cd6d33..00000000
--- a/tests/e2e/cypress/integration/setup-wizard-complete.spec.js
+++ /dev/null
@@ -1,508 +0,0 @@
-/**
- * E2E tests for completing the WP Multisite Ultimate setup wizard
- *
- * This test suite ensures the setup wizard is completed properly,
- * which creates the necessary checkout forms and pages required
- * for the checkout flow tests.
- *
- * ⚠️ IMPORTANT: This test should run before any checkout tests
- * as it sets up the plugin for use.
- */
-
-describe("Setup Wizard Completion", () => {
- const setupData = {
- company: {
- name: 'Test Company',
- email: 'admin@testcompany.com',
- website: 'https://testcompany.com'
- },
- settings: {
- currency: 'USD',
- defaultPlan: 'Basic Plan',
- enableRegistration: true
- }
- };
-
- before(() => {
- // Enable detailed logging
- cy.log('🔧 Starting Setup Wizard E2E Test');
-
- // Check environment
- cy.log('Environment check:', Cypress.env());
-
- // Login as admin before starting setup
- const adminUsername = Cypress.env("admin") && Cypress.env("admin").username || 'admin';
- const adminPassword = Cypress.env("admin") && Cypress.env("admin").password || 'password';
-
- cy.log(`Attempting login with username: ${adminUsername}`);
-
- cy.loginByApi(adminUsername, adminPassword).then(() => {
- cy.log('✅ Login successful');
- }).catch((error) => {
- cy.log('❌ Login failed:', error);
- throw error;
- });
- });
-
- describe("Complete Setup Wizard Flow", () => {
- it("Should navigate to setup wizard if not completed", () => {
- cy.log('🔍 Checking for setup wizard requirement');
-
- // First, let's verify WordPress is working
- cy.visit('/wp-admin/network/', {
- failOnStatusCode: false,
- timeout: 30000
- });
-
- cy.url().then(url => {
- cy.log('Current URL:', url);
- });
-
- // Take a screenshot to see what we're dealing with
- cy.screenshot('network-admin-initial');
-
- cy.get('body').then($body => {
- const bodyText = $body.text();
- cy.log('Page content preview:', bodyText.substring(0, 200));
-
- // Check for WordPress admin indicators
- if ($body.find('#wpadminbar, #adminmenu, .wp-admin').length === 0) {
- cy.log('❌ Not on WordPress admin page');
- throw new Error('Expected WordPress admin interface not found');
- }
-
- // Look for setup wizard indicators
- const setupIndicators = [
- '[href*="wp-ultimo-setup"]',
- '.wu-setup-wizard',
- ':contains("Setup Wizard")',
- ':contains("Setup WP Ultimo")',
- '[data-testid="setup-wizard"]'
- ];
-
- let foundSetup = false;
- setupIndicators.forEach(selector => {
- if ($body.find(selector).length > 0) {
- cy.log(`✅ Setup wizard indicator found: ${selector}`);
- foundSetup = true;
- }
- });
-
- if (foundSetup) {
- cy.log('📋 Setup wizard found - navigating to setup');
-
- // Try multiple selectors to navigate to setup
- cy.get('[href*="wp-ultimo-setup"], .wu-setup-wizard, a:contains("Setup")')
- .first()
- .should('be.visible')
- .click();
-
- } else {
- cy.log('🔗 No setup wizard link found, trying direct URL');
- // Try direct URL
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-setup', {
- failOnStatusCode: false
- });
- }
- });
-
- // Verify we're on setup wizard page
- cy.url({ timeout: 10000 }).should('contain', 'wp-ultimo-setup');
- cy.screenshot('setup-wizard-page');
- cy.log('✅ Successfully navigated to setup wizard');
- });
-
- it("Should complete the Welcome step", () => {
- cy.log("🎯 Starting Welcome Step");
-
- // Verify we're on welcome step
- cy.url({ timeout: 10000 }).should('contain', 'wp-ultimo-setup');
- cy.screenshot('welcome-step-start');
-
- cy.get('body').then($body => {
- const pageText = $body.text();
- cy.log('Welcome page content preview:', pageText.substring(0, 300));
-
- // Look for welcome indicators
- const hasWelcome = /welcome|setup|getting.*started/i.test(pageText);
- if (!hasWelcome) {
- cy.log('⚠️ No welcome indicators found, but continuing...');
- }
- });
-
- // Look for and click get started button with multiple fallbacks
- const startButtonSelectors = [
- 'button:contains("Get Started")',
- 'a:contains("Get Started")',
- '[data-testid="get-started"]',
- '.wu-button:contains("Start")',
- 'input[value*="Get Started"]',
- 'button:contains("Begin")',
- 'a:contains("Continue")'
- ];
-
- let buttonFound = false;
- startButtonSelectors.forEach(selector => {
- cy.get('body').then($body => {
- if (!buttonFound && $body.find(selector).length > 0) {
- cy.log(`✅ Found start button with selector: ${selector}`);
- cy.get(selector).first().should('be.visible').click();
- buttonFound = true;
- }
- });
- });
-
- if (!buttonFound) {
- cy.log('❌ No start button found, trying clickPrimaryBtnByTxt fallback');
- cy.clickPrimaryBtnByTxt("Get Started");
- }
-
- // Wait and verify navigation
- cy.wait(2000);
- cy.url({ timeout: 15000 }).should('contain', 'step=checks');
- cy.screenshot('welcome-step-completed');
- cy.log('✅ Welcome step completed successfully');
- });
-
- it("Should complete the System Checks step", () => {
- cy.log("Completing System Checks Step");
-
- // Wait for checks to complete
- cy.get('.wu-setup-check, .setup-check, [class*="check"]', { timeout: 10000 })
- .should('be.visible');
-
- // Look for any failed checks
- cy.get('body').then($body => {
- const hasFailures = $body.find('.wu-check-fail, .check-fail, [class*="fail"]').length > 0;
-
- if (hasFailures) {
- cy.log('Warning: Some system checks failed, but proceeding');
- } else {
- cy.log('All system checks passed');
- }
- });
-
- // Proceed to next step
- cy.clickPrimaryBtnByTxt("Go to the Next Step");
-
- // Should move to installation step
- cy.assertPageUrl({
- pathname: "/wp-admin/network/admin.php",
- page: "wp-ultimo-setup",
- step: "installation"
- });
- });
-
- it("Should complete the Installation step", () => {
- cy.log("Completing Installation Step");
-
- // Verify installation content
- cy.get('body').should('contain.text', /install|database|table/i);
-
- // Click install button
- cy.clickPrimaryBtnByTxt("Install");
-
- // Wait for installation to complete
- cy.get('.wu-progress, .progress, [class*="progress"]', { timeout: 30000 })
- .should('be.visible');
-
- // Wait for installation success
- cy.get('.wu-success, .success, [class*="success"]', { timeout: 30000 })
- .should('be.visible');
-
- // Should move to company details step
- cy.assertPageUrl({
- pathname: "/wp-admin/network/admin.php",
- page: "wp-ultimo-setup",
- step: "your-company"
- });
- });
-
- it("Should complete the Company Details step", () => {
- cy.log("Completing Company Details Step");
-
- // Fill company information
- cy.get('body').then($body => {
- // Company name
- if ($body.find('#company_name, [name="company_name"], [data-testid="company-name"]').length > 0) {
- cy.get('#company_name, [name="company_name"], [data-testid="company-name"]')
- .clear()
- .type(setupData.company.name);
- }
-
- // Company email
- if ($body.find('#company_email, [name="company_email"], [data-testid="company-email"]').length > 0) {
- cy.get('#company_email, [name="company_email"], [data-testid="company-email"]')
- .clear()
- .type(setupData.company.email);
- }
-
- // Company website
- if ($body.find('#company_website, [name="company_website"], [data-testid="company-website"]').length > 0) {
- cy.get('#company_website, [name="company_website"], [data-testid="company-website"]')
- .clear()
- .type(setupData.company.website);
- }
-
- // Currency selection
- if ($body.find('#currency, [name="currency"], [data-testid="currency"]').length > 0) {
- cy.get('#currency, [name="currency"], [data-testid="currency"]')
- .select(setupData.settings.currency);
- }
- });
-
- // Continue to next step
- cy.clickPrimaryBtnByTxt("Continue");
-
- // Should move to defaults step
- cy.assertPageUrl({
- pathname: "/wp-admin/network/admin.php",
- page: "wp-ultimo-setup",
- step: "defaults"
- });
- });
-
- it("Should complete the Defaults step and create sample data", () => {
- cy.log("Completing Defaults Step");
-
- // This step typically creates sample plans, checkout forms, etc.
- cy.get('body').should('contain.text', /default|sample|plan|product/i);
-
- // Look for sample data creation options
- cy.get('body').then($body => {
- // Enable sample data creation if option exists
- if ($body.find('[name="create_sample_data"], [data-testid="create-sample"], input[type="checkbox"]').length > 0) {
- cy.get('[name="create_sample_data"], [data-testid="create-sample"], input[type="checkbox"]')
- .check();
- }
-
- // Enable checkout forms creation if option exists
- if ($body.find('[name="create_checkout_forms"], [data-testid="create-checkout"], input[type="checkbox"]').length > 0) {
- cy.get('[name="create_checkout_forms"], [data-testid="create-checkout"], input[type="checkbox"]')
- .check();
- }
-
- // Enable sample plans if option exists
- if ($body.find('[name="create_sample_plans"], [data-testid="create-plans"], input[type="checkbox"]').length > 0) {
- cy.get('[name="create_sample_plans"], [data-testid="create-plans"], input[type="checkbox"]')
- .check();
- }
- });
-
- // Install defaults
- cy.clickPrimaryBtnByTxt("Install");
-
- // Wait for installation to complete
- cy.get('.wu-progress, .progress, [class*="progress"]', { timeout: 30000 })
- .should('be.visible');
-
- // Wait for completion
- cy.get('.wu-success, .success, [class*="success"]', { timeout: 30000 })
- .should('be.visible');
-
- // Should move to completion step
- cy.url({ timeout: 10000 }).should('satisfy', url =>
- url.includes('step=done') ||
- url.includes('step=complete') ||
- url.includes('step=finish')
- );
- });
-
- it("Should complete the final step and redirect to dashboard", () => {
- cy.log("Completing Final Step");
-
- // Should show completion message
- cy.get('body').should('contain.text', /complete|done|ready|congratulations|success/i);
-
- // Click finish button
- cy.clickPrimaryBtnByTxt("Thanks!");
-
- // Should redirect to main dashboard
- cy.assertPageUrl({
- pathname: "/wp-admin/network/index.php"
- });
-
- // Verify we're on the network dashboard
- cy.get('body').should('contain.text', /dashboard|network|admin/i);
- });
- });
-
- describe("Verify Setup Completion", () => {
- it("Should have created necessary database tables", () => {
- cy.log("Verifying database tables were created");
-
- // Use WP-CLI to check for WP Multisite Ultimate tables
- cy.wpCli("db query 'SHOW TABLES LIKE \"%wu_%\"'").then(result => {
- // Should have multiple WP Multisite Ultimate tables
- expect(result.stdout).to.contain('wu_');
- });
- });
-
- it("Should have created sample plans", () => {
- cy.log("Verifying sample plans were created");
-
- // Navigate to plans page
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-products');
-
- // Should show plans list
- cy.get('body').should('contain.text', /plan|product/i);
-
- // Should have at least one plan
- cy.get('.wp-list-table tbody tr, .wu-list-table tbody tr').should('have.length.at.least', 1);
- });
-
- it("Should have created default checkout forms", () => {
- cy.log("Verifying checkout forms were created");
-
- // Navigate to checkout forms page
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-checkout-forms');
-
- // Should show checkout forms list
- cy.get('body').should('contain.text', /checkout.*form|registration.*form/i);
-
- // Should have at least one checkout form
- cy.get('.wp-list-table tbody tr, .wu-list-table tbody tr').should('have.length.at.least', 1);
- });
-
- it("Should have created necessary pages", () => {
- cy.log("Verifying necessary pages were created");
-
- // Check for registration page
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-sites');
-
- // Navigate to main site pages
- cy.visit('/wp-admin/edit.php?post_type=page');
-
- // Look for checkout/registration related pages
- cy.get('body').then($body => {
- const hasCheckoutPages = $body.find('a:contains("Checkout"), a:contains("Registration"), a:contains("Sign Up")').length > 0;
-
- if (hasCheckoutPages) {
- cy.log('Checkout pages found');
- } else {
- cy.log('Note: Checkout pages may be created automatically on first access');
- }
- });
- });
-
- it("Should have configured payment gateways", () => {
- cy.log("Verifying payment gateways configuration");
-
- // Navigate to payment settings
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-settings&tab=payments');
-
- // Should show payment gateway settings
- cy.get('body').should('contain.text', /payment|gateway|stripe|paypal/i);
-
- // Manual gateway should be enabled by default
- cy.get('body').should('contain.text', /manual.*payment|manual.*gateway/i);
- });
-
- it("Should allow access to main plugin features", () => {
- cy.log("Verifying main plugin features are accessible");
-
- // Test main menu items
- const menuItems = [
- { url: '/wp-admin/network/admin.php?page=wp-ultimo-dashboard', text: 'dashboard' },
- { url: '/wp-admin/network/admin.php?page=wp-ultimo-products', text: 'product' },
- { url: '/wp-admin/network/admin.php?page=wp-ultimo-customers', text: 'customer' },
- { url: '/wp-admin/network/admin.php?page=wp-ultimo-sites', text: 'site' }
- ];
-
- menuItems.forEach(item => {
- cy.visit(item.url);
- cy.get('body').should('contain.text', new RegExp(item.text, 'i'));
- });
- });
- });
-
- describe("Create Test Checkout Form for E2E Tests", () => {
- it("Should create a test checkout form for e2e testing", () => {
- cy.log("Creating test checkout form for e2e tests");
-
- // Navigate to checkout forms
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-checkout-forms');
-
- // Check if a registration form already exists
- cy.get('body').then($body => {
- const hasRegistrationForm = $body.find('td:contains("registration"), td:contains("Registration")').length > 0;
-
- if (!hasRegistrationForm) {
- // Create new checkout form
- cy.get('a:contains("Add New"), .page-title-action').click();
-
- // Fill form details
- cy.get('#title, [name="name"], [data-testid="form-name"]')
- .type('Registration Form');
-
- cy.get('#slug, [name="slug"], [data-testid="form-slug"]')
- .clear()
- .type('registration');
-
- // Save form
- cy.get('#publish, [type="submit"], .wu-button-primary').click();
-
- // Should show success message
- cy.get('.notice-success, .wu-success').should('be.visible');
-
- cy.log('Test checkout form created');
- } else {
- cy.log('Registration form already exists');
- }
- });
- });
-
- it("Should verify checkout form is accessible on frontend", () => {
- cy.log("Verifying checkout form frontend access");
-
- // Try to access checkout form
- cy.visit('/checkout/registration', { failOnStatusCode: false });
-
- cy.get('body').then($body => {
- // Check if checkout form loads or if we need to create pages
- const hasCheckoutContent = $body.find('.wu-checkout, .checkout-form, form').length > 0;
-
- if (hasCheckoutContent) {
- cy.log('Checkout form is accessible');
- } else {
- cy.log('Checkout form may need additional setup');
- // This is normal - checkout forms may need additional configuration
- }
- });
- });
- });
-
- describe("Setup Wizard Skip/Reset", () => {
- it("Should mark setup as completed to prevent re-running", () => {
- cy.log("Marking setup wizard as completed");
-
- // Navigate to settings to verify setup completion
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-settings');
-
- // Should not redirect to setup wizard
- cy.url().should('contain', 'wp-ultimo-settings');
- cy.url().should('not.contain', 'wp-ultimo-setup');
-
- cy.log('Setup wizard is marked as completed');
- });
-
- it("Should provide reset option for testing (if available)", () => {
- cy.log("Checking for setup reset option");
-
- cy.visit('/wp-admin/network/admin.php?page=wp-ultimo-settings&tab=advanced');
-
- cy.get('body').then($body => {
- if ($body.find(':contains("Reset Setup"), :contains("Re-run Setup")').length > 0) {
- cy.log('Setup reset option is available for future testing');
- } else {
- cy.log('No setup reset option found (this is normal)');
- }
- });
- });
- });
-
- after(() => {
- cy.log("Setup wizard completion tests finished");
- cy.log("Checkout flow tests can now be run");
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/integration/wizard.spec.js b/tests/e2e/cypress/integration/wizard.spec.js
index 947c4458..de46be89 100644
--- a/tests/e2e/cypress/integration/wizard.spec.js
+++ b/tests/e2e/cypress/integration/wizard.spec.js
@@ -3,6 +3,12 @@ const page_name = "wp-ultimo-setup";
describe("Wizard", () => {
before(() => {
cy.loginByApi(Cypress.env("admin").username, Cypress.env("admin").password);
+
+ // Clear setup-finished flag so the wizard is accessible
+ cy.wpCli(
+ 'eval "delete_network_option(null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED);"'
+ );
+
cy.visit(`/wp-admin/network/admin.php?page=${page_name}`);
});
@@ -28,13 +34,17 @@ describe("Wizard", () => {
/**
* Steps: Installation
+ * Button text varies ("Install" vs "Go to the Next Step") depending on
+ * whether DB tables were already created by a prior spec.
*/
cy.assertPageUrl({
pathname: "/wp-admin/network/admin.php",
page: page_name,
step: "installation",
});
- cy.clickPrimaryBtnByTxt("Install");
+ cy.get('button[data-testid="button-primary"]')
+ .should("not.be.disabled")
+ .click();
/**
* Steps: Your Company
@@ -47,18 +57,32 @@ describe("Wizard", () => {
cy.clickPrimaryBtnByTxt("Continue");
/**
- * Steps: Defaults
+ * Steps: Default Content
+ * Creates template site, example products, checkout form, emails, login page.
+ * Items already created by prior specs are skipped automatically.
*/
cy.assertPageUrl({
pathname: "/wp-admin/network/admin.php",
page: page_name,
step: "defaults",
});
- cy.clickPrimaryBtnByTxt("Install");
+ cy.get('button[data-testid="button-primary"]')
+ .should("not.be.disabled")
+ .click();
+
+ /**
+ * Steps: Recommended Plugins
+ * May download plugins from wordpress.org via AJAX; allow extra time.
+ */
+ cy.url({ timeout: 120000 }).should("include", "step=recommended-plugins");
+ cy.get('button[data-testid="button-primary"]')
+ .should("not.be.disabled")
+ .click();
/**
* Steps: Done
*/
+ cy.url({ timeout: 120000 }).should("include", "step=done");
cy.clickPrimaryBtnByTxt("Thanks!");
cy.assertPageUrl({
pathname: "/wp-admin/network/index.php",
diff --git a/tests/e2e/cypress/support/commands/checkout.js b/tests/e2e/cypress/support/commands/checkout.js
deleted file mode 100644
index 9ed7f2e2..00000000
--- a/tests/e2e/cypress/support/commands/checkout.js
+++ /dev/null
@@ -1,409 +0,0 @@
-/**
- * Cypress custom commands for checkout flow testing
- */
-
-/**
- * Navigate to a specific checkout form
- * @param {string} formSlug - The checkout form slug
- * @param {object} options - Additional options
- */
-Cypress.Commands.add("visitCheckoutForm", (formSlug = 'registration', options = {}) => {
- const url = `/checkout/${formSlug}`;
- cy.visit(url, options);
-});
-
-/**
- * Select a pricing plan/product from the pricing table
- * @param {number} planIndex - Index of the plan to select (0-based)
- * @param {string} planSelector - Custom selector for pricing plans
- */
-Cypress.Commands.add("selectPricingPlan", (planIndex = 0, planSelector = null) => {
- const selector = planSelector || '[data-testid="pricing-table"], .wu-pricing-table, [id*="pricing"], [class*="plan"]';
-
- cy.get(selector)
- .should('be.visible')
- .eq(planIndex)
- .within(() => {
- cy.get('button, .wu-button, [type="submit"], a[href*="checkout"]')
- .first()
- .click();
- });
-});
-
-/**
- * Fill checkout account details
- * @param {object} customerData - Customer information
- */
-Cypress.Commands.add("fillAccountDetails", (customerData) => {
- const {
- username,
- email,
- password,
- firstName = '',
- lastName = ''
- } = customerData;
-
- // Username field
- cy.get('#username, [name="username"], [data-testid="username"]')
- .should('be.visible')
- .clear()
- .type(username);
-
- // Email field
- cy.get('#email, [name="email"], [data-testid="email"]')
- .should('be.visible')
- .clear()
- .type(email);
-
- // Password field
- cy.get('#password, [name="password"], [data-testid="password"]')
- .should('be.visible')
- .clear()
- .type(password);
-
- // Password confirmation (if exists)
- cy.get('body').then(($body) => {
- if ($body.find('#password_confirmation, [name="password_confirmation"], [data-testid="password-confirm"]').length > 0) {
- cy.get('#password_confirmation, [name="password_confirmation"], [data-testid="password-confirm"]')
- .clear()
- .type(password);
- }
- });
-
- // First name (if exists)
- if (firstName) {
- cy.get('body').then(($body) => {
- if ($body.find('#first_name, [name="first_name"], [data-testid="first-name"]').length > 0) {
- cy.get('#first_name, [name="first_name"], [data-testid="first-name"]')
- .clear()
- .type(firstName);
- }
- });
- }
-
- // Last name (if exists)
- if (lastName) {
- cy.get('body').then(($body) => {
- if ($body.find('#last_name, [name="last_name"], [data-testid="last-name"]').length > 0) {
- cy.get('#last_name, [name="last_name"], [data-testid="last-name"]')
- .clear()
- .type(lastName);
- }
- });
- }
-});
-
-/**
- * Fill site details
- * @param {object} siteData - Site information
- */
-Cypress.Commands.add("fillSiteDetails", (siteData) => {
- const { title, path } = siteData;
-
- // Site title
- cy.get('#site_title, [name="site_title"], [data-testid="site-title"]')
- .should('be.visible')
- .clear()
- .type(title);
-
- // Site URL/path
- cy.get('#site_url, [name="site_url"], [data-testid="site-url"], [name="blogname"]')
- .should('be.visible')
- .clear()
- .type(path);
-});
-
-/**
- * Select a site template (if template selection is available)
- * @param {number} templateIndex - Index of template to select (0-based)
- */
-Cypress.Commands.add("selectSiteTemplate", (templateIndex = 0) => {
- cy.get('body').then(($body) => {
- const templateSelectors = [
- '[data-testid="template-selection"]',
- '.wu-template-selection',
- '[class*="template"]',
- '.template-item'
- ];
-
- let templateFound = false;
-
- templateSelectors.forEach(selector => {
- if (!templateFound && $body.find(selector).length > 0) {
- cy.get(selector).eq(templateIndex).click();
- templateFound = true;
- }
- });
-
- if (!templateFound) {
- cy.log('No template selection found, skipping template selection');
- }
- });
-});
-
-/**
- * Fill billing address information
- * @param {object} billingData - Billing address data
- */
-Cypress.Commands.add("fillBillingAddress", (billingData = {}) => {
- const {
- address = '123 Test Street',
- city = 'Test City',
- state = 'CA',
- zipCode = '12345',
- country = 'US'
- } = billingData;
-
- cy.get('body').then(($body) => {
- if ($body.find('[name*="billing"], [data-testid*="billing"]').length > 0) {
-
- // Address line 1
- const addressSelectors = [
- '[name="billing_address[address_line_1]"]',
- '[name="billing_address_line_1"]',
- '[name="billing_address"]',
- '[data-testid="billing-address"]'
- ];
-
- addressSelectors.forEach(selector => {
- cy.get('body').then(($addressBody) => {
- if ($addressBody.find(selector).length > 0) {
- cy.get(selector).type(address);
- }
- });
- });
-
- // City
- const citySelectors = [
- '[name="billing_address[city]"]',
- '[name="billing_city"]',
- '[data-testid="billing-city"]'
- ];
-
- citySelectors.forEach(selector => {
- cy.get('body').then(($cityBody) => {
- if ($cityBody.find(selector).length > 0) {
- cy.get(selector).type(city);
- }
- });
- });
-
- // State
- const stateSelectors = [
- '[name="billing_address[state]"]',
- '[name="billing_state"]',
- '[data-testid="billing-state"]'
- ];
-
- stateSelectors.forEach(selector => {
- cy.get('body').then(($stateBody) => {
- if ($stateBody.find(selector).length > 0) {
- cy.get(selector).type(state);
- }
- });
- });
-
- // Zip Code
- const zipSelectors = [
- '[name="billing_address[zip_code]"]',
- '[name="billing_zip"]',
- '[data-testid="billing-zip"]'
- ];
-
- zipSelectors.forEach(selector => {
- cy.get('body').then(($zipBody) => {
- if ($zipBody.find(selector).length > 0) {
- cy.get(selector).type(zipCode);
- }
- });
- });
- }
- });
-});
-
-/**
- * Select a payment gateway
- * @param {string} gateway - Gateway type ('manual', 'stripe', 'paypal', 'free')
- */
-Cypress.Commands.add("selectPaymentGateway", (gateway = 'manual') => {
- const gatewaySelectors = [
- `[data-testid="gateway-${gateway}"]`,
- `[value="${gateway}"]`,
- `[data-gateway="${gateway}"]`,
- `#gateway_${gateway}`,
- `.gateway-${gateway}`
- ];
-
- let gatewayFound = false;
-
- gatewaySelectors.forEach(selector => {
- cy.get('body').then(($body) => {
- if (!gatewayFound && $body.find(selector).length > 0) {
- cy.get(selector).click();
- gatewayFound = true;
- }
- });
- });
-
- if (!gatewayFound) {
- cy.log(`Payment gateway ${gateway} not found, proceeding anyway`);
- }
-});
-
-/**
- * Proceed to next checkout step
- * @param {string} buttonText - Text to look for in the button
- */
-Cypress.Commands.add("proceedToNextStep", (buttonText = '') => {
- const buttonSelectors = [
- '[data-testid="continue-btn"]',
- '[data-testid="next-btn"]',
- '.wu-button',
- 'button[type="submit"]',
- 'input[type="submit"]'
- ];
-
- const textPatterns = buttonText ? [buttonText] : [
- 'continue',
- 'next',
- 'proceed',
- 'complete',
- 'finish',
- 'create',
- 'register'
- ];
-
- buttonSelectors.forEach(selector => {
- cy.get('body').then(($body) => {
- if ($body.find(selector).length > 0) {
- textPatterns.forEach(pattern => {
- cy.get(selector).then($buttons => {
- const matchingButton = Array.from($buttons).find(btn =>
- btn.textContent.toLowerCase().includes(pattern.toLowerCase())
- );
- if (matchingButton && !matchingButton.disabled) {
- cy.wrap(matchingButton).click();
- return false; // Break out of loops
- }
- });
- });
- }
- });
- });
-});
-
-/**
- * Complete the checkout process
- */
-Cypress.Commands.add("completeCheckout", () => {
- const completionSelectors = [
- '[data-testid="complete-btn"]',
- '[data-testid="finish-btn"]',
- '.wu-button',
- 'button[type="submit"]'
- ];
-
- const completionTexts = [
- 'complete',
- 'finish',
- 'create account',
- 'register',
- 'pay now',
- 'submit'
- ];
-
- completionSelectors.forEach(selector => {
- cy.get('body').then(($body) => {
- if ($body.find(selector).length > 0) {
- completionTexts.forEach(text => {
- cy.get(selector).then($buttons => {
- const matchingButton = Array.from($buttons).find(btn =>
- btn.textContent.toLowerCase().includes(text)
- );
- if (matchingButton && !matchingButton.disabled) {
- cy.wrap(matchingButton).click({ timeout: 10000 });
- return false;
- }
- });
- });
- }
- });
- });
-});
-
-/**
- * Verify checkout completion/success
- * @param {object} verificationData - Data to verify in success page
- */
-Cypress.Commands.add("verifyCheckoutSuccess", (verificationData = {}) => {
- const { email, siteTitle, shouldRedirect = true } = verificationData;
-
- // Wait for redirect if expected
- if (shouldRedirect) {
- cy.url({ timeout: 30000 }).should('satisfy', url =>
- url.includes('/confirmation') ||
- url.includes('/thank') ||
- url.includes('/success') ||
- url.includes('/complete')
- );
- }
-
- // Verify success message
- const successSelectors = [
- '[data-testid="success-message"]',
- '.wu-success',
- '.notice-success',
- '[class*="success"]',
- '.checkout-success'
- ];
-
- successSelectors.forEach(selector => {
- cy.get('body').then(($body) => {
- if ($body.find(selector).length > 0) {
- cy.get(selector)
- .should('be.visible')
- .and('contain.text', /success|complete|welcome|thank|registered/i);
- }
- });
- });
-
- // Verify email if provided
- if (email) {
- cy.get('[data-testid="customer-info"], .wu-customer-info, .customer-details')
- .should('contain.text', email);
- }
-
- // Verify site title if provided
- if (siteTitle) {
- cy.get('[data-testid="site-info"], .wu-site-info, .site-details')
- .should('contain.text', siteTitle);
- }
-});
-
-/**
- * Assert current checkout step
- * @param {number|string} expectedStep - Expected step number or name
- */
-Cypress.Commands.add("assertCheckoutStep", (expectedStep) => {
- cy.get('[data-testid="checkout-step"], .wu-step, [class*="step"], .checkout-progress')
- .should('be.visible')
- .and('contain.text', expectedStep);
-});
-
-/**
- * Check if checkout form has validation errors
- */
-Cypress.Commands.add("hasValidationErrors", () => {
- return cy.get('body').then($body => {
- const errorSelectors = [
- '[data-testid="error"]',
- '.wu-error',
- '.error',
- '[class*="error"]',
- '.form-error',
- '.validation-error'
- ];
-
- return errorSelectors.some(selector => $body.find(selector + ':visible').length > 0);
- });
-});
\ No newline at end of file
diff --git a/tests/e2e/cypress/support/commands/index.js b/tests/e2e/cypress/support/commands/index.js
index 83a57f8f..8cf5f550 100644
--- a/tests/e2e/cypress/support/commands/index.js
+++ b/tests/e2e/cypress/support/commands/index.js
@@ -1,10 +1,40 @@
import "./login";
import "./wizard";
-import "./checkout";
import "./domain-mapping";
Cypress.Commands.add("wpCli", (command, options = {}) => {
- cy.exec(`npm run env run tests-cli wp ${command}`, options);
+ cy.exec(`npx wp-env run tests-cli wp ${command}`, {
+ ...options,
+ timeout: options.timeout || 60000,
+ });
+});
+
+/**
+ * Run a PHP file inside the wp-env container via WP-CLI eval-file.
+ * Path is relative to the plugin root inside the container.
+ */
+Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
+ const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`;
+
+ cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, {
+ ...options,
+ timeout: options.timeout || 60000,
+ });
+});
+
+Cypress.Commands.add("loginByApi", (username, password) => {
+ cy.request({
+ method: "POST",
+ url: "/wp-login.php",
+ form: true,
+ body: {
+ log: username,
+ pwd: password,
+ "wp-submit": "Log In",
+ redirect_to: "/wp-admin/",
+ testcookie: 1,
+ },
+ });
});
Cypress.Commands.overwrite("type", (originalFn, subject, string, options) =>
diff --git a/views/checkout/fields/field-select.php b/views/checkout/fields/field-select.php
index 2f0d0ca1..2008bb17 100644
--- a/views/checkout/fields/field-select.php
+++ b/views/checkout/fields/field-select.php
@@ -26,10 +26,16 @@
?>
+ html_attr['v-bind:name']);
+ ?>