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 } : '
nbsp;
'; + + return h(component); + + }, + }; - const template = context.props.template; + // eslint-disable-next-line no-unused-vars + window.wu_checkout_form = new Vue({ + el: '#wu_form', + data: initial_data, + directives: { + init: { + bind(el, binding, vnode) { - const component = template ? { template } : '
nbsp;
'; + vnode.context[ binding.arg ] = binding.value; - return h(component); + }, + }, + }, + components: { + dynamic, + }, + computed: { + hooks() { - }, - }; + return wp.hooks; - // eslint-disable-next-line no-unused-vars - window.wu_checkout_form = new Vue({ - el: '#wu_form', - data: initial_data, - directives: { - init: { - bind(el, binding, vnode) { + }, + unique_products() { - vnode.context[binding.arg] = binding.value; + return _.uniq(this.products, false, (item) => parseInt(item, 10)); - }, - }, - }, - components: { - dynamic, - }, - computed: { - hooks() { + }, + }, + methods: { + debounce(fn) { - return wp.hooks; + return _.debounce(fn, 200, true); - }, - unique_products() { + }, + open_url(url, target = '_blank') { - return _.uniq(this.products, false, (item) => parseInt(item, 10)); + window.open(url, target); - }, - }, - methods: { - debounce(fn) { + }, + get_template(template, data) { - return _.debounce(fn, 200, true); + if (typeof data.id === 'undefined') { - }, - open_url(url, target = '_blank') { + data.id = 'default'; - window.open(url, target); + } // end if; - }, - get_template(template, data) { + const template_name = template + '/' + data.id; - if (typeof data.id === 'undefined') { + if (typeof this.stored_templates[ template_name ] !== 'undefined') { - data.id = 'default'; + return this.stored_templates[ template_name ]; - } // end if; + } // end if; - const template_name = template + '/' + data.id; + const template_data = this.hooks.applyFilters('wu_before_template_fetch', { + duration: this.duration, + duration_unit: this.duration_unit, + products: this.products, + ...data, + }, this); - if (typeof this.stored_templates[template_name] !== 'undefined') { + this.fetch_template(template, template_data); - return this.stored_templates[template_name]; + return '
' + wu_checkout.i18n.loading + '
'; - } // end if; + }, + reset_templates(to_clear) { - const template_data = this.hooks.applyFilters('wu_before_template_fetch', { - duration: this.duration, - duration_unit: this.duration_unit, - products: this.products, - ...data, - }, this); + if (typeof to_clear === 'undefined') { - this.fetch_template(template, template_data); + this.stored_templates = {}; - return '
' + wu_checkout.i18n.loading + '
'; + return; - }, - reset_templates(to_clear) { + } - if (typeof to_clear === 'undefined') { + const new_list = {}; - this.stored_templates = {}; + _.forEach(this.stored_templates, function (item, key) { - return; + const type = key.toString().substr(0, key.toString().indexOf('/')); - } + if (_.contains(to_clear, type) === false) { - const new_list = {}; + new_list[ key ] = item; - _.forEach(this.stored_templates, function (item, key) { + } // end if; - const type = key.toString().substr(0, key.toString().indexOf('/')); + }); - if (_.contains(to_clear, type) === false) { + this.stored_templates = new_list; - new_list[key] = item; + }, + fetch_template(template, data) { - } // end if; + const that = this; - }); + if (typeof data.id === 'undefined') { - this.stored_templates = new_list; + data.id = 'default'; - }, - fetch_template(template, data) { + } // end if; - const that = this; + this.request('wu_render_field_template', { + template, + attributes: data, + }, function (results) { - if (typeof data.id === 'undefined') { + const template_name = template + '/' + data.id; - data.id = 'default'; + if (results.success) { - } // end if; + Vue.set(that.stored_templates, template_name, results.data.html); - this.request('wu_render_field_template', { - template, - attributes: data, - }, function (results) { + } else { - const template_name = template + '/' + data.id; + Vue.set(that.stored_templates, template_name, '
' + results.data[ 0 ].message + '
'); - if (results.success) { + } // end if; - Vue.set(that.stored_templates, template_name, results.data.html); + }); - } else { + }, + go_back() { - Vue.set(that.stored_templates, template_name, '
' + results.data[0].message + '
'); + this.block(); - } // end if; + window.history.back(); - }); + }, + set_prevent_submission(value) { - }, - go_back() { + this.$nextTick(function () { - this.block(); + this.prevent_submission = value; - window.history.back(); + }); - }, - set_prevent_submission(value) { + }, + remove_product(product_id, product_slug) { - this.$nextTick(function () { + this.products = _.filter(this.products, function (item) { - this.prevent_submission = value; + // eslint-disable-next-line eqeqeq + return item != product_id && item != product_slug; - }); + }); - }, - remove_product(product_id, product_slug) { + }, + add_plan(product_id) { - this.products = _.filter(this.products, function (item) { + if (this.plan) { - // eslint-disable-next-line eqeqeq - return item != product_id && item != product_slug; + this.remove_product(this.plan); - }); + } // end if; - }, - add_plan(product_id) { + this.plan = product_id; - if (this.plan) { + this.add_product(product_id); - this.remove_product(this.plan); + }, + add_product(product_id) { - } // end if; + this.products.push(product_id); - this.plan = product_id; + }, + has_product(product_id) { - this.add_product(product_id); + return this.products.indexOf(product_id) > -1 || this.products.indexOf(parseInt(product_id, 10)) > -1; - }, - add_product(product_id) { + }, + set_custom_amount(product_id, amount) { - this.products.push(product_id); + Vue.set(this.custom_amounts, product_id, parseFloat(amount) || 0); - }, - has_product(product_id) { + this.create_order(); - return this.products.indexOf(product_id) > -1 || this.products.indexOf(parseInt(product_id, 10)) > -1; + }, + get_custom_amount(product_id) { - }, - wu_format_money(value) { + return this.custom_amounts[ product_id ] || null; - return window.wu_format_money(value); + }, + set_pwyw_recurring(product_id, is_recurring) { - }, - filter_for_request(data, request_type = '') { + Vue.set(this.pwyw_recurring, product_id, Boolean(is_recurring)); - const filter_list = this.hooks.doAction('wu_filter_for_request', [ - 'stored_templates', - ], data, request_type); + this.create_order(); - const filtered_list = _.omit(data, filter_list); + }, + get_pwyw_recurring(product_id) { - return filtered_list; + return this.pwyw_recurring[ product_id ] || false; - }, - create_order() { + }, + wu_format_money(value) { - /* + return window.wu_format_money(value); + + }, + filter_for_request(data, request_type = '') { + + const filter_list = this.hooks.doAction('wu_filter_for_request', [ + 'stored_templates', + ], data, request_type); + + const filtered_list = _.omit(data, filter_list); + + return filtered_list; + + }, + create_order() { + + /* * Bail if there is no order summary to update. */ - if (!jQuery('#wu-order-summary-content').length) { + if (! jQuery('#wu-order-summary-content').length) { - return; + return; - } // end if; + } // end if; - this.block(); + this.block(); - this.order = false; + this.order = false; - const that = this; + const that = this; - const _request = this.debounce(this.request); + const _request = this.debounce(this.request); - const data = { ...this.$data }; + const data = { ...this.$data }; - delete data.stored_templates; - delete data.state_list; - delete data.city_list; - delete data.labels; + delete data.stored_templates; + delete data.state_list; + delete data.city_list; + delete data.labels; - _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { + _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { - that.order = results.data.order; + that.order = results.data.order; - that.state_list = results.data.states; + that.state_list = results.data.states; - that.city_list = results.data.cities; + that.city_list = results.data.cities; - that.labels = results.data.labels; + that.labels = results.data.labels; - that.cart_type = results.data.order.type; + that.cart_type = results.data.order.type; - that.errors = results.data.order.errors; + that.errors = results.data.order.errors; - that.hooks.doAction('wu_on_create_order', that, results.data); + that.hooks.doAction('wu_on_create_order', that, results.data); - if (results.data.order.url) { + if (results.data.order.url) { - try { + try { - // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); + // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); - } catch (err) { + } catch (err) { - // eslint-disable-next-line no-console - console.warn('Browser does not support pushState.', err); + // eslint-disable-next-line no-console + console.warn('Browser does not support pushState.', err); - } // end try; + } // end try; - } // ed if; + } // ed if; - that.unblock(); + that.unblock(); - }, this.handle_errors); + }, this.handle_errors); - }, - get_errors() { + }, + get_errors() { - const result = this.errors.map(function (e) { + const result = this.errors.map(function (e) { - return e.message; + return e.message; - }); + }); - return result.length > 0 ? result : false; + return result.length > 0 ? result : false; - }, - get_error(field) { + }, + get_error(field) { - const result = this.errors.filter(function (e) { + const result = this.errors.filter(function (e) { - return e.code === field; + return e.code === field; - }); + }); - return result.length > 0 ? result[0] : false; + return result.length > 0 ? result[ 0 ] : false; - }, - form_success(results) { + }, + form_success(results) { - if (!_.isEmpty(results.data)) { + if (! _.isEmpty(results.data)) { - this.hooks.doAction('wu_on_form_success', this, results.data); + this.hooks.doAction('wu_on_form_success', this, results.data); - const fields = results.data.gateway.data; + const fields = results.data.gateway.data; - fields.payment_id = results.data.payment_id; + fields.payment_id = results.data.payment_id; - fields.membership_id = results.data.membership_id; + fields.membership_id = results.data.membership_id; - fields.cart_type = results.data.cart_type; + fields.cart_type = results.data.cart_type; - // Append the hidden fields - jQuery.each(Object.assign({}, fields), function (index, value) { + // Append the hidden fields + jQuery.each(Object.assign({}, fields), function (index, value) { - const hidden = document.createElement('input'); + const hidden = document.createElement('input'); - hidden.type = 'hidden'; + hidden.type = 'hidden'; - hidden.name = index; + hidden.name = index; - hidden.value = value; + hidden.value = value; - jQuery('#wu_form').append(hidden); + jQuery('#wu_form').append(hidden); - }); + }); - } // end if; + } // end if; - }, - validate_form() { + }, + validate_form() { - this.errors = []; + this.errors = []; - const form_data_obj = jQuery('#wu_form').serializeArray().reduce(function (json, { name, value }) { + const form_data_obj = jQuery('#wu_form').serializeArray().reduce(function (json, { name, value }) { - // Get products from this - if (name !== 'products[]') { + // Get products from this + if (name !== 'products[]') { - json[name] = value; + json[ name ] = value; - } + } - return json; + return json; - }, {}); + }, {}); - const form_data = jQuery.param({ - ...form_data_obj, - products: this.products, - membership_id: this.membership_id, - payment_id: this.payment_id, - auto_renew: this.auto_renew, - cart_type: this.type, - valid_password: this.valid_password, - duration: this.duration, - duration_unit: this.duration_unit, - }); + const form_data = jQuery.param({ + ...form_data_obj, + products: this.products, + membership_id: this.membership_id, + payment_id: this.payment_id, + auto_renew: this.auto_renew, + cart_type: this.type, + valid_password: this.valid_password, + duration: this.duration, + duration_unit: this.duration_unit, + }); - const that = this; + const that = this; - this.request('wu_validate_form', form_data, function (results) { + this.request('wu_validate_form', form_data, function (results) { - if (!that.valid_password) { + if (! that.valid_password) { - that.errors.push({ - code: 'password', - message: wu_checkout.i18n.weak_password, - }); + that.errors.push({ + code: 'password', + message: wu_checkout.i18n.weak_password, + }); - } // end if; + } // end if; - if (results.success === false) { + if (results.success === false) { - that.errors = [].concat(that.errors, results.data); + that.errors = [].concat(that.errors, results.data); - that.unblock(); + that.unblock(); - return; + return; - } // end if; + } // end if; - if (!that.errors.length) { + if (! that.errors.length) { - that.form_success(results); + that.form_success(results); - if (that.prevent_submission === false) { + if (that.prevent_submission === false) { - that.resubmit(); + that.resubmit(); - } // end if; + } // end if; - } else { + } else { - that.unblock(); + that.unblock(); - } // end if; + } // end if; - }, this.handle_errors); + }, this.handle_errors); - }, - resubmit() { + }, + resubmit() { - jQuery('#wu_form').get(0).submit(); + jQuery('#wu_form').get(0).submit(); - }, - handle_errors(errors) { + }, + handle_errors(errors) { - this.unblock(); + this.unblock(); - // eslint-disable-next-line no-console - console.error(errors); + // eslint-disable-next-line no-console + console.error(errors); - }, - on_submit(event) { + }, + on_submit(event) { - event.preventDefault(); + event.preventDefault(); - }, - on_change_product(new_value, old_value) { + }, + on_change_product(new_value, old_value) { - window.wu_create_cookie('wu_selected_products', new_value.join(','), 0.5) // Save it for 12 hours max. + window.wu_create_cookie('wu_selected_products', new_value.join(','), 0.5) // Save it for 12 hours max. - this.reset_templates(['template-selection']); + this.reset_templates([ 'template-selection' ]); - hooks.doAction('wu_on_change_product', new_value, old_value, this); + hooks.doAction('wu_on_change_product', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_gateway(new_value, old_value) { + }, + on_change_gateway(new_value, old_value) { - hooks.doAction('wu_on_change_gateway', new_value, old_value, this); + hooks.doAction('wu_on_change_gateway', new_value, old_value, this); - }, - on_change_country(new_value, old_value) { + }, + on_change_country(new_value, old_value) { - hooks.doAction('wu_on_change_country', new_value, old_value, this); + hooks.doAction('wu_on_change_country', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_state(new_value, old_value) { + }, + on_change_state(new_value, old_value) { - hooks.doAction('wu_on_change_state', new_value, old_value, this); + hooks.doAction('wu_on_change_state', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_city(new_value, old_value) { + }, + on_change_city(new_value, old_value) { - hooks.doAction('wu_on_change_city', new_value, old_value, this); + hooks.doAction('wu_on_change_city', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_duration(new_value, old_value) { + }, + on_change_duration(new_value, old_value) { - this.reset_templates(); + this.reset_templates(); - hooks.doAction('wu_on_change_duration', new_value, old_value, this); + hooks.doAction('wu_on_change_duration', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_duration_unit(new_value, old_value) { + }, + on_change_duration_unit(new_value, old_value) { - this.reset_templates(); + this.reset_templates(); - hooks.doAction('wu_on_change_duration_unit', new_value, old_value, this); + hooks.doAction('wu_on_change_duration_unit', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - on_change_discount_code(new_value, old_value) { + }, + on_change_discount_code(new_value, old_value) { - hooks.doAction('wu_on_change_discount_code', new_value, old_value, this); + hooks.doAction('wu_on_change_discount_code', new_value, old_value, this); - this.create_order(); + this.create_order(); - }, - block() { + }, + block() { - /* + /* * Get the first bg color from a parent. */ - const bg_color = jQuery(this.$el).parents().filter(function () { + const bg_color = jQuery(this.$el).parents().filter(function () { - return $(this).css('backgroundColor') !== 'rgba(0, 0, 0, 0)'; + return $(this).css('backgroundColor') !== 'rgba(0, 0, 0, 0)'; - }).first().css('backgroundColor'); + }).first().css('backgroundColor'); - jQuery(this.$el).wu_block({ - message: '
', - overlayCSS: { - backgroundColor: bg_color ? bg_color : '#ffffff', - opacity: 0.6, - }, - css: { - padding: 0, - margin: 0, - width: '50%', - fontSize: '14px !important', - top: '40%', - left: '35%', - textAlign: 'center', - color: '#000', - border: 'none', - backgroundColor: 'none', - cursor: 'wait', - }, - }); + jQuery(this.$el).wu_block({ + message: '
', + overlayCSS: { + backgroundColor: bg_color ? bg_color : '#ffffff', + opacity: 0.6, + }, + css: { + padding: 0, + margin: 0, + width: '50%', + fontSize: '14px !important', + top: '40%', + left: '35%', + textAlign: 'center', + color: '#000', + border: 'none', + backgroundColor: 'none', + cursor: 'wait', + }, + }); - }, - unblock() { + }, + unblock() { - jQuery(this.$el).wu_unblock(); + jQuery(this.$el).wu_unblock(); - }, - request(action, data, success_handler, error_handler) { + }, + request(action, data, success_handler, error_handler) { - const actual_ajax_url = (action === 'wu_validate_form' || action === 'wu_create_order' || action === 'wu_render_field_template' || action === 'wu_check_user_exists' || action === 'wu_inline_login') ? wu_checkout.late_ajaxurl : wu_checkout.ajaxurl; + const actual_ajax_url = (action === 'wu_validate_form' || action === 'wu_create_order' || action === 'wu_render_field_template' || action === 'wu_check_user_exists' || action === 'wu_inline_login') ? wu_checkout.late_ajaxurl : wu_checkout.ajaxurl; - jQuery.ajax({ - method: 'POST', - url: actual_ajax_url + '&action=' + action, - data, - success: success_handler, - error: error_handler, - }); + jQuery.ajax({ + method: 'POST', + url: actual_ajax_url + '&action=' + action, + data, + success: success_handler, + error: error_handler, + }); - }, - init_password_strength() { + }, + init_password_strength() { - const that = this; - const pass1_el = jQuery('#field-password'); + const that = this; + const pass1_el = jQuery('#field-password'); - if (!pass1_el.length) { + if (! pass1_el.length) { - return; + return; - } // end if; + } // end if; - // Use the shared WU_PasswordStrength utility - if (typeof window.WU_PasswordStrength !== 'undefined') { + // Use the shared WU_PasswordStrength utility + if (typeof window.WU_PasswordStrength !== 'undefined') { - this.password_strength_checker = new window.WU_PasswordStrength({ - pass1: pass1_el, - result: jQuery('#pass-strength-result'), - minStrength: 3, - onValidityChange: function(isValid) { + // Set valid_password to false initially since password field exists and needs validation + this.valid_password = false; - that.valid_password = isValid; + this.password_strength_checker = new window.WU_PasswordStrength({ + pass1: pass1_el, + result: jQuery('#pass-strength-result'), + minStrength: 3, + onValidityChange(isValid) { - } - }); + that.valid_password = isValid; - } // end if; + } + }); - }, - check_user_exists_debounced: _.debounce(function(field_type, value) { + } // end if; - this.check_user_exists(field_type, value); + }, + check_user_exists_debounced: _.debounce(function(field_type, value) { - }, 500), - check_user_exists(field_type, value) { + this.check_user_exists(field_type, value); - // Don't check if value is too short - if (!value || value.length < 3) { + }, 500), + check_user_exists(field_type, value) { - this.show_login_prompt = false; + // Don't check if value is too short + if (! value || value.length < 3) { - return; + this.show_login_prompt = false; - } + return; - this.checking_user_exists = true; - this.login_error = ''; + } - const that = this; + this.checking_user_exists = true; + this.login_error = ''; - this.request('wu_check_user_exists', { - field_type: field_type, - value: value, - _wpnonce: jQuery('[name="_wpnonce"]').val() - }, function(results) { + const that = this; - that.checking_user_exists = false; + this.request('wu_check_user_exists', { + field_type, + value, + _wpnonce: jQuery('[name="_wpnonce"]').val() + }, function(results) { - if (results.success && results.data.exists) { + that.checking_user_exists = false; - that.show_login_prompt = true; - that.login_prompt_field = field_type; + if (results.success && results.data.exists) { - } else { + that.show_login_prompt = true; + that.login_prompt_field = field_type; - that.show_login_prompt = false; + } else { - } + that.show_login_prompt = false; - }, function(error) { + } - that.checking_user_exists = false; - that.show_login_prompt = false; + }, function(error) { - }); + that.checking_user_exists = false; + that.show_login_prompt = false; - }, - handle_inline_login(event) { + }); - console.log('handle_inline_login called', event); + }, + handle_inline_login(event) { - // Prevent any default behavior or form submission - if (event) { + // Prevent any default behavior or form submission + if (event) { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); - } + } - if (!this.inline_login_password) { + if (! this.inline_login_password) { - this.login_error = wu_checkout.i18n.password_required || 'Password is required'; + this.login_error = wu_checkout.i18n.password_required || 'Password is required'; - return false; + return false; - } + } - this.logging_in = true; - this.login_error = ''; + this.logging_in = true; + this.login_error = ''; - const that = this; - const username_or_email = this.login_prompt_field === 'email' - ? this.email_address || '' - : this.username || ''; + const that = this; + const username_or_email = this.login_prompt_field === 'email' + ? this.email_address || '' + : this.username || ''; - this.request('wu_inline_login', { - username_or_email: username_or_email, - password: this.inline_login_password, - _wpnonce: jQuery('[name="_wpnonce"]').val() - }, function(results) { + this.request('wu_inline_login', { + username_or_email, + password: this.inline_login_password, + _wpnonce: jQuery('[name="_wpnonce"]').val() + }, function(results) { - that.logging_in = false; + that.logging_in = false; - if (results.success) { + if (results.success) { - // Login successful - reload page to show logged-in state - window.location.reload(); + // Login successful - reload page to show logged-in state + window.location.reload(); - } + } - }, function(error) { + }, function(error) { - that.logging_in = false; + that.logging_in = false; - if (error.responseJSON && error.responseJSON.data && error.responseJSON.data.message) { + if (error.responseJSON && error.responseJSON.data && error.responseJSON.data.message) { - that.login_error = error.responseJSON.data.message; + that.login_error = error.responseJSON.data.message; - } else { + } else { - that.login_error = wu_checkout.i18n.login_failed || 'Login failed. Please try again.'; + that.login_error = wu_checkout.i18n.login_failed || 'Login failed. Please try again.'; - } + } - }); + }); - return false; + return false; - }, - dismiss_login_prompt() { + }, + dismiss_login_prompt() { - this.show_login_prompt = false; - this.inline_login_password = ''; - this.login_error = ''; + this.show_login_prompt = false; + this.inline_login_password = ''; + this.login_error = ''; - }, - setup_inline_login_handlers() { + }, + setup_inline_login_handlers() { - const that = this; + const that = this; - // Setup handlers for both email and username field types - ['email', 'username'].forEach(function(fieldType) { + // Setup handlers for both email and username field types + [ 'email', 'username' ].forEach(function(fieldType) { - const passwordField = document.getElementById('wu-inline-login-password-' + fieldType); - const submitButton = document.getElementById('wu-inline-login-submit-' + fieldType); - const dismissButton = document.getElementById('wu-dismiss-login-prompt-' + fieldType); - const errorDiv = document.getElementById('wu-login-error-' + fieldType); - const loginPromptContainer = document.getElementById('wu-inline-login-prompt-' + fieldType); + const passwordField = document.getElementById('wu-inline-login-password-' + fieldType); + const submitButton = document.getElementById('wu-inline-login-submit-' + fieldType); - if (!passwordField || !submitButton) return; + if (! passwordField || ! submitButton) { + return; + } - // Remove any existing listeners to avoid duplicates - const newSubmitButton = submitButton.cloneNode(true); - submitButton.parentNode.replaceChild(newSubmitButton, submitButton); + const dismissButton = document.getElementById('wu-dismiss-login-prompt-' + fieldType); + const errorDiv = document.getElementById('wu-login-error-' + fieldType); + const loginPromptContainer = document.getElementById('wu-inline-login-prompt-' + fieldType); - const newPasswordField = passwordField.cloneNode(true); - passwordField.parentNode.replaceChild(newPasswordField, passwordField); - function handleError(error) { + // Remove any existing listeners to avoid duplicates + const newSubmitButton = submitButton.cloneNode(true); + submitButton.parentNode.replaceChild(newSubmitButton, submitButton); - newSubmitButton.disabled = false; - newSubmitButton.textContent = wu_checkout.i18n.sign_in || 'Sign in'; + const newPasswordField = passwordField.cloneNode(true); + passwordField.parentNode.replaceChild(newPasswordField, passwordField); + function handleError(error) { - if (error.data && error.data.message) { + newSubmitButton.disabled = false; + newSubmitButton.textContent = wu_checkout.i18n.sign_in || 'Sign in'; - errorDiv.textContent = error.data.message; + if (error.data && error.data.message) { - } else { + errorDiv.textContent = error.data.message; - errorDiv.textContent = wu_checkout.i18n.login_failed || 'Login failed. Please try again.'; + } else { - } + errorDiv.textContent = wu_checkout.i18n.login_failed || 'Login failed. Please try again.'; - errorDiv.style.display = 'block'; + } + errorDiv.style.display = 'block'; - } + } - function handleLogin(e) { + function handleLogin(e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); - const password = newPasswordField.value; + const password = newPasswordField.value; - if (!password) { + if (! password) { - errorDiv.textContent = wu_checkout.i18n.password_required || 'Password is required'; - errorDiv.style.display = 'block'; + errorDiv.textContent = wu_checkout.i18n.password_required || 'Password is required'; + errorDiv.style.display = 'block'; - return false; + return false; - } + } - newSubmitButton.disabled = true; - newSubmitButton.innerHTML = '' + (wu_checkout.i18n.logging_in || 'Logging in...'); - errorDiv.style.display = 'none'; + newSubmitButton.disabled = true; + newSubmitButton.innerHTML = '' + (wu_checkout.i18n.logging_in || 'Logging in...'); + errorDiv.style.display = 'none'; - const username_or_email = fieldType === 'email' ? that.email_address : that.username; + const username_or_email = fieldType === 'email' ? that.email_address : that.username; - jQuery.ajax({ - method: 'POST', - url: wu_checkout.late_ajaxurl + '&action=wu_inline_login', - data: { - username_or_email: username_or_email, - password: password, - _wpnonce: jQuery('[name="_wpnonce"]').val() - }, - success: function(results) { + jQuery.ajax({ + method: 'POST', + url: wu_checkout.late_ajaxurl + '&action=wu_inline_login', + data: { + username_or_email, + password, + _wpnonce: jQuery('[name="_wpnonce"]').val() + }, + success(results) { - if (results.success) { + if (results.success) { - window.location.reload(); + window.location.reload(); - } else { - handleError(results); - } + } else { + handleError(results); + } - }, - error: handleError - }); + }, + error: handleError + }); - return false; + return false; - } + } - // Stop all events from bubbling out of the login prompt - if (loginPromptContainer) { + // Stop all events from bubbling out of the login prompt + if (loginPromptContainer) { - loginPromptContainer.addEventListener('click', function(e) { + loginPromptContainer.addEventListener('click', function(e) { - e.stopPropagation(); + e.stopPropagation(); - }); + }); - loginPromptContainer.addEventListener('keydown', function(e) { + loginPromptContainer.addEventListener('keydown', function(e) { - e.stopPropagation(); + e.stopPropagation(); - }); + }); - loginPromptContainer.addEventListener('keyup', function(e) { + loginPromptContainer.addEventListener('keyup', function(e) { - e.stopPropagation(); + e.stopPropagation(); - }); + }); - } + } - newSubmitButton.addEventListener('click', handleLogin); + newSubmitButton.addEventListener('click', handleLogin); - newPasswordField.addEventListener('keydown', function(e) { + newPasswordField.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { + if (e.key === 'Enter') { - handleLogin(e); + handleLogin(e); - } + } - }); + }); - if (dismissButton) { + if (dismissButton) { - dismissButton.addEventListener('click', function(e) { + dismissButton.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - that.show_login_prompt = false; - that.inline_login_password = ''; - newPasswordField.value = ''; + e.preventDefault(); + e.stopPropagation(); + that.show_login_prompt = false; + that.inline_login_password = ''; + newPasswordField.value = ''; - }); + }); - } + } - }); + }); - }, - }, - updated() { + }, + }, + updated() { - this.$nextTick(function () { + this.$nextTick(function () { - hooks.doAction('wu_on_form_updated', this); + hooks.doAction('wu_on_form_updated', this); - wu_initialize_tooltip(); + wu_initialize_tooltip(); - // Setup inline login handlers if prompt is visible - this.setup_inline_login_handlers(); + // Setup inline login handlers if prompt is visible + this.setup_inline_login_handlers(); - }); + }); - }, - mounted() { + }, + mounted() { - const that = this; + const that = this; - jQuery(this.$el).on('click', function (e) { + jQuery(this.$el).on('click', function (e) { - $(this).data('submited_via', $(e.target)); + $(this).data('submited_via', $(e.target)); - }); + }); - jQuery(this.$el).on('submit', async function (e) { + jQuery(this.$el).on('submit', async function (e) { - e.preventDefault(); + e.preventDefault(); - /** - * Handle button submission. - */ - const submit_el = jQuery(this).data('submited_via'); + /** + * Handle button submission. + */ + const submit_el = jQuery(this).data('submited_via'); - if (submit_el) { + if (submit_el) { - const new_input = jQuery(''); + const new_input = jQuery(''); - new_input.attr('type', 'hidden'); + new_input.attr('type', 'hidden'); - new_input.attr('name', submit_el.attr('name')); + new_input.attr('name', submit_el.attr('name')); - new_input.attr('value', submit_el.val()); + new_input.attr('value', submit_el.val()); - jQuery(this).append(new_input); + jQuery(this).append(new_input); - } // end if; + } // end if; - that.block(); + that.block(); - try { + try { - const promises = []; + const promises = []; - // Here we use filter to return possible promises to await - await Promise.all(hooks.applyFilters("wu_before_form_submitted", promises, that, that.gateway)); + // Here we use filter to return possible promises to await + await Promise.all(hooks.applyFilters("wu_before_form_submitted", promises, that, that.gateway)); - } catch (error) { + } catch (error) { - that.errors = []; + that.errors = []; - that.errors.push({ - code: 'before-submit-error', - message: error.message, - }); + that.errors.push({ + code: 'before-submit-error', + message: error.message, + }); - that.unblock(); + that.unblock(); - that.handle_errors(error); + that.handle_errors(error); - return; + return; - } // end try; + } // end try; - that.validate_form(); + that.validate_form(); - hooks.doAction('wu_on_form_submitted', that, that.gateway); + hooks.doAction('wu_on_form_submitted', that, that.gateway); - }); + }); - this.create_order(); + this.create_order(); - hooks.doAction('wu_checkout_loaded', this); + hooks.doAction('wu_checkout_loaded', this); - hooks.doAction('wu_on_change_gateway', this.gateway, this.gateway); + hooks.doAction('wu_on_change_gateway', this.gateway, this.gateway); - // Initialize password strength checker using the shared utility - this.init_password_strength(); + // Initialize password strength checker using the shared utility + this.init_password_strength(); - wu_initialize_tooltip(); + wu_initialize_tooltip(); - }, - watch: { - products(new_value, old_value) { + }, + watch: { + products(new_value, old_value) { - this.on_change_product(new_value, old_value); + this.on_change_product(new_value, old_value); - }, - toggle_discount_code(new_value) { + }, + toggle_discount_code(new_value) { - if (!new_value) { + if (! new_value) { - this.discount_code = ''; + this.discount_code = ''; - } // end if; + } // end if; - }, - discount_code(new_value, old_value) { + }, + discount_code(new_value, old_value) { - this.on_change_discount_code(new_value, old_value); + this.on_change_discount_code(new_value, old_value); - }, - gateway(new_value, old_value) { + }, + gateway(new_value, old_value) { - this.on_change_gateway(new_value, old_value); + this.on_change_gateway(new_value, old_value); - }, - country(new_value, old_value) { + }, + country(new_value, old_value) { - this.state = ''; + this.state = ''; - this.on_change_country(new_value, old_value); + this.on_change_country(new_value, old_value); - }, - state(new_value, old_value) { + }, + state(new_value, old_value) { - this.city = ''; + this.city = ''; - this.on_change_state(new_value, old_value); + this.on_change_state(new_value, old_value); - }, - city(new_value, old_value) { + }, + city(new_value, old_value) { - this.on_change_city(new_value, old_value); + this.on_change_city(new_value, old_value); - }, - duration(new_value, old_value) { + }, + duration(new_value, old_value) { - this.on_change_duration(new_value, old_value); + this.on_change_duration(new_value, old_value); - }, - duration_unit(new_value, old_value) { + }, + duration_unit(new_value, old_value) { - this.on_change_duration_unit(new_value, old_value); + this.on_change_duration_unit(new_value, old_value); - }, - }, - }); + }, + }, + }); - }); + }); }(jQuery, wp.hooks, _)); diff --git a/assets/js/checkout.min.js b/assets/js/checkout.min.js index 7f42f880..d870b366 100644 --- a/assets/js/checkout.min.js +++ b/assets/js/checkout.min.js @@ -1 +1 @@ -((n,r,s)=>{window.history.replaceState&&window.history.replaceState(null,null,wu_checkout.baseurl),r.addAction("wu_on_create_order","nextpress/wp-ultimo",function(e,t){void 0!==t.order.extra.template_id&&t.order.extra.template_id&&(e.template_id=t.order.extra.template_id)}),r.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(e){void 0!==window.wu_auto_submittable_field&&window.wu_auto_submittable_field&&e.$watch(window.wu_auto_submittable_field,function(){jQuery(this.$el).submit()},{deep:!0})}),r.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(t){wu_create_cookie("wu_template",""),wu_create_cookie("wu_selected_products",""),wu_listen_to_cookie_change("wu_template",function(e){e&&(t.template_id=e)})}),n(document).on("click",'[href|="#wu-checkout-add"]',function(e){e.preventDefault();var e=n(this),t=e.attr("href").split("#").pop().replace("wu-checkout-add-","");"undefined"!=typeof wu_checkout_form&&-1===wu_checkout_form.products.indexOf(t)&&(wu_checkout_form.add_product(t),e.html(wu_checkout.i18n.added_to_order))}),window.addEventListener("pageshow",function(e){e.persisted&&this.window.wu_checkout_form&&this.window.wu_checkout_form.unblock()}),n(document).ready(function(){var e;void 0!==window.Vue&&(Object.defineProperty(Vue.prototype,"$moment",{value:moment}),e={plan:(e=function(e){return isNaN(e)?e:parseInt(e,10)})(wu_checkout.plan),errors:[],order:wu_checkout.order,products:s.map(wu_checkout.products,e),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_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:!1,valid_password:!0,stored_templates:{},state_list:[],city_list:[],labels:{},show_login_prompt:!1,login_prompt_field:"",checking_user_exists:!1,logging_in:!1,login_error:"",inline_login_password:""},r.applyFilters("wu_before_form_init",e),jQuery("#wu_form").length)&&(Vue.component("colorPicker",{props:["value"],template:'',mounted(){let o=this;n(this.$el).val(this.value).wpColorPicker({width:200,defaultColor:this.value,change(e,t){o.$emit("input",t.color.toString())}})},watch:{value(e){n(this.$el).wpColorPicker("color",e)}},destroyed(){n(this.$el).off().wpColorPicker("destroy")}}),window.wu_checkout_form=new Vue({el:"#wu_form",data:e,directives:{init:{bind(e,t,o){o.context[t.arg]=t.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(e,t){t=t.props.template;return e(t?{template:t}:"
nbsp;
")}}},computed:{hooks(){return wp.hooks},unique_products(){return s.uniq(this.products,!1,e=>parseInt(e,10))}},methods:{debounce(e){return s.debounce(e,200,!0)},open_url(e,t="_blank"){window.open(e,t)},get_template(e,t){void 0===t.id&&(t.id="default");var o=e+"/"+t.id;return void 0!==this.stored_templates[o]?this.stored_templates[o]:(o=this.hooks.applyFilters("wu_before_template_fetch",{duration:this.duration,duration_unit:this.duration_unit,products:this.products,...t},this),this.fetch_template(e,o),'
'+wu_checkout.i18n.loading+"
")},reset_templates(n){if(void 0===n)this.stored_templates={};else{let i={};s.forEach(this.stored_templates,function(e,t){var o=t.toString().substr(0,t.toString().indexOf("/"));!1===s.contains(n,o)&&(i[t]=e)}),this.stored_templates=i}},fetch_template(o,i){let n=this;void 0===i.id&&(i.id="default"),this.request("wu_render_field_template",{template:o,attributes:i},function(e){var t=o+"/"+i.id;e.success?Vue.set(n.stored_templates,t,e.data.html):Vue.set(n.stored_templates,t,"
"+e.data[0].message+"
")})},go_back(){this.block(),window.history.back()},set_prevent_submission(e){this.$nextTick(function(){this.prevent_submission=e})},remove_product(t,o){this.products=s.filter(this.products,function(e){return e!=t&&e!=o})},add_plan(e){this.plan&&this.remove_product(this.plan),this.plan=e,this.add_product(e)},add_product(e){this.products.push(e)},has_product(e){return-1',overlayCSS:{backgroundColor:e||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(e,t,o,i){var n="wu_validate_form"===e||"wu_create_order"===e||"wu_render_field_template"===e||"wu_check_user_exists"===e||"wu_inline_login"===e?wu_checkout.late_ajaxurl:wu_checkout.ajaxurl;jQuery.ajax({method:"POST",url:n+"&action="+e,data:t,success:o,error:i})},init_password_strength(){let t=this;var e=jQuery("#field-password");e.length&&void 0!==window.WU_PasswordStrength&&(this.password_strength_checker=new window.WU_PasswordStrength({pass1:e,result:jQuery("#pass-strength-result"),minStrength:3,onValidityChange:function(e){t.valid_password=e}}))},check_user_exists_debounced:s.debounce(function(e,t){this.check_user_exists(e,t)},500),check_user_exists(o,e){if(!e||e.length<3)this.show_login_prompt=!1;else{this.checking_user_exists=!0,this.login_error="";let t=this;this.request("wu_check_user_exists",{field_type:o,value:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.checking_user_exists=!1,e.success&&e.data.exists?(t.show_login_prompt=!0,t.login_prompt_field=o):t.show_login_prompt=!1},function(e){t.checking_user_exists=!1,t.show_login_prompt=!1})}},handle_inline_login(e){if(console.log("handle_inline_login called",e),e&&(e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation()),this.inline_login_password){this.logging_in=!0,this.login_error="";let t=this;e="email"===this.login_prompt_field?this.email_address||"":this.username||"";this.request("wu_inline_login",{username_or_email:e,password:this.inline_login_password,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.logging_in=!1,e.success&&window.location.reload()},function(e){t.logging_in=!1,e.responseJSON&&e.responseJSON.data&&e.responseJSON.data.message?t.login_error=e.responseJSON.data.message:t.login_error=wu_checkout.i18n.login_failed||"Login failed. Please try again."})}else this.login_error=wu_checkout.i18n.password_required||"Password is required";return!1},dismiss_login_prompt(){this.show_login_prompt=!1,this.inline_login_password="",this.login_error=""},setup_inline_login_handlers(){let _=this;["email","username"].forEach(function(n){var e=document.getElementById("wu-inline-login-password-"+n),t=document.getElementById("wu-inline-login-submit-"+n),r=document.getElementById("wu-dismiss-login-prompt-"+n);let s=document.getElementById("wu-login-error-"+n);var a=document.getElementById("wu-inline-login-prompt-"+n);if(e&&t){let o=t.cloneNode(!0),i=(t.parentNode.replaceChild(o,t),e.cloneNode(!0));function u(e){o.disabled=!1,o.textContent=wu_checkout.i18n.sign_in||"Sign in",e.data&&e.data.message?s.textContent=e.data.message:s.textContent=wu_checkout.i18n.login_failed||"Login failed. Please try again.",s.style.display="block"}function d(e){e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation();e=i.value;if(!e)return s.textContent=wu_checkout.i18n.password_required||"Password is required",!(s.style.display="block");o.disabled=!0,o.innerHTML=''+(wu_checkout.i18n.logging_in||"Logging in..."),s.style.display="none";var t="email"===n?_.email_address:_.username;return jQuery.ajax({method:"POST",url:wu_checkout.late_ajaxurl+"&action=wu_inline_login",data:{username_or_email:t,password:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},success:function(e){e.success?window.location.reload():u(e)},error:u}),!1}e.parentNode.replaceChild(i,e),a&&(a.addEventListener("click",function(e){e.stopPropagation()}),a.addEventListener("keydown",function(e){e.stopPropagation()}),a.addEventListener("keyup",function(e){e.stopPropagation()})),o.addEventListener("click",d),i.addEventListener("keydown",function(e){"Enter"===e.key&&d(e)}),r&&r.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),_.show_login_prompt=!1,_.inline_login_password="",i.value=""})}})}},updated(){this.$nextTick(function(){r.doAction("wu_on_form_updated",this),wu_initialize_tooltip(),this.setup_inline_login_handlers()})},mounted(){let i=this;jQuery(this.$el).on("click",function(e){n(this).data("submited_via",n(e.target))}),jQuery(this.$el).on("submit",async function(e){e.preventDefault();var t,e=jQuery(this).data("submited_via");e&&((t=jQuery("")).attr("type","hidden"),t.attr("name",e.attr("name")),t.attr("value",e.val()),jQuery(this).append(t)),i.block();try{var o=[];await Promise.all(r.applyFilters("wu_before_form_submitted",o,i,i.gateway))}catch(e){return i.errors=[],i.errors.push({code:"before-submit-error",message:e.message}),i.unblock(),void i.handle_errors(e)}i.validate_form(),r.doAction("wu_on_form_submitted",i,i.gateway)}),this.create_order(),r.doAction("wu_checkout_loaded",this),r.doAction("wu_on_change_gateway",this.gateway,this.gateway),this.init_password_strength(),wu_initialize_tooltip()},watch:{products(e,t){this.on_change_product(e,t)},toggle_discount_code(e){e||(this.discount_code="")},discount_code(e,t){this.on_change_discount_code(e,t)},gateway(e,t){this.on_change_gateway(e,t)},country(e,t){this.state="",this.on_change_country(e,t)},state(e,t){this.city="",this.on_change_state(e,t)},city(e,t){this.on_change_city(e,t)},duration(e,t){this.on_change_duration(e,t)},duration_unit(e,t){this.on_change_duration_unit(e,t)}}}))})})(jQuery,wp.hooks,_); \ No newline at end of file +((r,n,s)=>{window.history.replaceState&&window.history.replaceState(null,null,wu_checkout.baseurl),n.addAction("wu_on_create_order","nextpress/wp-ultimo",function(e,t){void 0!==t.order.extra.template_id&&t.order.extra.template_id&&(e.template_id=t.order.extra.template_id)}),n.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(e){void 0!==window.wu_auto_submittable_field&&window.wu_auto_submittable_field&&e.$watch(window.wu_auto_submittable_field,function(){jQuery(this.$el).submit()},{deep:!0})}),n.addAction("wu_checkout_loaded","nextpress/wp-ultimo",function(t){wu_create_cookie("wu_template",""),wu_create_cookie("wu_selected_products",""),wu_listen_to_cookie_change("wu_template",function(e){e&&(t.template_id=e)})}),r(document).on("click",'[href|="#wu-checkout-add"]',function(e){e.preventDefault();var e=r(this),t=e.attr("href").split("#").pop().replace("wu-checkout-add-","");"undefined"!=typeof wu_checkout_form&&-1===wu_checkout_form.products.indexOf(t)&&(wu_checkout_form.add_product(t),e.html(wu_checkout.i18n.added_to_order))}),window.addEventListener("pageshow",function(e){e.persisted&&this.window.wu_checkout_form&&this.window.wu_checkout_form.unblock()}),r(document).ready(function(){var e;void 0!==window.Vue&&(Object.defineProperty(Vue.prototype,"$moment",{value:moment}),e={plan:(e=function(e){return isNaN(e)?e:parseInt(e,10)})(wu_checkout.plan),errors:[],order:wu_checkout.order,products:s.map(wu_checkout.products,e),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_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:!1,valid_password:!0,stored_templates:{},state_list:[],city_list:[],labels:{},show_login_prompt:!1,login_prompt_field:"",checking_user_exists:!1,logging_in:!1,login_error:"",inline_login_password:"",custom_amounts:wu_checkout.custom_amounts||{},pwyw_recurring:wu_checkout.pwyw_recurring||{}},n.applyFilters("wu_before_form_init",e),jQuery("#wu_form").length)&&(Vue.component("colorPicker",{props:["value"],template:'',mounted(){let o=this;r(this.$el).val(this.value).wpColorPicker({width:200,defaultColor:this.value,change(e,t){o.$emit("input",t.color.toString())}})},watch:{value(e){r(this.$el).wpColorPicker("color",e)}},destroyed(){r(this.$el).off().wpColorPicker("destroy")}}),window.wu_checkout_form=new Vue({el:"#wu_form",data:e,directives:{init:{bind(e,t,o){o.context[t.arg]=t.value}}},components:{dynamic:{functional:!0,template:"#dynamic",props:["template"],render(e,t){t=t.props.template;return e(t?{template:t}:"
nbsp;
")}}},computed:{hooks(){return wp.hooks},unique_products(){return s.uniq(this.products,!1,e=>parseInt(e,10))}},methods:{debounce(e){return s.debounce(e,200,!0)},open_url(e,t="_blank"){window.open(e,t)},get_template(e,t){void 0===t.id&&(t.id="default");var o=e+"/"+t.id;return void 0!==this.stored_templates[o]?this.stored_templates[o]:(o=this.hooks.applyFilters("wu_before_template_fetch",{duration:this.duration,duration_unit:this.duration_unit,products:this.products,...t},this),this.fetch_template(e,o),'
'+wu_checkout.i18n.loading+"
")},reset_templates(r){if(void 0===r)this.stored_templates={};else{let i={};s.forEach(this.stored_templates,function(e,t){var o=t.toString().substr(0,t.toString().indexOf("/"));!1===s.contains(r,o)&&(i[t]=e)}),this.stored_templates=i}},fetch_template(o,i){let r=this;void 0===i.id&&(i.id="default"),this.request("wu_render_field_template",{template:o,attributes:i},function(e){var t=o+"/"+i.id;e.success?Vue.set(r.stored_templates,t,e.data.html):Vue.set(r.stored_templates,t,"
"+e.data[0].message+"
")})},go_back(){this.block(),window.history.back()},set_prevent_submission(e){this.$nextTick(function(){this.prevent_submission=e})},remove_product(t,o){this.products=s.filter(this.products,function(e){return e!=t&&e!=o})},add_plan(e){this.plan&&this.remove_product(this.plan),this.plan=e,this.add_product(e)},add_product(e){this.products.push(e)},has_product(e){return-1',overlayCSS:{backgroundColor:e||"#ffffff",opacity:.6},css:{padding:0,margin:0,width:"50%",fontSize:"14px !important",top:"40%",left:"35%",textAlign:"center",color:"#000",border:"none",backgroundColor:"none",cursor:"wait"}})},unblock(){jQuery(this.$el).wu_unblock()},request(e,t,o,i){var r="wu_validate_form"===e||"wu_create_order"===e||"wu_render_field_template"===e||"wu_check_user_exists"===e||"wu_inline_login"===e?wu_checkout.late_ajaxurl:wu_checkout.ajaxurl;jQuery.ajax({method:"POST",url:r+"&action="+e,data:t,success:o,error:i})},init_password_strength(){let t=this;var e=jQuery("#field-password");e.length&&void 0!==window.WU_PasswordStrength&&(this.password_strength_checker=new window.WU_PasswordStrength({pass1:e,result:jQuery("#pass-strength-result"),minStrength:3,onValidityChange:function(e){t.valid_password=e}}))},check_user_exists_debounced:s.debounce(function(e,t){this.check_user_exists(e,t)},500),check_user_exists(o,e){if(!e||e.length<3)this.show_login_prompt=!1;else{this.checking_user_exists=!0,this.login_error="";let t=this;this.request("wu_check_user_exists",{field_type:o,value:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.checking_user_exists=!1,e.success&&e.data.exists?(t.show_login_prompt=!0,t.login_prompt_field=o):t.show_login_prompt=!1},function(e){t.checking_user_exists=!1,t.show_login_prompt=!1})}},handle_inline_login(e){if(console.log("handle_inline_login called",e),e&&(e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation()),this.inline_login_password){this.logging_in=!0,this.login_error="";let t=this;e="email"===this.login_prompt_field?this.email_address||"":this.username||"";this.request("wu_inline_login",{username_or_email:e,password:this.inline_login_password,_wpnonce:jQuery('[name="_wpnonce"]').val()},function(e){t.logging_in=!1,e.success&&window.location.reload()},function(e){t.logging_in=!1,e.responseJSON&&e.responseJSON.data&&e.responseJSON.data.message?t.login_error=e.responseJSON.data.message:t.login_error=wu_checkout.i18n.login_failed||"Login failed. Please try again."})}else this.login_error=wu_checkout.i18n.password_required||"Password is required";return!1},dismiss_login_prompt(){this.show_login_prompt=!1,this.inline_login_password="",this.login_error=""},setup_inline_login_handlers(){let d=this;["email","username"].forEach(function(r){var e=document.getElementById("wu-inline-login-password-"+r),t=document.getElementById("wu-inline-login-submit-"+r),n=document.getElementById("wu-dismiss-login-prompt-"+r);let s=document.getElementById("wu-login-error-"+r);var a=document.getElementById("wu-inline-login-prompt-"+r);if(e&&t){let o=t.cloneNode(!0),i=(t.parentNode.replaceChild(o,t),e.cloneNode(!0));function u(e){o.disabled=!1,o.textContent=wu_checkout.i18n.sign_in||"Sign in",e.data&&e.data.message?s.textContent=e.data.message:s.textContent=wu_checkout.i18n.login_failed||"Login failed. Please try again.",s.style.display="block"}function _(e){e.preventDefault(),e.stopPropagation(),e.stopImmediatePropagation();e=i.value;if(!e)return s.textContent=wu_checkout.i18n.password_required||"Password is required",!(s.style.display="block");o.disabled=!0,o.innerHTML=''+(wu_checkout.i18n.logging_in||"Logging in..."),s.style.display="none";var t="email"===r?d.email_address:d.username;return jQuery.ajax({method:"POST",url:wu_checkout.late_ajaxurl+"&action=wu_inline_login",data:{username_or_email:t,password:e,_wpnonce:jQuery('[name="_wpnonce"]').val()},success:function(e){e.success?window.location.reload():u(e)},error:u}),!1}e.parentNode.replaceChild(i,e),a&&(a.addEventListener("click",function(e){e.stopPropagation()}),a.addEventListener("keydown",function(e){e.stopPropagation()}),a.addEventListener("keyup",function(e){e.stopPropagation()})),o.addEventListener("click",_),i.addEventListener("keydown",function(e){"Enter"===e.key&&_(e)}),n&&n.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),d.show_login_prompt=!1,d.inline_login_password="",i.value=""})}})}},updated(){this.$nextTick(function(){n.doAction("wu_on_form_updated",this),wu_initialize_tooltip(),this.setup_inline_login_handlers()})},mounted(){let i=this;jQuery(this.$el).on("click",function(e){r(this).data("submited_via",r(e.target))}),jQuery(this.$el).on("submit",async function(e){e.preventDefault();var t,e=jQuery(this).data("submited_via");e&&((t=jQuery("")).attr("type","hidden"),t.attr("name",e.attr("name")),t.attr("value",e.val()),jQuery(this).append(t)),i.block();try{var o=[];await Promise.all(n.applyFilters("wu_before_form_submitted",o,i,i.gateway))}catch(e){return i.errors=[],i.errors.push({code:"before-submit-error",message:e.message}),i.unblock(),void i.handle_errors(e)}i.validate_form(),n.doAction("wu_on_form_submitted",i,i.gateway)}),this.create_order(),n.doAction("wu_checkout_loaded",this),n.doAction("wu_on_change_gateway",this.gateway,this.gateway),this.init_password_strength(),wu_initialize_tooltip()},watch:{products(e,t){this.on_change_product(e,t)},toggle_discount_code(e){e||(this.discount_code="")},discount_code(e,t){this.on_change_discount_code(e,t)},gateway(e,t){this.on_change_gateway(e,t)},country(e,t){this.state="",this.on_change_country(e,t)},state(e,t){this.city="",this.on_change_state(e,t)},city(e,t){this.on_change_city(e,t)},duration(e,t){this.on_change_duration(e,t)},duration_unit(e,t){this.on_change_duration_unit(e,t)}}}))})})(jQuery,wp.hooks,_); \ No newline at end of file diff --git a/inc/admin-pages/class-discount-code-edit-admin-page.php b/inc/admin-pages/class-discount-code-edit-admin-page.php index b6a49e8e..24ef8711 100644 --- a/inc/admin-pages/class-discount-code-edit-admin-page.php +++ b/inc/admin-pages/class-discount-code-edit-admin-page.php @@ -113,7 +113,7 @@ public function register_widgets(): void { $tz_note = sprintf('The site timezone is %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[a-zA-Z0-9_-]+)', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'get_setting'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => [ + 'setting_key' => [ + 'description' => __('The setting key to retrieve.', 'ultimate-multisite'), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], + ], + ] + ); + + // POST /settings - Update multiple settings + register_rest_route( + $namespace, + '/settings', + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [$this, 'update_settings'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => $this->get_update_args(), + ] + ); + + // PUT/PATCH /settings/{setting_key} - Update a specific setting + register_rest_route( + $namespace, + '/settings/(?P[a-zA-Z0-9_-]+)', + [ + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => [$this, 'update_setting'], + 'permission_callback' => \Closure::fromCallable([$api, 'check_authorization']), + 'args' => [ + 'setting_key' => [ + 'description' => __('The setting key to update.', 'ultimate-multisite'), + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], + 'value' => [ + 'description' => __('The new value for the setting.', 'ultimate-multisite'), + 'required' => true, + ], + ], + ] + ); + } + + /** + * Get all settings. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response + */ + public function get_settings($request) { + + $this->maybe_log_api_call($request); + + $settings = wu_get_all_settings(); + + // Remove sensitive settings from the response + $settings = $this->filter_sensitive_settings($settings); + + return rest_ensure_response( + [ + 'success' => true, + 'settings' => $settings, + ] + ); + } + + /** + * Get a specific setting. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function get_setting($request) { + + $this->maybe_log_api_call($request); + + $setting_key = $request->get_param('setting_key'); + + // Check if this is a sensitive setting + if ($this->is_sensitive_setting($setting_key)) { + return new \WP_Error( + 'setting_protected', + __('This setting is protected and cannot be retrieved via the API.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + $value = wu_get_setting($setting_key, null); + + if (null === $value) { + // Check if setting exists (even with null/false value) vs doesn't exist + $all_settings = wu_get_all_settings(); + + if (! array_key_exists($setting_key, $all_settings)) { + return new \WP_Error( + 'setting_not_found', + sprintf( + /* translators: %s is the setting key */ + __('Setting "%s" not found.', 'ultimate-multisite'), + $setting_key + ), + ['status' => 404] + ); + } + } + + return rest_ensure_response( + [ + 'success' => true, + 'setting_key' => $setting_key, + 'value' => $value, + ] + ); + } + + /** + * Update multiple settings. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function update_settings($request) { + + $this->maybe_log_api_call($request); + + $params = $request->get_json_params(); + + if (empty($params) || ! is_array($params)) { + $params = $request->get_body_params(); + } + + $settings_to_update = wu_get_isset($params, 'settings', $params); + + if (empty($settings_to_update) || ! is_array($settings_to_update)) { + return new \WP_Error( + 'invalid_settings', + __('No valid settings provided. Please provide a "settings" object with key-value pairs.', 'ultimate-multisite'), + ['status' => 400] + ); + } + + // Validate, filter, and save settings + $errors = []; + $updated = []; + $failed = []; + + foreach ($settings_to_update as $key => $value) { + $result = $this->save_setting($key, $value); + + if (is_wp_error($result)) { + $errors[] = $result->get_error_message(); + continue; + } + + if ($result) { + $updated[] = $key; + } else { + $failed[] = $key; + } + } + + if (empty($updated) && ! empty($errors)) { + return new \WP_Error( + 'no_valid_settings', + __('No valid settings to update after filtering.', 'ultimate-multisite'), + [ + 'status' => 400, + 'errors' => $errors, + ] + ); + } + + $response_data = [ + 'success' => ! empty($updated), + 'updated' => $updated, + ]; + + if (! empty($failed)) { + $response_data['failed'] = $failed; + } + + if (! empty($errors)) { + $response_data['warnings'] = $errors; + } + + return rest_ensure_response($response_data); + } + + /** + * Update a specific setting. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request WP Request Object. + * @return \WP_REST_Response|\WP_Error + */ + public function update_setting($request) { + + $this->maybe_log_api_call($request); + + $setting_key = $request->get_param('setting_key'); + + $params = $request->get_json_params(); + + if (empty($params)) { + $params = $request->get_body_params(); + } + + if (! isset($params['value'])) { + return new \WP_Error( + 'missing_value', + __('The "value" parameter is required.', 'ultimate-multisite'), + ['status' => 400] + ); + } + + $value = wu_get_isset($params, 'value'); + $result = $this->save_setting($setting_key, $value); + + if (is_wp_error($result)) { + return $result; + } + + if (! $result) { + return new \WP_Error( + 'update_failed', + sprintf( + /* translators: %s is the setting key */ + __('Failed to update setting "%s".', 'ultimate-multisite'), + $setting_key + ), + ['status' => 500] + ); + } + + return rest_ensure_response( + [ + 'success' => true, + 'setting_key' => $setting_key, + 'value' => wu_get_setting($setting_key), + ] + ); + } + + /** + * Save a single setting with validation and sanitization. + * + * This method handles the common logic for saving settings: + * - Validates the setting key format + * - Checks if the setting is sensitive/protected + * - Sanitizes the value using the Field API if a field definition exists + * - Saves the setting to the database + * + * @since 2.4.0 + * + * @param string $key The setting key. + * @param mixed $value The value to save. + * @return bool|\WP_Error True on success, false on save failure, WP_Error on validation failure. + */ + protected function save_setting(string $key, $value) { + + // Check if this is a sensitive setting + if ($this->is_sensitive_setting($key)) { + return new \WP_Error( + 'setting_protected', + sprintf( + /* translators: %s is the setting key */ + __('Setting "%s" is protected and cannot be modified via the API.', 'ultimate-multisite'), + $key + ), + ['status' => 403] + ); + } + + // Validate setting key format + $sanitized_key = sanitize_key($key); + + if ($sanitized_key !== $key) { + return new \WP_Error( + 'invalid_key_format', + sprintf( + /* translators: %s is the setting key */ + __('Invalid setting key format: "%s".', 'ultimate-multisite'), + $key + ), + ['status' => 400] + ); + } + + // Sanitize the value using Field API if field definition exists + $sanitized_value = $this->sanitize_setting_value($key, $value); + + return wu_save_setting($key, $sanitized_value); + } + + /** + * Sanitize a setting value using the Field API. + * + * Looks up the field definition from Settings and uses the Field class + * to apply appropriate sanitization based on field type. + * + * @since 2.4.0 + * + * @param string $key The setting key. + * @param mixed $value The raw value to sanitize. + * @return mixed The sanitized value, or the original value if no field definition exists. + */ + protected function sanitize_setting_value(string $key, $value) { + + $field_definition = $this->get_field_definition($key); + + if (empty($field_definition)) { + // No field definition found, apply basic sanitization based on value type + if (is_string($value)) { + return sanitize_text_field($value); + } + + return $value; + } + + // Create a Field instance and use its sanitization + $field = new Field($key, $field_definition); + $field->set_value($value); + + return $field->get_value(); + } + + /** + * Get the field definition for a setting key. + * + * Searches through all settings sections to find the field definition + * that matches the given setting key. + * + * @since 2.4.0 + * + * @param string $key The setting key to look up. + * @return array|null The field definition array, or null if not found. + */ + protected function get_field_definition(string $key): ?array { + + $sections = Settings::get_instance()->get_sections(); + + foreach ($sections as $section) { + $fields = $section['fields'] ?? []; + + if (isset($fields[ $key ])) { + return $fields[ $key ]; + } + } + + return null; + } + + /** + * Get the arguments schema for the update endpoint. + * + * @since 2.4.0 + * @return array + */ + protected function get_update_args(): array { + + return [ + 'settings' => [ + 'description' => __('An object containing setting key-value pairs to update.', 'ultimate-multisite'), + 'type' => 'object', + 'required' => false, + ], + ]; + } + + /** + * Check if a setting is sensitive and should not be exposed via API. + * + * @since 2.4.0 + * + * @param string $setting_key The setting key to check. + * @return bool + */ + protected function is_sensitive_setting(string $setting_key): bool { + + $sensitive_settings = [ + 'api_key', + 'api_secret', + 'stripe_api_sk_live', + 'stripe_api_sk_test', + 'paypal_client_secret_live', + 'paypal_client_secret_sandbox', + ]; + + /** + * Filter the list of sensitive settings that should not be exposed via API. + * + * @since 2.4.0 + * + * @param array $sensitive_settings List of sensitive setting keys. + * @param string $setting_key The setting key being checked. + */ + $sensitive_settings = apply_filters('wu_api_sensitive_settings', $sensitive_settings, $setting_key); + + return in_array($setting_key, $sensitive_settings, true); + } + + /** + * Filter out sensitive settings from a settings array. + * + * @since 2.4.0 + * + * @param array $settings The settings array to filter. + * @return array + */ + protected function filter_sensitive_settings(array $settings): array { + + foreach ($settings as $key => $_) { + if ($this->is_sensitive_setting($key)) { + unset($settings[ $key ]); + } + } + + return $settings; + } + + /** + * Log API call if logging is enabled. + * + * Note: Request body is intentionally not logged to avoid + * accidentally storing sensitive data like passwords or API keys. + * + * @since 2.4.0 + * + * @param \WP_REST_Request $request The request object. + * @return void + */ + protected function maybe_log_api_call($request): void { + + if (\WP_Ultimo\API::get_instance()->should_log_api_calls()) { + $payload = [ + 'route' => $request->get_route(), + 'method' => $request->get_method(), + 'url_params' => $request->get_url_params(), + ]; + + wu_log_add('api-calls', wp_json_encode($payload, JSON_PRETTY_PRINT)); + } + } +} diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index 053c5471..dbde8dad 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -229,6 +229,22 @@ class Cart implements \JsonSerializable { */ protected $extra = []; + /** + * Custom amounts for PWYW products. + * + * @since 2.0.0 + * @var array + */ + protected $custom_amounts = []; + + /** + * Customer's recurring choice for PWYW products. + * + * @since 2.0.0 + * @var array + */ + protected $pwyw_recurring = []; + /** * The cart description. * @@ -260,54 +276,66 @@ public function __construct($args) { /* * Cart Type. */ - 'cart_type' => 'new', + 'cart_type' => 'new', /* * The list of products being bought. */ - 'products' => [], + 'products' => [], /* * The duration parameters * This will dictate which price variations we are going to use. */ - 'duration' => false, - 'duration_unit' => false, + 'duration' => false, + 'duration_unit' => false, /* * The membership ID. * This is passed when we want to handle a upgrade/downgrade/addon. */ - 'membership_id' => false, + 'membership_id' => false, /* * Payment ID. * This is passed when we are trying to recovered a abandoned/pending payment. */ - 'payment_id' => false, + 'payment_id' => false, /* * The discount code to be used. */ - 'discount_code' => false, + 'discount_code' => false, /* * If we should auto-renew or not. */ - 'auto_renew' => true, + 'auto_renew' => true, /* * The country, state, and city of the customer. * Used for taxation purposes. */ - 'country' => '', - 'state' => '', - 'city' => '', + 'country' => '', + 'state' => '', + 'city' => '', /* * Currency */ - 'currency' => '', + 'currency' => '', + + /* + * Custom amounts for PWYW products. + * Keyed by product ID => custom amount. + */ + 'custom_amounts' => [], + + /* + * Customer's recurring choice for PWYW products. + * Keyed by product ID => boolean. + */ + 'pwyw_recurring' => [], ], $args @@ -335,13 +363,15 @@ public function __construct($args) { /* * Set the country, duration and duration_unit. */ - $this->cart_type = $this->attributes->cart_type; - $this->country = $this->attributes->country; - $this->state = $this->attributes->state; - $this->city = $this->attributes->city; - $this->currency = $this->attributes->currency; - $this->duration = $this->attributes->duration; - $this->duration_unit = $this->attributes->duration_unit; + $this->cart_type = $this->attributes->cart_type; + $this->country = $this->attributes->country; + $this->state = $this->attributes->state; + $this->city = $this->attributes->city; + $this->currency = $this->attributes->currency; + $this->duration = $this->attributes->duration; + $this->duration_unit = $this->attributes->duration_unit; + $this->custom_amounts = is_array($this->attributes->custom_amounts) ? $this->attributes->custom_amounts : []; + $this->pwyw_recurring = is_array($this->attributes->pwyw_recurring) ? $this->attributes->pwyw_recurring : []; /* * Loads the current customer, if it exists. @@ -1336,7 +1366,7 @@ protected function set_discount_code($code): bool { if (empty($discount_code)) { // translators: %s is the coupon code being used, all-caps. e.g. PROMO10OFF - $this->errors->add('discount_code', sprintf(__('The code %s do not exist or is no longer valid.', 'ultimate-multisite'), $code)); + $this->errors->add('discount_code', sprintf(__('The code %s does not exist or is no longer valid.', 'ultimate-multisite'), $code)); return false; } @@ -1665,7 +1695,7 @@ public function add_product($product_id_or_slug, $quantity = 1): bool { * If a price variation doesn't exist, we add an error to * the cart. */ - if ($product->is_free() === false) { + if ($product->is_free() === false && ! $product->is_pay_what_you_want()) { if (absint($this->duration) !== $product->get_duration() || $product->get_duration_unit() !== $this->duration_unit) { $price_variation = $product->get_price_variation($this->duration, $this->duration_unit); @@ -1690,15 +1720,73 @@ public function add_product($product_id_or_slug, $quantity = 1): bool { } } + /* + * Handle Pay What You Want pricing. + */ + $is_recurring = $product->is_recurring(); + + if ($product->is_pay_what_you_want()) { + $custom_amount = $this->get_custom_amount_for_product($product->get_id()); + + if (null !== $custom_amount) { + $minimum = $product->get_pwyw_minimum_amount(); + + if ($custom_amount < $minimum) { + $this->errors->add( + 'pwyw-below-minimum', + sprintf( + // translators: %1$s is the product name, %2$s is the minimum amount formatted as currency + __('The amount for %1$s must be at least %2$s.', 'ultimate-multisite'), + $product->get_name(), + wu_format_currency($minimum, $product->get_currency()) + ) + ); + + return false; + } + + $amount = (float) $custom_amount; + } else { + // Use suggested amount as default + $amount = $product->get_pwyw_suggested_amount(); + } + + // Determine recurring status based on product mode and customer choice + $recurring_mode = $product->get_pwyw_recurring_mode(); + + if ('force_recurring' === $recurring_mode) { + $is_recurring = true; + } elseif ('force_one_time' === $recurring_mode) { + $is_recurring = false; + } else { + // customer_choice - check customer's selection, default to false (one-time) + $is_recurring = $this->get_pwyw_recurring_for_product($product->get_id()); + } + } + + // Build line item data + $line_item_base = [ + 'product' => $product, + 'quantity' => $quantity, + 'unit_price' => $amount, + 'duration' => $duration, + 'duration_unit' => $duration_unit, + ]; + + // For PWYW products, explicitly set the recurring status + if ($product->is_pay_what_you_want()) { + $line_item_base['recurring'] = $is_recurring; + + // If recurring, ensure duration values are set + if ($is_recurring && (! $duration || ! $duration_unit)) { + $line_item_base['duration'] = $product->get_duration(); + $line_item_base['duration_unit'] = $product->get_duration_unit(); + } + } + $line_item_data = apply_filters( 'wu_add_product_line_item', - [ - 'product' => $product, - 'quantity' => $quantity, - 'unit_price' => $amount, - 'duration' => $duration, - 'duration_unit' => $duration_unit, - ], + $line_item_base, $product, $duration, $duration_unit, @@ -2324,7 +2412,13 @@ public function apply_discounts_to_item($line_item) { return $line_item; } - if (is_wp_error($this->discount_code->is_valid($line_item->get_product_id()))) { + $is_valid = $this->discount_code->is_valid( + $line_item->get_product_id(), + $line_item->get_duration(), + $line_item->get_duration_unit() + ); + + if (is_wp_error($is_valid)) { return $line_item; } @@ -2836,4 +2930,32 @@ protected function cancel_conflicting_pending_payments(): void { // Pending payment is the same. Nothing to do, Let the form element show the pay pending payment message. } } + + /** + * Get the custom amount for a PWYW product. + * + * @since 2.0.0 + * @param int $product_id The product ID. + * @return float|null The custom amount or null if not set. + */ + public function get_custom_amount_for_product($product_id) { + + $product_id = (int) $product_id; + + return isset($this->custom_amounts[ $product_id ]) ? (float) $this->custom_amounts[ $product_id ] : null; + } + + /** + * Get whether this PWYW product should be recurring. + * + * @since 2.0.0 + * @param int $product_id The product ID. + * @return bool + */ + public function get_pwyw_recurring_for_product($product_id): bool { + + $product_id = (int) $product_id; + + return (bool) wu_get_isset($this->pwyw_recurring, $product_id, false); + } } diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 66c1e47c..b316e0ed 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -37,19 +37,28 @@ public function init(): void { add_filter('lostpassword_redirect', [$this, 'filter_lost_password_redirect']); + $use_custom_login = wu_get_setting('enable_custom_login_page', false); + + /* + * Login URL filters need to run on ALL sites (including subsites) + * so that password reset and login links redirect to the main site's + * custom login page instead of wp-login.php (which may be obfuscated). + * + * @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/291 + */ + if ($use_custom_login) { + add_filter('login_url', [$this, 'filter_login_url'], 10, 3); + + add_filter('lostpassword_url', [$this, 'filter_login_url'], 10, 3); + } + if (is_main_site()) { add_action('before_signup_header', [$this, 'redirect_to_registration_page']); - $use_custom_login = wu_get_setting('enable_custom_login_page', false); - if ( ! $use_custom_login) { return; } - add_filter('login_url', [$this, 'filter_login_url'], 10, 3); - - add_filter('lostpassword_url', [$this, 'filter_login_url'], 10, 3); - add_filter('retrieve_password_message', [$this, 'replace_reset_password_link'], 10, 4); add_filter('network_site_url', [$this, 'maybe_change_wp_login_on_urls']); diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 888865d9..9c0cafd0 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -695,17 +695,19 @@ public function process_order() { apply_filters( 'wu_cart_parameters', [ - 'products' => $this->request_or_session('products', []), - 'discount_code' => $this->request_or_session('discount_code'), - 'country' => $this->request_or_session('billing_country'), - 'state' => $this->request_or_session('billing_state'), - 'city' => $this->request_or_session('billing_city'), - 'membership_id' => $this->request_or_session('membership_id'), - 'payment_id' => $this->request_or_session('payment_id'), - 'auto_renew' => $this->request_or_session('auto_renew', false), - 'duration' => $this->request_or_session('duration'), - 'duration_unit' => $this->request_or_session('duration_unit'), - 'cart_type' => $this->request_or_session('cart_type', 'new'), + 'products' => $this->request_or_session('products', []), + 'discount_code' => $this->request_or_session('discount_code'), + 'country' => $this->request_or_session('billing_country'), + 'state' => $this->request_or_session('billing_state'), + 'city' => $this->request_or_session('billing_city'), + 'membership_id' => $this->request_or_session('membership_id'), + 'payment_id' => $this->request_or_session('payment_id'), + 'auto_renew' => $this->request_or_session('auto_renew', false), + 'duration' => $this->request_or_session('duration'), + 'duration_unit' => $this->request_or_session('duration_unit'), + 'cart_type' => $this->request_or_session('cart_type', 'new'), + 'custom_amounts' => $this->request_or_session('custom_amounts', []), + 'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []), ], $this ) @@ -762,6 +764,13 @@ public function process_order() { $this->gateway_id = $gateway->get_id(); + /* + * Set the order early so that validation_rules() + * can check should_collect_payment() to skip + * billing field requirements for free trials. + */ + $this->order = $cart; + /* * Now we need to validate the form. * @@ -777,13 +786,6 @@ public function process_order() { return $validation; } - /* - * From now on, logic can be delegated to - * special methods, so we need to set - * the order as globally accessible. - */ - $this->order = $cart; - /* * Handles display names, if needed. */ @@ -1095,16 +1097,14 @@ protected function maybe_create_customer() { $billing_address->load_attributes_from_post($session); /* - * Validates the address. + * Validates the address when payment is being collected. */ - $valid_address = $billing_address->validate(); + if ($this->should_collect_payment()) { + $valid_address = $billing_address->validate(); - /* - * There's something invalid on the address, - * bail with the errors. - */ - if (is_wp_error($valid_address)) { - return $valid_address; + if (is_wp_error($valid_address)) { + return $valid_address; + } } $customer->set_billing_address($billing_address); @@ -1618,17 +1618,19 @@ public function create_order(): void { apply_filters( 'wu_cart_parameters', [ - 'products' => $this->request_or_session('products', []), - 'discount_code' => $this->request_or_session('discount_code'), - 'country' => $country, - 'state' => $state, - 'city' => $city, - 'membership_id' => $this->request_or_session('membership_id'), - 'payment_id' => $this->request_or_session('payment_id'), - 'auto_renew' => $this->request_or_session('auto_renew', false), - 'duration' => $this->request_or_session('duration'), - 'duration_unit' => $this->request_or_session('duration_unit'), - 'cart_type' => $this->request_or_session('cart_type', 'new'), + 'products' => $this->request_or_session('products', []), + 'discount_code' => $this->request_or_session('discount_code'), + 'country' => $country, + 'state' => $state, + 'city' => $city, + 'membership_id' => $this->request_or_session('membership_id'), + 'payment_id' => $this->request_or_session('payment_id'), + 'auto_renew' => $this->request_or_session('auto_renew', false), + 'duration' => $this->request_or_session('duration'), + 'duration_unit' => $this->request_or_session('duration_unit'), + 'cart_type' => $this->request_or_session('cart_type', 'new'), + 'custom_amounts' => $this->request_or_session('custom_amounts', []), + 'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []), ], $this ) @@ -1867,7 +1869,7 @@ public function get_checkout_variables() { 'duration' => $duration, 'duration_unit' => $duration_unit, 'site_title' => $this->request_or_session('site_title'), - 'site_url' => $this->request_or_session('site_url'), + 'site_url' => $this->request_or_session('site_url') === 'autogenerate' ? '' : $this->request_or_session('site_url'), 'site_domain' => $this->request_or_session('site_domain', preg_replace('#^https?://#', '', $site_domain)), 'is_subdomain' => is_subdomain_install(), 'gateway' => wu_request('gateway', $default_gateway), @@ -1956,6 +1958,46 @@ public function get_checkout_variables() { return apply_filters('wu_get_checkout_variables', $variables, $this); } + /** + * Determines whether payment should be collected for the current checkout. + * + * Uses $this->order if available, otherwise builds a temporary Cart + * from the request/session data to check. + * + * @since 2.0.20 + * @return bool + */ + public function should_collect_payment(): bool { + + if ($this->order) { + return $this->order->should_collect_payment(); + } + + $products = $this->request_or_session('products', []); + + if (empty($products)) { + return true; + } + + try { + $cart = new Cart( + [ + 'products' => (array) $products, + 'country' => $this->request_or_session('billing_country'), + 'discount_code' => $this->request_or_session('discount_code'), + 'duration' => $this->request_or_session('duration'), + 'duration_unit' => $this->request_or_session('duration_unit'), + 'custom_amounts' => $this->request_or_session('custom_amounts', []), + 'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []), + ] + ); + + return $cart->should_collect_payment(); + } catch (\Throwable $e) { + return true; + } + } + /** * Returns the validation rules for the fields. * @@ -2068,6 +2110,17 @@ public function get_validation_rules() { } } + /* + * Remove billing field requirements when payment is not needed + * (e.g. free trials with allow_trial_without_payment_method enabled). + */ + if ( ! $this->should_collect_payment()) { + $validation_rules['billing_country'] = ''; + $validation_rules['billing_zip_code'] = ''; + $validation_rules['billing_state'] = ''; + $validation_rules['billing_city'] = ''; + } + /** * Allow plugin developers to filter the validation rules. * diff --git a/inc/checkout/signup-fields/class-signup-field-billing-address.php b/inc/checkout/signup-fields/class-signup-field-billing-address.php index f8c223cc..8f2d9d9a 100644 --- a/inc/checkout/signup-fields/class-signup-field-billing-address.php +++ b/inc/checkout/signup-fields/class-signup-field-billing-address.php @@ -267,7 +267,9 @@ public function to_fields_array($attributes) { } foreach ($fields as &$field) { - $field['wrapper_classes'] = trim(wu_get_isset($field, 'wrapper_classes', '') . ' ' . $attributes['element_classes']); + $field['wrapper_classes'] = trim(wu_get_isset($field, 'wrapper_classes', '') . ' ' . $attributes['element_classes']); + $field['wrapper_html_attr']['v-show'] = 'order.should_collect_payment'; + $field['wrapper_html_attr']['v-cloak'] = 1; } uasort($fields, 'wu_sort_by_order'); diff --git a/inc/class-addon-repository.php b/inc/class-addon-repository.php index 8bcb0550..ca02fd5d 100644 --- a/inc/class-addon-repository.php +++ b/inc/class-addon-repository.php @@ -1,7 +1,12 @@ get_error_message()), (int) $request->get_error_code()); + wu_log_add('api-calls', $request->get_error_message(), LogLevel::ERROR); + $this->delete_tokens(); } if (200 === absint($code) && 'OK' === $message) { $user = json_decode($body, true); diff --git a/inc/class-ajax.php b/inc/class-ajax.php index 71cd4a04..3ee1779f 100644 --- a/inc/class-ajax.php +++ b/inc/class-ajax.php @@ -104,16 +104,15 @@ public function search_models(): void { [ 'model' => 'membership', 'query' => [], + 'number' => 100, 'exclude' => [], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude ] ); - $query = array_merge( - $args['query'], - [ - 'number' => -1, - ] - ); + // Number can be in the query array or it's own. Code uses both. + if (empty($args['query']['number'])) { + $args['query']['number'] = $args['number']; + } if ($args['exclude']) { if (is_string($args['exclude'])) { diff --git a/inc/class-settings.php b/inc/class-settings.php index f6e111cf..d58d5740 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -708,6 +708,22 @@ public function default_sections(): void { 120 ); + $this->add_field( + 'general', + 'enable_error_reporting', + [ + 'title' => __('Help Improve Ultimate Multisite', 'ultimate-multisite'), + 'desc' => sprintf( + /* translators: %s is a link to the privacy policy */ + __('Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more.', 'ultimate-multisite'), + esc_url('https://ultimatemultisite.com/privacy-policy/') + ), + 'type' => 'toggle', + 'default' => 0, + ], + 130 + ); + /* * Login & Registration * This section holds the Login & Registration settings of the Ultimate Multisite Plugin. @@ -856,7 +872,7 @@ public function default_sections(): void { 'login-and-registration', 'force_publish_sites_sync', [ - 'title' => __('Force Synchronous Site Publication ', 'ultimate-multisite'), + 'title' => __('Force Synchronous Site Publication', 'ultimate-multisite'), 'desc' => __('By default, when a new pending site needs to be converted into a real network site, the publishing process happens via Job Queue, asynchronously. Enable this option to force the publication to happen in the same request as the signup. Be careful, as this can cause timeouts depending on the size of the site templates being copied.', 'ultimate-multisite'), 'type' => 'toggle', 'default' => 0, @@ -1730,21 +1746,6 @@ public function default_sections(): void { ] ); - $this->add_field( - 'other', - 'enable_error_reporting', - [ - 'title' => __('Help Improve Ultimate Multisite', 'ultimate-multisite'), - 'desc' => sprintf( - /* translators: %s is a link to the privacy policy */ - __('Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more.', 'ultimate-multisite'), - 'https://ultimatemultisite.com/privacy-policy/' - ), - 'type' => 'toggle', - 'default' => 0, - ] - ); - $this->add_field( 'other', 'advanced_header', diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 9eee0c2a..ac6c8c2b 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -540,6 +540,11 @@ protected function load_extra_components(): void { */ \WP_Ultimo\API\Register_Endpoint::get_instance(); + /* + * Loads API settings endpoint. + */ + \WP_Ultimo\API\Settings_Endpoint::get_instance(); + /* * Loads Documentation */ diff --git a/inc/documentation/berlindb-dynamic-hooks.php b/inc/documentation/berlindb-dynamic-hooks.php new file mode 100644 index 00000000..024ecb83 --- /dev/null +++ b/inc/documentation/berlindb-dynamic-hooks.php @@ -0,0 +1,1901 @@ + $line) { + if (preg_match($pattern, $line)) { + return $i + 1; + } + } + + return 0; +} + +// ── Discover query classes ────────────────────────────────────────────────── + +$database_dir = $plugin_root . '/inc/database'; +$query_files = glob($database_dir . '/*/class-*-query.php'); + +if (empty($query_files)) { + fwrite(STDERR, "No query class files found in {$database_dir}\n"); + exit(1); +} + +// Skip the engine base class. +$query_files = array_filter( + $query_files, + function ($file) { + return strpos($file, '/engine/') === false; + } +); + +// ── Extract metadata from each query class ────────────────────────────────── + +/** + * Parse a single PHP class file and extract protected property values. + * + * @param string $file Absolute path to the PHP file. + * @param array $properties List of property names to extract. + * @return array Associative array of property => value. + */ +function extract_class_properties(string $file, array $properties): array { + + $source = file_get_contents($file); + $result = []; + + foreach ($properties as $prop) { + // Match: protected $prop = 'value'; + // protected $prop = SomeClass::class; + if (preg_match('/protected\s+\$' . preg_quote($prop, '/') . '\s*=\s*(.+?);/s', $source, $m)) { + $raw = trim($m[1]); + + // String literal + if (preg_match("/^['\"](.+?)['\"]$/", $raw, $sm)) { + $result[ $prop ] = $sm[1]; + } + // Class reference (Foo::class or \Foo\Bar::class) + elseif (preg_match('/^(.+?)::class$/', $raw, $cm)) { + $result[ $prop ] = trim($cm[1], '\\'); + } else { + $result[ $prop ] = $raw; + } + } + } + + return $result; +} + +/** + * Parse a schema class and return column definitions that have 'transition' => true. + * + * @param string $file Absolute path to the schema class file. + * @return array List of ['name' => string, 'type' => string] for transition columns. + */ +function extract_schema_columns(string $file): array { + + $source = file_get_contents($file); + + // Extract the $columns array block. + if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) { + return []; + } + + $columns_block = $m[1]; + $all_columns = []; + $transition_columns = []; + $seen_names = []; + + // Split into individual column arrays. + // Each column is delimited by [ ... ], + preg_match_all('/\[\s*(.*?)\s*\]/s', $columns_block, $entries); + + foreach ($entries[1] as $entry) { + $col = []; + + // Extract name + if (preg_match("/'name'\s*=>\s*'([^']+)'/", $entry, $nm)) { + $col['name'] = $nm[1]; + } else { + continue; + } + + // Extract type + if (preg_match("/'type'\s*=>\s*'([^']+)'/", $entry, $tm)) { + $col['type'] = $tm[1]; + } else { + $col['type'] = 'mixed'; + } + + // Deduplicate columns (some schemas define the same column twice). + if (isset($seen_names[ $col['name'] ])) { + continue; + } + + $seen_names[ $col['name'] ] = true; + $all_columns[] = $col; + + // Check for transition => true + if (preg_match("/'transition'\s*=>\s*true/", $entry)) { + $transition_columns[] = $col; + } + } + + return [ + 'all' => $all_columns, + 'transition' => $transition_columns, + ]; +} + +/** + * Resolve a schema class reference to a file path. + * + * @param string $class_fqn Fully qualified class name without leading \. + * @param string $plugin_root Plugin root directory. + * @return string|null File path or null. + */ +function resolve_schema_file(string $class_fqn, string $plugin_root): ?string { + + // WP_Ultimo\Database\Memberships\Memberships_Schema + // → inc/database/memberships/class-memberships-schema.php + $parts = explode('\\', $class_fqn); + $class_name = array_pop($parts); // Memberships_Schema + + // Convert class name to filename: Memberships_Schema → class-memberships-schema.php + $filename = 'class-' . str_replace('_', '-', strtolower($class_name)) . '.php'; + + // Build the directory path from namespace parts after WP_Ultimo + // WP_Ultimo\Database\Memberships → inc/database/memberships + $ns_path = ''; + $found_root = false; + + foreach ($parts as $part) { + if ($part === 'WP_Ultimo') { + $found_root = true; + $ns_path = 'inc'; + continue; + } + + if ($found_root) { + $ns_path .= '/' . strtolower(str_replace('_', '-', $part)); + } + } + + $full_path = $plugin_root . '/' . $ns_path . '/' . $filename; + + return file_exists($full_path) ? $full_path : null; +} + +/** + * Map a MySQL type string to a PHPDoc type. + * + * @param string $mysql_type The MySQL column type. + * @return string PHPDoc type. + */ +function mysql_type_to_phpdoc(string $mysql_type): string { + + $type = strtolower($mysql_type); + + if (preg_match('/^(big)?int|smallint|tinyint|mediumint/', $type)) { + return 'int'; + } + + if (preg_match('/^decimal|float|double/', $type)) { + return 'string'; // Stored as string in PHP for precision. + } + + if (preg_match('/^datetime|date|timestamp/', $type)) { + return 'string'; + } + + if (preg_match('/^enum/', $type)) { + return 'string'; + } + + return 'string'; +} + +/** + * Convert an item_name to a human-readable label. + * + * @param string $item_name The item name (e.g. 'discount_code'). + * @return string Human-readable label (e.g. 'discount code'). + */ +function humanize(string $item_name): string { + + return str_replace('_', ' ', $item_name); +} + +// ── Collect all model metadata ────────────────────────────────────────────── + +$models = []; + +foreach ($query_files as $query_file) { + $props = extract_class_properties( + $query_file, + [ + 'item_name', + 'item_name_plural', + 'table_name', + 'table_schema', + ] + ); + + if (empty($props['item_name']) || empty($props['item_name_plural'])) { + fwrite(STDERR, "Skipping {$query_file}: missing item_name or item_name_plural\n"); + continue; + } + + $schema_file = null; + $columns = [ + 'all' => [], + 'transition' => [], + ]; + + if (! empty($props['table_schema'])) { + $schema_file = resolve_schema_file($props['table_schema'], $plugin_root); + + if ($schema_file) { + $columns = extract_schema_columns($schema_file); + } else { + fwrite(STDERR, "Schema file not found for {$props['table_schema']}\n"); + } + } + + $key = $props['item_name'] . '|' . $props['item_name_plural']; + + // Deduplicate: Broadcast_Query, Email_Query, and Post_Query all share + // item_name = 'post'. We only need one set of hooks per unique + // item_name/item_name_plural combination. + if (isset($models[ $key ])) { + continue; + } + + $models[ $key ] = [ + 'item_name' => $props['item_name'], + 'item_name_plural' => $props['item_name_plural'], + 'table_name' => $props['table_name'] ?? $props['item_name_plural'], + 'transition_cols' => $columns['transition'], + 'all_cols' => $columns['all'], + 'source_file' => basename($query_file), + ]; +} + +// Sort by item name for consistent output. +ksort($models); + +fwrite( + STDERR, + sprintf( + "Found %d unique models with %d total transition columns\n", + count($models), + array_sum( + array_map( + function ($m) { + return count($m['transition_cols']); + }, + $models + ) + ) + ) +); + +// ── Generate the output file ──────────────────────────────────────────────── + +$output_file = __DIR__ . '/berlindb-dynamic-hooks.php'; +$prefix = 'wu'; +$src = $query_php_relative; + +ob_start(); + +echo " true, ]; - return [ + $allowed_tags = [ 'svg' => $svg_attributes + [ 'width' => true, 'height' => true, @@ -613,4 +612,15 @@ function wu_kses_allowed_html(): array { 'height' => true, ], ] + array_merge_recursive($allowed_html, array_fill_keys(array_keys($allowed_html) + ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'], $vue_and_data_attributes)); + + /** + * Filters the allowed HTML tags and attributes. + * + * Allows addons to extend the allowed HTML elements for wp_kses sanitization. + * + * @since 2.5.0 + * + * @param array $allowed_tags The allowed HTML tags and attributes. + */ + return apply_filters('wu_kses_allowed_html', $allowed_tags); } diff --git a/inc/gateways/class-base-gateway.php b/inc/gateways/class-base-gateway.php index 66da5d2c..cd6657ba 100644 --- a/inc/gateways/class-base-gateway.php +++ b/inc/gateways/class-base-gateway.php @@ -562,7 +562,7 @@ public function get_customer_url_on_gateway($gateway_customer_id) {} /** * Reflects membership changes on the gateway. * - * By default, this method will process tha cancellation of current gateway subscription + * By default, this method will process the cancellation of a current gateway subscription * * @since 2.1.3 * diff --git a/inc/helpers/validation-rules/class-site-template.php b/inc/helpers/validation-rules/class-site-template.php index ea8e6ffc..1d4ef7c5 100644 --- a/inc/helpers/validation-rules/class-site-template.php +++ b/inc/helpers/validation-rules/class-site-template.php @@ -92,7 +92,7 @@ public function check($template_id) : bool { // phpcs:ignore [$plan, $additional_products] = wu_segregate_products($products); - $products = array_merge([$plan], $additional_products); + $products = array_filter(array_merge([$plan], $additional_products)); foreach ($products as $product) { $limits = $limits->merge($product->get_limitations()); diff --git a/inc/limits/class-site-template-limits.php b/inc/limits/class-site-template-limits.php index e62b7a02..8a6fc18a 100644 --- a/inc/limits/class-site-template-limits.php +++ b/inc/limits/class-site-template-limits.php @@ -72,7 +72,7 @@ public function maybe_filter_template_selection_options($attributes) { [$plan, $additional_products] = wu_segregate_products($products); - $products = array_merge([$plan], $additional_products); + $products = array_filter(array_merge([$plan], $additional_products)); foreach ($products as $product) { $limits = $limits->merge($product->get_limitations()); @@ -168,7 +168,7 @@ protected function is_template_available($products, $template_id) { [$plan, $additional_products] = wu_segregate_products($products); - $products = array_merge([$plan], $additional_products); + $products = array_filter(array_merge([$plan], $additional_products)); foreach ($products as $product) { $limits = $limits->merge($product->get_limitations()); diff --git a/inc/list-tables/class-base-list-table.php b/inc/list-tables/class-base-list-table.php index 4ffb7171..0175aecb 100644 --- a/inc/list-tables/class-base-list-table.php +++ b/inc/list-tables/class-base-list-table.php @@ -874,7 +874,7 @@ public function column_membership($item): void {
- "; + 'required|min:2', - 'code' => 'required|min:2|max:20|alpha_dash', - 'uses' => 'integer|default:0', - 'max_uses' => 'integer|min:0|default:0', - 'active' => 'default:1', - 'apply_to_renewals' => 'default:0', - 'type' => 'default:absolute|in:percentage,absolute', - 'value' => 'required|numeric', - 'setup_fee_type' => 'in:percentage,absolute', - 'setup_fee_value' => 'numeric', - 'allowed_products' => 'array', - 'limit_products' => 'default:0', + 'name' => 'required|min:2', + 'code' => 'required|min:2|max:20|alpha_dash', + 'uses' => 'integer|default:0', + 'max_uses' => 'integer|min:0|default:0', + 'active' => 'default:1', + 'apply_to_renewals' => 'default:0', + 'type' => 'default:absolute|in:percentage,absolute', + 'value' => 'required|numeric', + 'setup_fee_type' => 'in:percentage,absolute', + 'setup_fee_value' => 'numeric', + 'allowed_products' => 'array', + 'limit_products' => 'default:0', + 'allowed_billing_periods' => 'array', + 'limit_billing_periods' => 'default:0', ]; } @@ -470,9 +498,11 @@ public function is_active() { * * @since 2.0.0 * @param int|\WP_Ultimo\Models\Product $product Product to check against. + * @param int|null $duration The billing duration (e.g., 1, 3, 12). + * @param string|null $duration_unit The billing duration unit (e.g., 'month', 'year'). * @return true|\WP_Error */ - public function is_valid($product = false) { + public function is_valid($product = false, ?int $duration = null, ?string $duration_unit = null) { if ($this->is_active() === false) { return new \WP_Error('discount_code', __('This coupon code is not valid.', 'ultimate-multisite')); @@ -509,27 +539,72 @@ public function is_valid($product = false) { } } - if ( ! $this->get_limit_products()) { - return true; - } - - if ( ! empty($product)) { + /* + * Check product restrictions. + */ + if ($this->get_limit_products() && ! empty($product)) { if (is_a($product, '\WP_Ultimo\Models\Product')) { $product_id = $product->get_id(); } elseif (is_numeric($product)) { $product_id = $product; } - $allowed = $this->get_limit_products() && in_array($product_id, $this->get_allowed_products()); // phpcs:ignore + $allowed = in_array($product_id, $this->get_allowed_products()); // phpcs:ignore if (false === $allowed) { return new \WP_Error('discount_code', __('This coupon code is not valid.', 'ultimate-multisite')); } } + /* + * Check billing period restrictions. + */ + if ($this->get_limit_billing_periods() && null !== $duration && null !== $duration_unit) { + $billing_period_key = self::get_billing_period_key($duration, $duration_unit); + $allowed_periods = $this->get_allowed_billing_periods(); + + if ( ! in_array($billing_period_key, $allowed_periods, true)) { + return new \WP_Error('discount_code', __('This coupon code is not valid for the selected billing period.', 'ultimate-multisite')); + } + } + return true; } + /** + * Creates a billing period key from duration and duration unit. + * + * @since 2.0.0 + * @param int $duration The billing duration (e.g., 1, 3, 12). + * @param string $duration_unit The billing duration unit (e.g., 'month', 'year'). + * @return string The billing period key (e.g., '1-month', '1-year'). + */ + public static function get_billing_period_key(int $duration, string $duration_unit): string { + + return sprintf('%d-%s', $duration, $duration_unit); + } + + /** + * Parses a billing period key back to duration and duration unit. + * + * @since 2.0.0 + * @param string $key The billing period key (e.g., '1-month', '1-year'). + * @return array{duration: int, duration_unit: string}|false Array with duration and duration_unit, or false if invalid. + */ + public static function parse_billing_period_key(string $key) { + + $parts = explode('-', $key, 2); + + if (count($parts) !== 2) { + return false; + } + + return [ + 'duration' => (int) $parts[0], + 'duration_unit' => $parts[1], + ]; + } + /** * Checks if this discount applies just for the first payment. * @@ -766,4 +841,62 @@ public function set_limit_products($limit_products): void { $this->limit_products = $this->meta[ self::META_LIMIT_PRODUCTS ]; } + + /** + * Get if we should check for billing periods or not. + * + * @since 2.0.0 + * @return bool + */ + public function get_limit_billing_periods() { + + if (null === $this->limit_billing_periods) { + $this->limit_billing_periods = $this->get_meta(self::META_LIMIT_BILLING_PERIODS, false); + } + + return (bool) $this->limit_billing_periods; + } + + /** + * Set if we should check for billing periods or not. + * + * @since 2.0.0 + * @param bool $limit_billing_periods This discount code will be limited to certain billing periods? If set to true, you must define a list of allowed billing periods. + * @return void + */ + public function set_limit_billing_periods($limit_billing_periods): void { + + $this->meta[ self::META_LIMIT_BILLING_PERIODS ] = (bool) $limit_billing_periods; + + $this->limit_billing_periods = $this->meta[ self::META_LIMIT_BILLING_PERIODS ]; + } + + /** + * Get holds the list of allowed billing periods. + * + * @since 2.0.0 + * @return array + */ + public function get_allowed_billing_periods() { + + if (null === $this->allowed_billing_periods) { + $this->allowed_billing_periods = $this->get_meta(self::META_ALLOWED_BILLING_PERIODS, []); + } + + return (array) $this->allowed_billing_periods; + } + + /** + * Set holds the list of allowed billing periods. + * + * @since 2.0.0 + * @param array $allowed_billing_periods The list of billing periods that allows this discount code to be used. Format: ['1-month', '1-year']. + * @return void + */ + public function set_allowed_billing_periods($allowed_billing_periods): void { + + $this->meta[ self::META_ALLOWED_BILLING_PERIODS ] = (array) $allowed_billing_periods; + + $this->allowed_billing_periods = $this->meta[ self::META_ALLOWED_BILLING_PERIODS ]; + } } diff --git a/inc/models/class-product.php b/inc/models/class-product.php index 90eec5b1..2960dfbf 100644 --- a/inc/models/class-product.php +++ b/inc/models/class-product.php @@ -75,6 +75,21 @@ class Product extends Base_Model implements Limitable { */ const META_LEGACY_OPTIONS = 'legacy_options'; + /** + * Meta key for PWYW minimum amount. + */ + const META_PWYW_MINIMUM_AMOUNT = 'wu_pwyw_minimum_amount'; + + /** + * Meta key for PWYW suggested amount. + */ + const META_PWYW_SUGGESTED_AMOUNT = 'wu_pwyw_suggested_amount'; + + /** + * Meta key for PWYW recurring mode. + */ + const META_PWYW_RECURRING_MODE = 'wu_pwyw_recurring_mode'; + /** * The product name. * @@ -319,6 +334,32 @@ class Product extends Base_Model implements Limitable { */ protected $legacy_options; + /** + * PWYW minimum amount. + * + * @since 2.0.0 + * @var float + */ + protected $pwyw_minimum_amount; + + /** + * PWYW suggested amount. + * + * @since 2.0.0 + * @var float + */ + protected $pwyw_suggested_amount; + + /** + * PWYW recurring mode. + * + * Can be 'customer_choice', 'force_recurring', or 'force_one_time'. + * + * @since 2.0.0 + * @var string + */ + protected $pwyw_recurring_mode; + /** * Query Class to the static query methods. * @@ -360,28 +401,31 @@ public function validation_rules() { $currency = wu_get_setting('currency_symbol', 'USD'); return [ - 'featured_image_id' => 'integer', - 'currency' => "required|default:{$currency}", - 'pricing_type' => 'required|in:free,paid,contact_us', - 'trial_duration' => 'integer', - 'trial_duration_unit' => 'in:day,week,month,year|default:month', - 'parent_id' => 'integer', - 'amount' => 'numeric|default:0', - 'recurring' => 'default:0', - 'setup_fee' => 'numeric', - 'duration' => 'numeric|default:1', - 'duration_unit' => 'in:day,week,month,year|default:month', - 'billing_cycles' => 'integer|default:0', - 'active' => 'default:1', - 'price_variations' => "price_variations:{$duration},{$duration_unit}", - 'type' => "required|default:plan|in:{$allowed_types}", - 'slug' => "required|unique:\WP_Ultimo\Models\Product,slug,{$id}|min:2", - 'taxable' => 'boolean|default:0', - 'tax_category' => 'default:', - 'contact_us_label' => 'default:', - 'contact_us_link' => 'url:http,https', - 'customer_role' => 'alpha_dash', - 'network_id' => 'integer|nullable', + 'featured_image_id' => 'integer', + 'currency' => "required|default:{$currency}", + 'pricing_type' => 'required|in:free,paid,contact_us,pay_what_you_want', + 'trial_duration' => 'integer', + 'trial_duration_unit' => 'in:day,week,month,year|default:month', + 'parent_id' => 'integer', + 'amount' => 'numeric|default:0', + 'recurring' => 'default:0', + 'setup_fee' => 'numeric', + 'duration' => 'numeric|default:1', + 'duration_unit' => 'in:day,week,month,year|default:month', + 'billing_cycles' => 'integer|default:0', + 'active' => 'default:1', + 'price_variations' => "price_variations:{$duration},{$duration_unit}", + 'type' => "required|default:plan|in:{$allowed_types}", + 'slug' => "required|unique:\WP_Ultimo\Models\Product,slug,{$id}|min:2", + 'taxable' => 'boolean|default:0', + 'tax_category' => 'default:', + 'contact_us_label' => 'default:', + 'contact_us_link' => 'url:http,https', + 'customer_role' => 'alpha_dash', + 'network_id' => 'integer|nullable', + 'pwyw_minimum_amount' => 'numeric|default:0', + 'pwyw_suggested_amount' => 'numeric|default:0', + 'pwyw_recurring_mode' => 'in:customer_choice,force_recurring,force_one_time|default:customer_choice', ]; } @@ -548,11 +592,11 @@ public function get_pricing_type() { } /** - * Set pricing type can be one of 'free', 'paid', and 'contact_us'. + * Set pricing type can be one of 'free', 'paid', 'contact_us', and 'pay_what_you_want'. * * @since 2.0.0 - * @param string $pricing_type The pricing type can be 'free', 'paid' or 'contact_us'. - * @options free,paid,contact_us + * @param string $pricing_type The pricing type can be 'free', 'paid', 'contact_us', or 'pay_what_you_want'. + * @options free,paid,contact_us,pay_what_you_want * @return void */ public function set_pricing_type($pricing_type): void { @@ -564,6 +608,9 @@ public function set_pricing_type($pricing_type): void { $this->set_recurring(false); } + + // For PWYW, we don't force amount to 0 - it uses suggested_amount as default + // and recurring is determined by pwyw_recurring_mode } /** @@ -667,7 +714,7 @@ public function set_duration_unit($duration_unit): void { /** * Get the product amount. * - * @return int + * @return int|float */ public function get_amount() { @@ -679,6 +726,11 @@ public function get_amount() { return 0; } + // For PWYW, return the suggested amount as the default + if ($this->is_pay_what_you_want()) { + return $this->get_pwyw_suggested_amount(); + } + return $this->amount; } @@ -699,6 +751,17 @@ public function get_formatted_amount($key = 'amount') { return $this->get_contact_us_label() ?: __('Contact Us', 'ultimate-multisite'); } + if ($this->is_pay_what_you_want()) { + $minimum = $this->get_pwyw_minimum_amount(); + + if ($minimum > 0) { + // translators: %s is the minimum amount formatted as currency + return sprintf(__('From %s', 'ultimate-multisite'), wu_format_currency($minimum, $this->get_currency())); + } + + return __('Name Your Price', 'ultimate-multisite'); + } + return wu_format_currency($this->get_amount(), $this->get_currency()); } @@ -959,12 +1022,39 @@ public function set_parent_id($parent_id): void { /** * Get is this product recurring? * + * For PWYW products, recurring is determined by pwyw_recurring_mode: + * - 'force_recurring': always recurring + * - 'force_one_time': never recurring + * - 'customer_choice': depends on customer selection (treated as potentially recurring) + * * @since 2.0.0 * @return boolean */ public function is_recurring() { - return (bool) $this->recurring && (float) $this->get_amount() > 0; + $is_recurring = (bool) $this->recurring && (float) $this->get_amount() > 0; + + // PWYW products determine recurring status via pwyw_recurring_mode + if ($this->is_pay_what_you_want()) { + $pwyw_mode = $this->get_pwyw_recurring_mode(); + + if ('force_recurring' === $pwyw_mode) { + $is_recurring = true; + } elseif ('force_one_time' === $pwyw_mode) { + $is_recurring = false; + } + // 'customer_choice' uses the base $is_recurring value + } + + /** + * Filter whether a product is considered recurring. + * + * @since 2.4.0 + * + * @param bool $is_recurring Whether the product is recurring. + * @param Product $product The product instance. + */ + return apply_filters('wu_product_is_recurring', $is_recurring, $this); } /** @@ -1533,6 +1623,121 @@ public function set_legacy_options($legacy_options): void { $this->legacy_options = $this->meta[ self::META_LEGACY_OPTIONS ]; } + /** + * Checks if this product uses Pay What You Want pricing. + * + * @since 2.0.0 + * @return bool + */ + public function is_pay_what_you_want(): bool { + + return 'pay_what_you_want' === $this->get_pricing_type(); + } + + /** + * Checks if this PWYW product allows customer to choose recurring. + * + * @since 2.0.0 + * @return bool + */ + public function allows_customer_recurring_choice(): bool { + + return $this->is_pay_what_you_want() && 'customer_choice' === $this->get_pwyw_recurring_mode(); + } + + /** + * Get the PWYW minimum amount. + * + * @since 2.0.0 + * @return float + */ + public function get_pwyw_minimum_amount(): float { + + if (null === $this->pwyw_minimum_amount) { + $this->pwyw_minimum_amount = (float) $this->get_meta(self::META_PWYW_MINIMUM_AMOUNT, 0); + } + + return (float) $this->pwyw_minimum_amount; + } + + /** + * Set the PWYW minimum amount. + * + * @since 2.0.0 + * @param float $amount The minimum amount customers can pay. + * @return void + */ + public function set_pwyw_minimum_amount($amount): void { + + $this->meta[ self::META_PWYW_MINIMUM_AMOUNT ] = wu_to_float($amount); + + $this->pwyw_minimum_amount = $this->meta[ self::META_PWYW_MINIMUM_AMOUNT ]; + } + + /** + * Get the PWYW suggested amount. + * + * @since 2.0.0 + * @return float + */ + public function get_pwyw_suggested_amount(): float { + + if (null === $this->pwyw_suggested_amount) { + $this->pwyw_suggested_amount = (float) $this->get_meta(self::META_PWYW_SUGGESTED_AMOUNT, 0); + } + + return (float) $this->pwyw_suggested_amount; + } + + /** + * Set the PWYW suggested amount. + * + * @since 2.0.0 + * @param float $amount The suggested price shown as the default value. + * @return void + */ + public function set_pwyw_suggested_amount($amount): void { + + $this->meta[ self::META_PWYW_SUGGESTED_AMOUNT ] = wu_to_float($amount); + + $this->pwyw_suggested_amount = $this->meta[ self::META_PWYW_SUGGESTED_AMOUNT ]; + } + + /** + * Get the PWYW recurring mode. + * + * @since 2.0.0 + * @return string One of 'customer_choice', 'force_recurring', or 'force_one_time'. + */ + public function get_pwyw_recurring_mode(): string { + + if (null === $this->pwyw_recurring_mode) { + $this->pwyw_recurring_mode = $this->get_meta(self::META_PWYW_RECURRING_MODE, 'customer_choice'); + } + + return $this->pwyw_recurring_mode ?: 'customer_choice'; + } + + /** + * Set the PWYW recurring mode. + * + * @since 2.0.0 + * @param string $mode The recurring mode: 'customer_choice', 'force_recurring', or 'force_one_time'. + * @return void + */ + public function set_pwyw_recurring_mode($mode): void { + + $valid_modes = ['customer_choice', 'force_recurring', 'force_one_time']; + + if ( ! in_array($mode, $valid_modes, true)) { + $mode = 'customer_choice'; + } + + $this->meta[ self::META_PWYW_RECURRING_MODE ] = $mode; + + $this->pwyw_recurring_mode = $this->meta[ self::META_PWYW_RECURRING_MODE ]; + } + /** * List of limitations that need to be merged. * diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php index 66bfbf93..41341330 100644 --- a/inc/sso/class-magic-link.php +++ b/inc/sso/class-magic-link.php @@ -148,6 +148,62 @@ public function generate_magic_link($user_id, $site_id, $redirect_to = '') { return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to); } + /** + * Generate a magic link for cross-network authentication. + * + * Unlike generate_magic_link(), this method stores the transient on the + * target site directly (for cross-network scenarios where the target site + * is on a different network) and accepts the site URL as a parameter + * instead of looking it up via wu_get_site(). + * + * @since 2.0.0 + * + * @param int $user_id The user ID to authenticate. + * @param int $site_id The target site ID (on the other network). + * @param string $site_url The target site's URL. + * @param string $redirect_to Optional. URL to redirect to after login. + * @return string|false The magic link URL or false on failure. + */ + public function generate_cross_network_magic_link(int $user_id, int $site_id, string $site_url, string $redirect_to = '') { + + if ( ! $this->is_enabled()) { + return false; + } + + $user = get_userdata($user_id); + + if ( ! $user) { + return false; + } + + $token = $this->generate_token(); + $user_agent = $this->get_user_agent(); + $ip_address = $this->get_client_ip(); + + $token_data = [ + 'user_id' => $user_id, + 'site_id' => $site_id, + 'redirect_to' => $redirect_to, + 'created_at' => time(), + 'user_agent' => $user_agent, + 'ip_address' => $ip_address, + ]; + + $transient_key = self::TRANSIENT_PREFIX . $token; + + // Store transient on the target site's network main site + // so the handler on that network can find it. + wu_switch_blog_and_run( + fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION), + $site_id + ); + + return add_query_arg( + [self::TOKEN_QUERY_ARG => $token], + $site_url + ); + } + /** * Generate a cryptographically secure token. * diff --git a/inc/stuff.php b/inc/stuff.php index d170a4dc..3832dfca 100644 --- a/inc/stuff.php +++ b/inc/stuff.php @@ -1,5 +1,5 @@ 'JOgRkxnYU/T77rarLGeUH2VENDdVc1d4ajdFeklhSm5SRFlVaW11M0k1WnFZMithRWpZZlZvMDVxbk8xR0RwejQwbjZMOEJRYmNGb3A4a0Q=', - 1 => 'T/CdTxvsrndQXyrK46n4gnRxYSt0OTFiZEk2V3k2aWptRHNSS0NKMFh0TGd2dko1eDI0OG14OGFwN243c1gvWWkzN3FzdlpxY2kvQlpsR1I=', + 0 => 'm8OHTALz1+lpJmH7wOZMqm5BZ09XRlpUdE9UK2RMN0dqbzNobk1OMk1XQUQwcDl5U1R5aHkxZlJHTmQrOExyZlV1WktGcTJlWTlGaXhxOWU=', + 1 => '+11XF9OjjG7eYuLSg8bnKzRIMHh4akl3WFNNdzZ0UzkwbFd6SDBCeDhoNFhSVm5OQUhtN01VOWNCN2JZRFhmcVFENWFzZlVsakVxUmFYSFM=', ); diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index 1374379b..137b5e29 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -771,12 +771,18 @@ public function output($atts, $content = null): void { 'title' => $atts['label_username'], 'placeholder' => $atts['placeholder_username'], 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'username', + ], ], 'pwd' => [ 'type' => 'password', 'title' => $atts['label_password'], 'placeholder' => $atts['placeholder_password'], 'tooltip' => '', + 'html_attr' => [ + 'autocomplete' => 'current-password', + ], ], ]; diff --git a/inc/ui/class-site-actions-element.php b/inc/ui/class-site-actions-element.php index de2d1655..dd066ad8 100644 --- a/inc/ui/class-site-actions-element.php +++ b/inc/ui/class-site-actions-element.php @@ -158,6 +158,14 @@ public function fields() { 'value' => 1, ]; + $fields['show_change_email'] = [ + 'type' => 'toggle', + 'title' => __('Show Change Email', 'ultimate-multisite'), + 'desc' => __('Toggle to show/hide the change email link.', 'ultimate-multisite'), + 'tooltip' => '', + 'value' => 1, + ]; + $fields['show_change_default_site'] = [ 'type' => 'toggle', 'title' => __('Show Change Default Site', 'ultimate-multisite'), @@ -246,6 +254,7 @@ public function defaults() { return [ 'show_change_password' => 1, + 'show_change_email' => 1, 'show_change_default_site' => 1, 'show_change_payment_method' => 1, 'redirect_after_delete' => 0, @@ -318,6 +327,15 @@ public function register_forms(): void { ] ); + wu_register_form( + 'change_email', + [ + 'render' => [$this, 'render_change_email'], + 'handler' => [$this, 'handle_change_email'], + 'capability' => 'exist', + ] + ); + wu_register_form( 'delete_site', [ @@ -407,6 +425,15 @@ public function get_actions($atts) { ]; } + if (wu_get_isset($atts, 'show_change_email')) { + $actions['change_email'] = [ + 'label' => __('Change Email', 'ultimate-multisite'), + 'icon_classes' => 'dashicons-wu-edit wu-align-middle', + 'classes' => 'wubox', + 'href' => wu_get_form_url('change_email'), + ]; + } + $payment_gateway = $this->membership ? $this->membership->get_gateway() : false; if (wu_get_isset($atts, 'show_change_payment_method') && $payment_gateway) { @@ -738,6 +765,151 @@ public function handle_change_password(): void { ); } + /** + * Renders the change email modal. + * + * @since 2.3.0 + * @return void + */ + public function render_change_email(): void { + + $user = wp_get_current_user(); + + $fields = [ + 'current_email' => [ + 'type' => 'text', + 'title' => __('Current Email', 'ultimate-multisite'), + 'value' => $user->user_email, + 'html_attr' => [ + 'disabled' => 'disabled', + ], + ], + 'password' => [ + 'type' => 'password', + 'title' => __('Current Password', 'ultimate-multisite'), + 'placeholder' => __('******', 'ultimate-multisite'), + 'desc' => __('Enter your password to confirm this change.', 'ultimate-multisite'), + ], + 'new_email' => [ + 'type' => 'email', + 'title' => __('New Email', 'ultimate-multisite'), + 'placeholder' => __('newemail@example.com', 'ultimate-multisite'), + ], + 'new_email_conf' => [ + 'type' => 'email', + 'placeholder' => __('newemail@example.com', 'ultimate-multisite'), + 'title' => __('Confirm New Email', 'ultimate-multisite'), + ], + 'submit_button' => [ + 'type' => 'submit', + 'title' => __('Change Email', 'ultimate-multisite'), + 'value' => 'save', + 'classes' => 'button button-primary wu-w-full', + 'wrapper_classes' => 'wu-items-end', + 'html_attr' => [], + ], + ]; + + $form = new \WP_Ultimo\UI\Form( + 'change_email', + $fields, + [ + 'views' => 'admin-pages/fields', + 'classes' => 'wu-modal-form wu-widget-list wu-striped wu-m-0 wu-mt-0', + 'field_wrapper_classes' => 'wu-w-full wu-box-border wu-items-center wu-flex wu-justify-between wu-p-4 wu-m-0 wu-border-t wu-border-l-0 wu-border-r-0 wu-border-b-0 wu-border-gray-300 wu-border-solid', + 'html_attr' => [ + 'data-wu-app' => 'change_email', + 'data-state' => wu_convert_to_state(), + ], + ] + ); + + $form->render(); + } + + /** + * Handles the email change form. + * + * @since 2.3.0 + * @return void + */ + public function handle_change_email(): void { + + $user = wp_get_current_user(); + + if ( ! $user) { + $error = new \WP_Error('user-dont-exist', __('Something went wrong.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + $current_password = wu_request('password'); + + if ( ! wp_check_password($current_password, $user->user_pass, $user->ID)) { + $error = new \WP_Error('wrong-password', __('Your current password is wrong.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + $new_email = wu_request('new_email'); + $new_email_conf = wu_request('new_email_conf'); + + if ( ! $new_email || ! is_email($new_email)) { + $error = new \WP_Error('invalid-email', __('Please enter a valid email address.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + if ($new_email !== $new_email_conf) { + $error = new \WP_Error('emails-dont-match', __('Email addresses do not match.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + if (strtolower($new_email) === strtolower($user->user_email)) { + $error = new \WP_Error('same-email', __('The new email address is the same as your current email.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + // Check if email is already in use by another user. + $existing_user = get_user_by('email', $new_email); + + if ($existing_user && $existing_user->ID !== $user->ID) { + $error = new \WP_Error('email-exists', __('This email address is already in use.', 'ultimate-multisite')); + + wp_send_json_error($error); + } + + // Update WordPress user email. + $user_id = wp_update_user( + [ + 'ID' => $user->ID, + 'user_email' => $new_email, + ] + ); + + if (is_wp_error($user_id)) { + wp_send_json_error($user_id); + } + + // Update customer email if exists. + $customer = wu_get_current_customer(); + + if ($customer) { + $customer->set_email_address($new_email); + $customer->save(); + } + + $referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_url(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; + + wp_send_json_success( + [ + 'redirect_url' => add_query_arg('updated', 1, $referer), + ] + ); + } + /** * Renders the change current site modal. * diff --git a/inc/ui/class-template-previewer.php b/inc/ui/class-template-previewer.php index bd9bd772..f345903e 100644 --- a/inc/ui/class-template-previewer.php +++ b/inc/ui/class-template-previewer.php @@ -282,7 +282,7 @@ public function template_previewer(): void { [$plan, $additional_products] = wu_segregate_products($products); - $products = array_merge([$plan], $additional_products); + $products = array_filter(array_merge([$plan], $additional_products)); foreach ($products as $product) { $limits = $limits->merge($product->get_limitations()); diff --git a/lang/ultimate-multisite.pot b/lang/ultimate-multisite.pot index 9c730eb6..82d921a6 100644 --- a/lang/ultimate-multisite.pot +++ b/lang/ultimate-multisite.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-01-23T20:12:05+00:00\n" +"POT-Creation-Date: 2026-02-03T18:06:12+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: ultimate-multisite\n" @@ -123,7 +123,7 @@ msgid "Premium" msgstr "" #: inc/admin-pages/class-addons-admin-page.php:458 -#: inc/admin-pages/class-product-edit-admin-page.php:299 +#: inc/admin-pages/class-product-edit-admin-page.php:300 #: inc/installers/class-default-content-installer.php:264 #: inc/list-tables/class-membership-list-table-widget.php:166 #: inc/list-tables/class-membership-list-table.php:129 @@ -143,8 +143,8 @@ msgid "Growth & Scaling" msgstr "" #: inc/admin-pages/class-addons-admin-page.php:470 -#: inc/class-settings.php:1526 -#: inc/class-settings.php:1527 +#: inc/class-settings.php:1542 +#: inc/class-settings.php:1543 msgid "Integrations" msgstr "" @@ -214,7 +214,7 @@ msgstr "" #: inc/admin-pages/class-broadcast-edit-admin-page.php:96 #: inc/admin-pages/class-checkout-form-edit-admin-page.php:1320 #: inc/admin-pages/class-customer-edit-admin-page.php:811 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:244 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:266 #: inc/admin-pages/class-domain-edit-admin-page.php:315 #: inc/admin-pages/class-event-list-admin-page.php:176 #: inc/admin-pages/class-event-list-admin-page.php:187 @@ -278,8 +278,8 @@ msgstr "" #: inc/admin-pages/class-broadcast-edit-admin-page.php:133 #: inc/admin-pages/class-membership-edit-admin-page.php:472 -#: inc/admin-pages/class-membership-list-admin-page.php:118 -#: inc/admin-pages/class-membership-list-admin-page.php:119 +#: inc/admin-pages/class-membership-list-admin-page.php:135 +#: inc/admin-pages/class-membership-list-admin-page.php:136 #: inc/admin-pages/class-payment-edit-admin-page.php:1042 #: inc/admin-pages/class-payment-edit-admin-page.php:1043 #: inc/admin-pages/class-payment-list-admin-page.php:105 @@ -359,7 +359,7 @@ msgstr "" #: inc/admin-pages/class-broadcast-edit-admin-page.php:370 #: inc/admin-pages/class-edit-admin-page.php:489 #: inc/admin-pages/class-tax-rates-admin-page.php:193 -#: inc/checkout/class-checkout.php:1810 +#: inc/checkout/class-checkout.php:1812 #: views/base/edit/widget-tabs.php:106 #: views/dashboard-statistics/filter.php:28 #: views/dashboard-statistics/widget-mrr-growth.php:16 @@ -403,7 +403,7 @@ msgstr "" #: inc/admin-pages/class-broadcast-edit-admin-page.php:428 #: inc/admin-pages/class-checkout-form-edit-admin-page.php:1459 #: inc/admin-pages/class-customer-edit-admin-page.php:1065 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:590 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:796 #: inc/admin-pages/class-domain-edit-admin-page.php:497 #: inc/admin-pages/class-edit-admin-page.php:277 #: inc/admin-pages/class-email-edit-admin-page.php:452 @@ -411,7 +411,7 @@ msgstr "" #: inc/admin-pages/class-event-view-admin-page.php:253 #: inc/admin-pages/class-membership-edit-admin-page.php:997 #: inc/admin-pages/class-payment-edit-admin-page.php:1225 -#: inc/admin-pages/class-product-edit-admin-page.php:1010 +#: inc/admin-pages/class-product-edit-admin-page.php:1091 #: inc/admin-pages/class-site-edit-admin-page.php:678 #: inc/admin-pages/class-view-logs-admin-page.php:281 #: inc/admin-pages/class-webhook-edit-admin-page.php:343 @@ -660,6 +660,7 @@ msgid "Save Field" msgstr "" #: inc/admin-pages/class-checkout-form-edit-admin-page.php:724 +#: inc/admin-pages/class-membership-list-admin-page.php:125 #: inc/compat/class-legacy-shortcodes.php:352 #: inc/models/class-checkout-form.php:841 #: views/legacy/signup/pricing-table/frequency-selector.php:31 @@ -783,9 +784,9 @@ msgid "Add custom snippets in HTML (with javascript support) to add conversion t msgstr "" #: inc/admin-pages/class-checkout-form-edit-admin-page.php:1245 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:230 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:252 #: inc/admin-pages/class-email-edit-admin-page.php:294 -#: inc/class-settings.php:1752 +#: inc/class-settings.php:1753 msgid "Advanced Options" msgstr "" @@ -857,14 +858,14 @@ msgstr "" #: inc/admin-pages/class-checkout-form-edit-admin-page.php:1370 #: inc/admin-pages/class-checkout-form-edit-admin-page.php:1374 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:378 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:382 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:400 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:404 #: inc/admin-pages/class-domain-edit-admin-page.php:376 #: inc/admin-pages/class-domain-edit-admin-page.php:388 #: inc/admin-pages/class-email-edit-admin-page.php:279 #: inc/admin-pages/class-email-edit-admin-page.php:283 -#: inc/admin-pages/class-product-edit-admin-page.php:515 -#: inc/admin-pages/class-product-edit-admin-page.php:519 +#: inc/admin-pages/class-product-edit-admin-page.php:596 +#: inc/admin-pages/class-product-edit-admin-page.php:600 #: inc/admin-pages/class-site-edit-admin-page.php:543 #: inc/admin-pages/class-site-edit-admin-page.php:547 #: inc/admin-pages/class-template-previewer-customize-admin-page.php:106 @@ -1151,10 +1152,10 @@ msgid "Value" msgstr "" #: inc/admin-pages/class-customer-edit-admin-page.php:592 -#: inc/admin-pages/class-product-edit-admin-page.php:762 +#: inc/admin-pages/class-product-edit-admin-page.php:843 #: inc/checkout/signup-fields/class-signup-field-period-selection.php:216 #: inc/checkout/signup-fields/class-signup-field-select.php:179 -#: inc/class-settings.php:1137 +#: inc/class-settings.php:1153 #: inc/list-tables/class-membership-line-item-list-table.php:139 #: inc/list-tables/class-payment-line-item-list-table.php:82 #: views/checkout/templates/order-bump/simple.php:49 @@ -1240,11 +1241,11 @@ msgid "Total Grossed" msgstr "" #: inc/admin-pages/class-customer-edit-admin-page.php:725 -#: inc/admin-pages/class-membership-list-admin-page.php:279 -#: inc/admin-pages/class-membership-list-admin-page.php:290 -#: inc/admin-pages/class-membership-list-admin-page.php:301 -#: inc/class-settings.php:950 -#: inc/class-settings.php:951 +#: inc/admin-pages/class-membership-list-admin-page.php:311 +#: inc/admin-pages/class-membership-list-admin-page.php:322 +#: inc/admin-pages/class-membership-list-admin-page.php:333 +#: inc/class-settings.php:966 +#: inc/class-settings.php:967 #: inc/debug/class-debug.php:195 #: inc/list-tables/class-customer-list-table.php:244 #: inc/list-tables/class-membership-list-table-widget.php:42 @@ -1263,7 +1264,7 @@ msgstr "" #: inc/admin-pages/class-email-edit-admin-page.php:298 #: inc/admin-pages/class-invoice-template-customize-admin-page.php:193 #: inc/admin-pages/class-membership-edit-admin-page.php:431 -#: inc/admin-pages/class-product-edit-admin-page.php:623 +#: inc/admin-pages/class-product-edit-admin-page.php:704 #: inc/admin-pages/class-template-previewer-customize-admin-page.php:132 #: inc/class-settings.php:550 #: inc/class-settings.php:551 @@ -1341,8 +1342,8 @@ msgstr "" #: inc/admin-pages/class-payment-list-admin-page.php:255 #: inc/admin-pages/class-payment-list-admin-page.php:266 #: inc/admin-pages/class-top-admin-nav-menu.php:115 -#: inc/class-settings.php:1357 -#: inc/class-settings.php:1358 +#: inc/class-settings.php:1373 +#: inc/class-settings.php:1374 #: inc/debug/class-debug.php:263 #: inc/list-tables/class-payment-list-table-widget.php:42 #: inc/list-tables/class-payment-list-table.php:42 @@ -1355,8 +1356,8 @@ msgstr "" #: inc/admin-pages/class-site-list-admin-page.php:517 #: inc/admin-pages/class-site-list-admin-page.php:528 #: inc/admin-pages/class-site-list-admin-page.php:539 -#: inc/class-settings.php:1197 -#: inc/class-settings.php:1198 +#: inc/class-settings.php:1213 +#: inc/class-settings.php:1214 #: inc/debug/class-debug.php:212 #: inc/list-tables/class-site-list-table.php:45 #: inc/managers/class-limitation-manager.php:276 @@ -1780,7 +1781,7 @@ msgid "Allows you to set an expiration date for this coupon code." msgstr "" #: inc/admin-pages/class-discount-code-edit-admin-page.php:184 -#: inc/admin-pages/class-membership-list-admin-page.php:135 +#: inc/admin-pages/class-membership-list-admin-page.php:152 #: inc/list-tables/class-membership-list-table.php:242 msgid "Expiration Date" msgstr "" @@ -1809,112 +1810,196 @@ msgstr "" msgid "Manually select to which products this discount code should be applicable." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:268 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:226 +msgid "Limit Billing Periods" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:227 +msgid "Restrict this discount code to specific billing periods (e.g., only monthly or only annual plans)." +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:236 +msgid "Select Billing Periods" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:237 +msgid "Manually select which billing periods this discount code should be applicable to." +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:290 #: inc/checkout/signup-fields/class-signup-field-discount-code.php:58 #: inc/models/class-checkout-form.php:1019 #: inc/models/class-checkout-form.php:1020 msgid "Coupon Code" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:270 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:292 msgid "E.g. XMAS10OFF" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:271 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:293 msgid "The actual code your customers will enter during checkout." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:284 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:306 #: inc/list-tables/class-line-item-list-table.php:172 #: inc/list-tables/class-payment-line-item-list-table.php:94 #: views/invoice/template.php:111 msgid "Discount" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:295 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:351 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:317 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:373 #: inc/admin-pages/class-payment-edit-admin-page.php:824 msgid "Percentage (%)" msgstr "" #. translators: %s is the currency symbol. e.g. $ -#: inc/admin-pages/class-discount-code-edit-admin-page.php:297 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:353 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:319 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:375 #, php-format msgid "Absolute (%s)" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:318 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:340 msgid "Apply to Renewals" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:319 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:341 msgid "By default, discounts are only applied to the first payment." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:327 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:339 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:349 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:361 msgid "Setup Fee Discount" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:328 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:350 msgid "Also set a discount for setup fee?" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:383 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:405 msgid "Use this option to manually enable or disable this discount code for new sign-ups." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:407 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:429 msgid "Make applicable to this product." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:435 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:457 msgid "You do not have any products at this moment." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:456 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:485 +msgid "Make applicable to this billing period." +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:513 +msgid "No billing periods found. Create products with different billing periods first." +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:602 +#: inc/functions/date.php:115 +#: views/dashboard-statistics/widget-tax-by-day.php:19 +#: views/dashboard-statistics/widget-tax-by-day.php:52 +msgid "Day" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:603 +#: inc/admin-pages/class-membership-edit-admin-page.php:701 +#: inc/admin-pages/class-product-edit-admin-page.php:489 +#: inc/admin-pages/class-product-edit-admin-page.php:543 +#: inc/admin-pages/class-product-edit-admin-page.php:870 +#: inc/checkout/signup-fields/class-signup-field-period-selection.php:242 +#: inc/functions/date.php:115 +msgid "Days" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:606 +msgid "Week" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:607 +#: inc/admin-pages/class-membership-edit-admin-page.php:702 +#: inc/admin-pages/class-product-edit-admin-page.php:490 +#: inc/admin-pages/class-product-edit-admin-page.php:544 +#: inc/admin-pages/class-product-edit-admin-page.php:871 +#: inc/checkout/signup-fields/class-signup-field-period-selection.php:243 +msgid "Weeks" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:610 +#: inc/functions/date.php:118 +msgid "Month" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:611 +#: inc/admin-pages/class-membership-edit-admin-page.php:703 +#: inc/admin-pages/class-product-edit-admin-page.php:491 +#: inc/admin-pages/class-product-edit-admin-page.php:545 +#: inc/admin-pages/class-product-edit-admin-page.php:872 +#: inc/checkout/signup-fields/class-signup-field-period-selection.php:244 +#: inc/functions/date.php:118 +msgid "Months" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:614 +#: inc/functions/date.php:121 +msgid "Year" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:615 +#: inc/admin-pages/class-membership-edit-admin-page.php:704 +#: inc/admin-pages/class-product-edit-admin-page.php:492 +#: inc/admin-pages/class-product-edit-admin-page.php:546 +#: inc/admin-pages/class-product-edit-admin-page.php:873 +#: inc/checkout/signup-fields/class-signup-field-period-selection.php:245 +#: inc/functions/date.php:121 +msgid "Years" +msgstr "" + +#: inc/admin-pages/class-discount-code-edit-admin-page.php:662 msgid "Legacy Add-ons" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:469 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:491 -#: inc/admin-pages/class-product-edit-admin-page.php:575 -#: inc/admin-pages/class-product-edit-admin-page.php:597 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:675 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:697 +#: inc/admin-pages/class-product-edit-admin-page.php:656 +#: inc/admin-pages/class-product-edit-admin-page.php:678 msgid "Legacy Options" msgstr "" #. translators: %s is the comma-separated list of legacy add-ons. -#: inc/admin-pages/class-discount-code-edit-admin-page.php:471 -#: inc/admin-pages/class-product-edit-admin-page.php:577 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:677 +#: inc/admin-pages/class-product-edit-admin-page.php:658 #, php-format msgid "Options for %s, and others." msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:548 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:559 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:582 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:754 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:765 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:788 msgid "Edit Discount Code" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:548 -#: inc/admin-pages/class-discount-code-edit-admin-page.php:583 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:754 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:789 msgid "Add new Discount Code" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:584 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:790 msgid "Discount Code updated successfully!" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:585 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:791 msgid "Enter Discount Code" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:587 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:793 msgid "Save Discount Code" msgstr "" -#: inc/admin-pages/class-discount-code-edit-admin-page.php:589 +#: inc/admin-pages/class-discount-code-edit-admin-page.php:795 msgid "Delete Discount Code" msgstr "" @@ -1962,8 +2047,8 @@ msgstr "" #: inc/managers/class-notes-manager.php:407 #: inc/ui/class-current-membership-element.php:454 #: inc/ui/class-domain-mapping-element.php:459 -#: inc/ui/class-site-actions-element.php:541 -#: inc/ui/class-site-actions-element.php:886 +#: inc/ui/class-site-actions-element.php:568 +#: inc/ui/class-site-actions-element.php:1058 msgid "This action can not be undone." msgstr "" @@ -2841,7 +2926,7 @@ msgstr "" #: inc/admin-pages/class-invoice-template-customize-admin-page.php:203 #: inc/admin-pages/class-invoice-template-customize-admin-page.php:287 -#: inc/admin-pages/class-product-edit-admin-page.php:298 +#: inc/admin-pages/class-product-edit-admin-page.php:299 #: inc/invoices/class-invoice.php:260 msgid "Paid" msgstr "" @@ -2968,7 +3053,7 @@ msgstr "" #: inc/admin-pages/class-product-list-admin-page.php:98 #: inc/admin-pages/class-product-list-admin-page.php:109 #: inc/admin-pages/class-top-admin-nav-menu.php:103 -#: inc/checkout/class-checkout.php:2125 +#: inc/checkout/class-checkout.php:2178 #: inc/checkout/signup-fields/class-signup-field-pricing-table.php:170 #: inc/checkout/signup-fields/class-signup-field-products.php:149 #: inc/checkout/signup-fields/class-signup-field-products.php:150 @@ -3061,7 +3146,7 @@ msgstr "" #: inc/admin-pages/class-membership-edit-admin-page.php:626 #: inc/admin-pages/class-payment-edit-admin-page.php:746 #: inc/admin-pages/class-payment-edit-admin-page.php:764 -#: inc/admin-pages/class-product-edit-admin-page.php:495 +#: inc/admin-pages/class-product-edit-admin-page.php:576 #, php-format msgid "E.g. %s" msgstr "" @@ -3084,48 +3169,13 @@ msgstr "" #. 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) #: inc/admin-pages/class-membership-edit-admin-page.php:665 -#: inc/admin-pages/class-product-edit-admin-page.php:370 +#: inc/admin-pages/class-product-edit-admin-page.php:451 #, php-format msgid "The customer will be charged %1$s every %2$s %3$s(s)." msgstr "" -#: inc/admin-pages/class-membership-edit-admin-page.php:701 -#: inc/admin-pages/class-product-edit-admin-page.php:408 -#: inc/admin-pages/class-product-edit-admin-page.php:462 -#: inc/admin-pages/class-product-edit-admin-page.php:789 -#: inc/checkout/signup-fields/class-signup-field-period-selection.php:242 -#: inc/functions/date.php:115 -msgid "Days" -msgstr "" - -#: inc/admin-pages/class-membership-edit-admin-page.php:702 -#: inc/admin-pages/class-product-edit-admin-page.php:409 -#: inc/admin-pages/class-product-edit-admin-page.php:463 -#: inc/admin-pages/class-product-edit-admin-page.php:790 -#: inc/checkout/signup-fields/class-signup-field-period-selection.php:243 -msgid "Weeks" -msgstr "" - -#: inc/admin-pages/class-membership-edit-admin-page.php:703 -#: inc/admin-pages/class-product-edit-admin-page.php:410 -#: inc/admin-pages/class-product-edit-admin-page.php:464 -#: inc/admin-pages/class-product-edit-admin-page.php:791 -#: inc/checkout/signup-fields/class-signup-field-period-selection.php:244 -#: inc/functions/date.php:118 -msgid "Months" -msgstr "" - -#: inc/admin-pages/class-membership-edit-admin-page.php:704 -#: inc/admin-pages/class-product-edit-admin-page.php:411 -#: inc/admin-pages/class-product-edit-admin-page.php:465 -#: inc/admin-pages/class-product-edit-admin-page.php:792 -#: inc/checkout/signup-fields/class-signup-field-period-selection.php:245 -#: inc/functions/date.php:121 -msgid "Years" -msgstr "" - #: inc/admin-pages/class-membership-edit-admin-page.php:737 -#: inc/admin-pages/class-product-edit-admin-page.php:418 +#: inc/admin-pages/class-product-edit-admin-page.php:499 msgid "Billing Cycles" msgstr "" @@ -3134,7 +3184,7 @@ msgid "E.g. 0" msgstr "" #: inc/admin-pages/class-membership-edit-admin-page.php:739 -#: inc/admin-pages/class-product-edit-admin-page.php:420 +#: inc/admin-pages/class-product-edit-admin-page.php:501 msgid "How many times should we bill this customer. Leave 0 to charge until cancelled." msgstr "" @@ -3240,7 +3290,7 @@ msgstr "" #: inc/admin-pages/class-membership-edit-admin-page.php:993 #: inc/admin-pages/class-payment-edit-admin-page.php:1221 -#: inc/admin-pages/class-product-edit-admin-page.php:1006 +#: inc/admin-pages/class-product-edit-admin-page.php:1087 msgid "This name will be used on pricing tables, invoices, and more." msgstr "" @@ -3364,45 +3414,85 @@ msgstr "" msgid "You can add multiples products to this membership." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:120 +#: inc/admin-pages/class-membership-list-admin-page.php:118 +#: inc/admin-pages/class-product-edit-admin-page.php:377 +msgid "Billing Period" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:119 +msgid "Select the billing period for this membership. Must match a price variation in the selected product." +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:123 +msgid "Daily" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:124 +msgid "Weekly" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:126 +msgid "Quarterly (3 months)" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:127 +msgid "Semi-annually (6 months)" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:128 +#: inc/compat/class-legacy-shortcodes.php:362 +#: inc/models/class-checkout-form.php:851 +#: views/legacy/signup/pricing-table/frequency-selector.php:33 +msgid "Yearly" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:129 +msgid "Every 2 years" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:130 +msgid "Every 3 years" +msgstr "" + +#: inc/admin-pages/class-membership-list-admin-page.php:137 #: inc/apis/class-register-endpoint.php:376 msgid "The membership status." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:127 +#: inc/admin-pages/class-membership-list-admin-page.php:144 #: inc/list-tables/class-membership-list-table.php:202 msgid "Lifetime" msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:128 +#: inc/admin-pages/class-membership-list-admin-page.php:145 msgid "Activate this toggle to mark the newly created membership as lifetime." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:136 +#: inc/admin-pages/class-membership-list-admin-page.php:153 msgid "Set the expiration date of the membership to be created." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:153 +#: inc/admin-pages/class-membership-list-admin-page.php:170 msgid "Create Membership" msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:200 +#: inc/admin-pages/class-membership-list-admin-page.php:217 msgid "Products can not be empty." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:211 +#: inc/admin-pages/class-membership-list-admin-page.php:228 msgid "The selected customer does not exist." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:266 +#: inc/admin-pages/class-membership-list-admin-page.php:298 msgid "Membership removed successfully." msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:267 +#: inc/admin-pages/class-membership-list-admin-page.php:299 msgid "Search Membership" msgstr "" -#: inc/admin-pages/class-membership-list-admin-page.php:314 +#: inc/admin-pages/class-membership-list-admin-page.php:346 msgid "Add Membership" msgstr "" @@ -3511,7 +3601,7 @@ msgid "Fee" msgstr "" #: inc/admin-pages/class-payment-edit-admin-page.php:669 -#: inc/checkout/class-cart.php:1302 +#: inc/checkout/class-cart.php:1332 msgid "Credit" msgstr "" @@ -3549,7 +3639,7 @@ msgid "Item quantity." msgstr "" #: inc/admin-pages/class-payment-edit-admin-page.php:725 -#: inc/admin-pages/class-product-edit-admin-page.php:419 +#: inc/admin-pages/class-product-edit-admin-page.php:500 msgid "E.g. 1" msgstr "" @@ -3575,7 +3665,7 @@ msgid "Refund, credit or fee amount." msgstr "" #: inc/admin-pages/class-payment-edit-admin-page.php:779 -#: inc/admin-pages/class-product-edit-admin-page.php:846 +#: inc/admin-pages/class-product-edit-admin-page.php:927 msgid "Is Taxable?" msgstr "" @@ -3864,58 +3954,118 @@ msgstr "" msgid "Product Options" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:292 +#: inc/admin-pages/class-product-edit-admin-page.php:293 msgid "Pricing Type" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:293 +#: inc/admin-pages/class-product-edit-admin-page.php:294 msgid "Select Pricing Type" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:294 -msgid "Products can be free, paid, or require further contact for pricing." +#: inc/admin-pages/class-product-edit-admin-page.php:295 +msgid "Products can be free, paid, pay what you want, or require further contact for pricing." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:300 -#: inc/models/class-product.php:699 +#: inc/admin-pages/class-product-edit-admin-page.php:301 +msgid "Pay What You Want" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:302 +#: inc/models/class-product.php:751 msgid "Contact Us" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:311 +#: inc/admin-pages/class-product-edit-admin-page.php:313 msgid "Button Label" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:312 +#: inc/admin-pages/class-product-edit-admin-page.php:314 msgid "E.g. Contact us" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:313 +#: inc/admin-pages/class-product-edit-admin-page.php:315 msgid "This will be used on the pricing table CTA button, as the label." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:322 +#: inc/admin-pages/class-product-edit-admin-page.php:324 msgid "Button Link" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:323 +#: inc/admin-pages/class-product-edit-admin-page.php:325 msgid "E.g. https://contactus.page.com" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:324 +#: inc/admin-pages/class-product-edit-admin-page.php:326 msgid "This will be used on the pricing table CTA button." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:333 +#: inc/admin-pages/class-product-edit-admin-page.php:335 +msgid "Minimum Price" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:337 +msgid "The minimum amount customers can pay. Leave at 0 for truly \"pay what you want\"." +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:347 +msgid "Suggested Price" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:349 +msgid "A suggested price shown as the default value in the price input." +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:359 +msgid "Recurring Mode" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:360 +msgid "Control whether customers can choose between one-time and recurring payments." +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:363 +msgid "Customer Chooses (One-time or Recurring)" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:364 +msgid "Force Recurring Only" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:365 +msgid "Force One-time Only" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:378 +msgid "The billing period for recurring PWYW payments. Only applies when recurring is enabled." +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:404 +msgid "Day(s)" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:405 +msgid "Week(s)" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:406 +msgid "Month(s)" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:407 +msgid "Year(s)" +msgstr "" + +#: inc/admin-pages/class-product-edit-admin-page.php:414 msgid "Is Recurring?" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:334 +#: inc/admin-pages/class-product-edit-admin-page.php:415 msgid "Check this if this product has a recurring charge." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:352 -#: inc/admin-pages/class-product-edit-admin-page.php:353 -#: inc/admin-pages/class-product-edit-admin-page.php:368 +#: inc/admin-pages/class-product-edit-admin-page.php:433 +#: inc/admin-pages/class-product-edit-admin-page.php:434 +#: inc/admin-pages/class-product-edit-admin-page.php:449 #: inc/list-tables/class-membership-list-table-widget.php:265 #: inc/list-tables/class-membership-list-table.php:180 #: inc/list-tables/class-product-list-table.php:256 @@ -3923,171 +4073,171 @@ msgstr "" msgid "Price" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:430 +#: inc/admin-pages/class-product-edit-admin-page.php:511 msgid "Offer Trial" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:431 +#: inc/admin-pages/class-product-edit-admin-page.php:512 msgid "Check if you want to add a trial period to this product." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:443 +#: inc/admin-pages/class-product-edit-admin-page.php:524 msgid "Trial" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:472 +#: inc/admin-pages/class-product-edit-admin-page.php:553 msgid "Add Setup Fee?" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:473 +#: inc/admin-pages/class-product-edit-admin-page.php:554 msgid "Check if you want to add a setup fee." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:492 +#: inc/admin-pages/class-product-edit-admin-page.php:573 #: inc/list-tables/class-product-list-table.php:257 msgid "Setup Fee" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:493 +#: inc/admin-pages/class-product-edit-admin-page.php:574 msgid "The setup fee will be added to the first charge, in addition to the regular price of the product." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:520 +#: inc/admin-pages/class-product-edit-admin-page.php:601 msgid "Use this option to manually enable or disable this product for new sign-ups." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:530 -#: inc/admin-pages/class-product-edit-admin-page.php:535 +#: inc/admin-pages/class-product-edit-admin-page.php:611 +#: inc/admin-pages/class-product-edit-admin-page.php:616 msgid "Product Image" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:536 +#: inc/admin-pages/class-product-edit-admin-page.php:617 msgid "This image is used on product list tables and other places." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:624 +#: inc/admin-pages/class-product-edit-admin-page.php:705 msgid "General product options such as product slug, type, etc." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:633 +#: inc/admin-pages/class-product-edit-admin-page.php:714 #: inc/list-tables/class-membership-line-item-list-table.php:115 msgid "Product Slug" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:634 +#: inc/admin-pages/class-product-edit-admin-page.php:715 msgid "e.g. premium" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:635 +#: inc/admin-pages/class-product-edit-admin-page.php:716 msgid "This serves as a id to the product in a number of different contexts." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:637 +#: inc/admin-pages/class-product-edit-admin-page.php:718 msgid "Lowercase alpha-numeric characters with dashes or underlines. No spaces allowed." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:646 -#: inc/admin-pages/class-product-edit-admin-page.php:647 +#: inc/admin-pages/class-product-edit-admin-page.php:727 +#: inc/admin-pages/class-product-edit-admin-page.php:728 msgid "Product Type" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:648 +#: inc/admin-pages/class-product-edit-admin-page.php:729 msgid "Different product types have different options." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:657 +#: inc/admin-pages/class-product-edit-admin-page.php:738 msgid "Customer Role" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:658 +#: inc/admin-pages/class-product-edit-admin-page.php:739 msgid "Select the role Ultimate Multisite should use when adding the user to their newly created site." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:675 +#: inc/admin-pages/class-product-edit-admin-page.php:756 msgid "Up & Downgrades" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:676 +#: inc/admin-pages/class-product-edit-admin-page.php:757 msgid "Settings related to upgrade and downgrade flows." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:682 +#: inc/admin-pages/class-product-edit-admin-page.php:763 msgid "Plan Group" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:683 +#: inc/admin-pages/class-product-edit-admin-page.php:764 msgid "Add related plans to the same group to have them show up as upgrade/downgrade paths." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:684 -#: inc/admin-pages/class-product-edit-admin-page.php:696 +#: inc/admin-pages/class-product-edit-admin-page.php:765 +#: inc/admin-pages/class-product-edit-admin-page.php:777 msgid "Type and press enter to search and/or add." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:687 +#: inc/admin-pages/class-product-edit-admin-page.php:768 msgid "Select Group" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:694 +#: inc/admin-pages/class-product-edit-admin-page.php:775 msgid "Product Order" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:695 +#: inc/admin-pages/class-product-edit-admin-page.php:776 msgid "Plans are shown in the order determined by this parameter, from the lowest to the highest." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:702 +#: inc/admin-pages/class-product-edit-admin-page.php:783 msgid "Offer Add-ons" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:703 +#: inc/admin-pages/class-product-edit-admin-page.php:784 msgid "Search for a package or service" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:704 -msgid "This products will be offered inside upgrade/downgrade forms as order bumps." +#: inc/admin-pages/class-product-edit-admin-page.php:785 +msgid "These products will be offered inside upgrade/downgrade forms as order bumps." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:726 +#: inc/admin-pages/class-product-edit-admin-page.php:807 msgid "Price Variations" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:727 +#: inc/admin-pages/class-product-edit-admin-page.php:808 msgid "Discounts for longer membership commitments." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:736 +#: inc/admin-pages/class-product-edit-admin-page.php:817 msgid "Enable Price Variations" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:737 +#: inc/admin-pages/class-product-edit-admin-page.php:818 msgid "Price Variations are an easy way to offer discounted prices for longer subscription commitments." msgstr "" #. translators: 1 is the price, 2 is the duration and 3 the duration unit -#: inc/admin-pages/class-product-edit-admin-page.php:746 +#: inc/admin-pages/class-product-edit-admin-page.php:827 #, php-format msgid "A discounted price of %1$s will be used when memberships are created with the recurrence of %2$s %3$s(s) instead of the regular period." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:769 +#: inc/admin-pages/class-product-edit-admin-page.php:850 #: inc/checkout/signup-fields/class-signup-field-period-selection.php:222 msgid "Duration" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:781 +#: inc/admin-pages/class-product-edit-admin-page.php:862 msgid "Period" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:805 +#: inc/admin-pages/class-product-edit-admin-page.php:886 msgid "New Price" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:818 +#: inc/admin-pages/class-product-edit-admin-page.php:899 msgid "Add new Price Variation" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:837 +#: inc/admin-pages/class-product-edit-admin-page.php:918 #: inc/list-tables/class-line-item-list-table.php:216 #: inc/tax/class-dashboard-taxes-tab.php:63 #: inc/tax/class-dashboard-taxes-tab.php:151 @@ -4097,102 +4247,102 @@ msgstr "" msgid "Taxes" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:838 +#: inc/admin-pages/class-product-edit-admin-page.php:919 msgid "Tax settings for your products." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:847 +#: inc/admin-pages/class-product-edit-admin-page.php:928 msgid "Enable this if you plan to collect taxes for this product." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:855 +#: inc/admin-pages/class-product-edit-admin-page.php:936 msgid "Tax Category" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:856 +#: inc/admin-pages/class-product-edit-admin-page.php:937 msgid "Select the product tax category." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:868 -#: inc/admin-pages/class-product-edit-admin-page.php:911 +#: inc/admin-pages/class-product-edit-admin-page.php:949 +#: inc/admin-pages/class-product-edit-admin-page.php:992 msgid "Site Templates" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:869 +#: inc/admin-pages/class-product-edit-admin-page.php:950 msgid "Limit which site templates are available for this particular template." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:880 +#: inc/admin-pages/class-product-edit-admin-page.php:961 msgid "Allow Site Templates" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:881 +#: inc/admin-pages/class-product-edit-admin-page.php:962 msgid "Toggle this option on to allow this plan to use Site Templates. If this option is disabled, sign-ups on this plan will get a default WordPress site." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:891 -#: inc/admin-pages/class-product-edit-admin-page.php:892 +#: inc/admin-pages/class-product-edit-admin-page.php:972 +#: inc/admin-pages/class-product-edit-admin-page.php:973 msgid "Site Template Selection Mode" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:893 +#: inc/admin-pages/class-product-edit-admin-page.php:974 msgid "Select the type of limitation you want to apply." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:894 +#: inc/admin-pages/class-product-edit-admin-page.php:975 msgid "\"Default\" will follow the settings of the checkout form: if you have a template selection field in there, all the templates selected will show up. If no field is present, then a default WordPress site will be created.

\"Assign Site Template\" forces new accounts with this plan to use a particular template site (this option removes the template selection field from the signup, if one exists).

Finally, \"Choose Available Site Templates\", overrides the templates selected on the checkout form with the templates selected here, while also giving you the chance of pre-select a template to be used as default." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:897 +#: inc/admin-pages/class-product-edit-admin-page.php:978 msgid "Default - Allow All Site Templates" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:898 +#: inc/admin-pages/class-product-edit-admin-page.php:979 msgid "Assign Site Template" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:899 +#: inc/admin-pages/class-product-edit-admin-page.php:980 msgid "Choose Available Site Templates" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:912 +#: inc/admin-pages/class-product-edit-admin-page.php:993 msgid "Select the Site Template to assign." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:912 +#: inc/admin-pages/class-product-edit-admin-page.php:993 msgid "Customize the access level of each Site Template below." msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:954 -#: inc/admin-pages/class-product-edit-admin-page.php:965 -#: inc/admin-pages/class-product-edit-admin-page.php:1002 +#: inc/admin-pages/class-product-edit-admin-page.php:1035 +#: inc/admin-pages/class-product-edit-admin-page.php:1046 +#: inc/admin-pages/class-product-edit-admin-page.php:1083 msgid "Edit Product" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:954 -#: inc/admin-pages/class-product-edit-admin-page.php:1003 +#: inc/admin-pages/class-product-edit-admin-page.php:1035 +#: inc/admin-pages/class-product-edit-admin-page.php:1084 #: views/memberships/product-list.php:17 #: views/memberships/product-list.php:21 msgid "Add new Product" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:983 +#: inc/admin-pages/class-product-edit-admin-page.php:1064 msgid "Click to copy Shareable Link" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:1004 +#: inc/admin-pages/class-product-edit-admin-page.php:1085 msgid "Product updated with success!" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:1005 +#: inc/admin-pages/class-product-edit-admin-page.php:1086 msgid "Enter Product Name" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:1007 +#: inc/admin-pages/class-product-edit-admin-page.php:1088 msgid "Save Product" msgstr "" -#: inc/admin-pages/class-product-edit-admin-page.php:1009 +#: inc/admin-pages/class-product-edit-admin-page.php:1090 msgid "Delete Product" msgstr "" @@ -4322,8 +4472,8 @@ msgid "This action cannot be undone. Make sure you have a backup of your current msgstr "" #: inc/admin-pages/class-settings-admin-page.php:711 -#: inc/class-settings.php:1608 -#: inc/class-settings.php:1621 +#: inc/class-settings.php:1624 +#: inc/class-settings.php:1637 msgid "Import Settings" msgstr "" @@ -4595,7 +4745,7 @@ msgid "Tell your customers what this site is about." msgstr "" #: inc/admin-pages/class-site-edit-admin-page.php:358 -#: inc/class-settings.php:1207 +#: inc/class-settings.php:1223 msgid "Site Options" msgstr "" @@ -4746,9 +4896,9 @@ msgid "This name will be used as the site title." msgstr "" #: inc/admin-pages/class-site-edit-admin-page.php:677 -#: inc/ui/class-site-actions-element.php:445 -#: inc/ui/class-site-actions-element.php:548 -#: inc/ui/class-site-actions-element.php:549 +#: inc/ui/class-site-actions-element.php:472 +#: inc/ui/class-site-actions-element.php:575 +#: inc/ui/class-site-actions-element.php:576 msgid "Delete Site" msgstr "" @@ -4891,7 +5041,7 @@ msgstr "" #: inc/admin-pages/class-system-info-admin-page.php:479 #: inc/admin-pages/class-system-info-admin-page.php:484 #: inc/admin-pages/class-system-info-admin-page.php:489 -#: inc/class-settings.php:1726 +#: inc/class-settings.php:1742 msgid "Disabled" msgstr "" @@ -5573,6 +5723,62 @@ msgstr "" msgid "The customer id sent does not correspond to a valid customer." msgstr "" +#: inc/apis/class-settings-endpoint.php:73 +msgid "The setting key to retrieve." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:104 +msgid "The setting key to update." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:110 +msgid "The new value for the setting." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:161 +msgid "This setting is protected and cannot be retrieved via the API." +msgstr "" + +#. translators: %s is the setting key +#: inc/apis/class-settings-endpoint.php:177 +#, php-format +msgid "Setting \"%s\" not found." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:217 +msgid "No valid settings provided. Please provide a \"settings\" object with key-value pairs." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:245 +msgid "No valid settings to update after filtering." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:292 +msgid "The \"value\" parameter is required." +msgstr "" + +#. translators: %s is the setting key +#: inc/apis/class-settings-endpoint.php:309 +#, php-format +msgid "Failed to update setting \"%s\"." +msgstr "" + +#. translators: %s is the setting key +#: inc/apis/class-settings-endpoint.php:348 +#, php-format +msgid "Setting \"%s\" is protected and cannot be modified via the API." +msgstr "" + +#. translators: %s is the setting key +#: inc/apis/class-settings-endpoint.php:363 +#, php-format +msgid "Invalid setting key format: \"%s\"." +msgstr "" + +#: inc/apis/class-settings-endpoint.php:444 +msgid "An object containing setting key-value pairs to update." +msgstr "" + #: inc/apis/schemas/broadcast-create.php:23 #: inc/apis/schemas/broadcast-update.php:23 #: inc/apis/schemas/checkout-form-create.php:83 @@ -6755,177 +6961,183 @@ msgstr "" msgid "Limit response to specific fields. Defaults to id, name" msgstr "" -#: inc/checkout/class-cart.php:564 +#: inc/checkout/class-cart.php:594 msgid "The payment in question was not found." msgstr "" -#: inc/checkout/class-cart.php:591 +#: inc/checkout/class-cart.php:621 msgid "You are not allowed to modify this payment." msgstr "" -#: inc/checkout/class-cart.php:602 -#: inc/checkout/class-cart.php:750 +#: inc/checkout/class-cart.php:632 +#: inc/checkout/class-cart.php:780 msgid "The membership in question was not found." msgstr "" -#: inc/checkout/class-cart.php:691 +#: inc/checkout/class-cart.php:721 msgid "The payment in question has an invalid status." msgstr "" -#: inc/checkout/class-cart.php:770 +#: inc/checkout/class-cart.php:800 #: inc/gateways/class-base-stripe-gateway.php:269 msgid "You are not allowed to modify this membership." msgstr "" -#: inc/checkout/class-cart.php:802 -#: inc/checkout/class-cart.php:824 -#: inc/checkout/class-cart.php:946 +#: inc/checkout/class-cart.php:832 +#: inc/checkout/class-cart.php:854 +#: inc/checkout/class-cart.php:976 msgid "This cart proposes no changes to the current membership." msgstr "" #. translators: %1$d: current number of posts, %2$s: post type name, %3$d: posts quota, %4$s: post type name, %5$d: number of posts to be deleted, %6$s: post type name. -#: inc/checkout/class-cart.php:966 +#: inc/checkout/class-cart.php:996 #, php-format msgid "Your site currently has %1$d %2$s but the new plan is limited to %3$d %4$s. You must trash %5$d %6$s before you can downgrade your plan." msgstr "" -#: inc/checkout/class-cart.php:992 +#: inc/checkout/class-cart.php:1022 msgid "This new plan does NOT support custom domains. You must remove all custom domains before you can downgrade your plan." msgstr "" #. translators: %1$d: current number of custom domains, %2$s: 'custom domain' or 'custom domains', %3$d: domain limit, %4$s: 'custom domain' or 'custom domains', %5$d: number of domains to be removed, %6$s: 'custom domain' or 'custom domains'. -#: inc/checkout/class-cart.php:1000 +#: inc/checkout/class-cart.php:1030 #, php-format msgid "Your site currently has %1$d %2$s but the new plan is limited to %3$d %4$s. You must remove %5$d %6$s before you can downgrade your plan." msgstr "" -#: inc/checkout/class-cart.php:1002 -#: inc/checkout/class-cart.php:1004 -#: inc/checkout/class-cart.php:1006 +#: inc/checkout/class-cart.php:1032 +#: inc/checkout/class-cart.php:1034 +#: inc/checkout/class-cart.php:1036 msgid "custom domains" msgstr "" -#: inc/checkout/class-cart.php:1002 -#: inc/checkout/class-cart.php:1004 -#: inc/checkout/class-cart.php:1006 +#: inc/checkout/class-cart.php:1032 +#: inc/checkout/class-cart.php:1034 +#: inc/checkout/class-cart.php:1036 msgid "custom domain" msgstr "" #. Translators: Placeholder receives the recurring period description -#: inc/checkout/class-cart.php:1080 +#: inc/checkout/class-cart.php:1110 #, php-format msgid "You already have an active %s agreement." msgstr "" -#: inc/checkout/class-cart.php:1101 +#: inc/checkout/class-cart.php:1131 msgid "Scheduled Swap Credit" msgstr "" -#: inc/checkout/class-cart.php:1102 +#: inc/checkout/class-cart.php:1132 msgid "Swap scheduled to next billing cycle." msgstr "" -#: inc/checkout/class-cart.php:1303 +#: inc/checkout/class-cart.php:1333 msgid "Prorated amount based on the previous membership." msgstr "" #. translators: %s is the coupon code being used, all-caps. e.g. PROMO10OFF -#: inc/checkout/class-cart.php:1339 +#: inc/checkout/class-cart.php:1369 #, php-format -msgid "The code %s do not exist or is no longer valid." +msgid "The code %s does not exist or is no longer valid." msgstr "" #. translators: two intervals -#: inc/checkout/class-cart.php:1420 +#: inc/checkout/class-cart.php:1450 #, php-format msgid "Interval %1$s and %2$s do not match." msgstr "" -#: inc/checkout/class-cart.php:1583 +#: inc/checkout/class-cart.php:1613 msgid "The product you are trying to add does not exist." msgstr "" -#: inc/checkout/class-cart.php:1602 +#: inc/checkout/class-cart.php:1632 msgid "The product you are trying to add does not exist for the selected duration." msgstr "" -#: inc/checkout/class-cart.php:1617 +#: inc/checkout/class-cart.php:1647 msgid "There's already a plan in this membership." msgstr "" #. translators: respectively, product name, duration, and duration unit. -#: inc/checkout/class-cart.php:1684 +#: inc/checkout/class-cart.php:1714 #, php-format msgid "%1$s does not have a valid price variation for that billing period (every %2$s %3$s(s)) and was not added to the cart." msgstr "" +#. translators: %1$s is the product name, %2$s is the minimum amount formatted as currency +#: inc/checkout/class-cart.php:1739 +#, php-format +msgid "The amount for %1$s must be at least %2$s." +msgstr "" + #. translators: placeholder is the product name. -#: inc/checkout/class-cart.php:1748 +#: inc/checkout/class-cart.php:1836 #, php-format msgid "Signup Fee for %s" msgstr "" #. translators: placeholder is the product name. -#: inc/checkout/class-cart.php:1748 +#: inc/checkout/class-cart.php:1836 #, php-format msgid "Signup Credit for %s" msgstr "" -#: inc/checkout/class-checkout-pages.php:108 +#: inc/checkout/class-checkout-pages.php:117 msgid "Ultimate Multisite Compatibility Mode" msgstr "" -#: inc/checkout/class-checkout-pages.php:109 +#: inc/checkout/class-checkout-pages.php:118 msgid "Toggle this option on if Ultimate Multisite elements are not loading correctly or at all." msgstr "" -#: inc/checkout/class-checkout-pages.php:188 +#: inc/checkout/class-checkout-pages.php:197 msgid "Error: The password you entered is incorrect." msgstr "" -#: inc/checkout/class-checkout-pages.php:220 +#: inc/checkout/class-checkout-pages.php:229 #: inc/integrations/host-providers/class-closte-host-provider.php:292 msgid "Something went wrong" msgstr "" #. translators: %1$s and %2$s are HTML tags -#: inc/checkout/class-checkout-pages.php:424 +#: inc/checkout/class-checkout-pages.php:433 #, php-format msgid "Your email address is not yet verified. Your site %1$s will only be activated %2$s after your email address is verified. Check your inbox and verify your email address." msgstr "" -#: inc/checkout/class-checkout-pages.php:428 +#: inc/checkout/class-checkout-pages.php:437 msgid "Resend verification email →" msgstr "" -#: inc/checkout/class-checkout-pages.php:633 +#: inc/checkout/class-checkout-pages.php:642 msgid "Ultimate Multisite - Register Page" msgstr "" -#: inc/checkout/class-checkout-pages.php:634 +#: inc/checkout/class-checkout-pages.php:643 msgid "Ultimate Multisite - Login Page" msgstr "" -#: inc/checkout/class-checkout-pages.php:635 +#: inc/checkout/class-checkout-pages.php:644 msgid "Ultimate Multisite - Site Blocked Page" msgstr "" -#: inc/checkout/class-checkout-pages.php:636 +#: inc/checkout/class-checkout-pages.php:645 msgid "Ultimate Multisite - Membership Update Page" msgstr "" -#: inc/checkout/class-checkout-pages.php:637 +#: inc/checkout/class-checkout-pages.php:646 msgid "Ultimate Multisite - New Site Page" msgstr "" -#: inc/checkout/class-checkout.php:752 -#: inc/checkout/class-checkout.php:760 -#: inc/checkout/class-checkout.php:2349 -#: inc/checkout/class-checkout.php:2355 +#: inc/checkout/class-checkout.php:754 +#: inc/checkout/class-checkout.php:762 +#: inc/checkout/class-checkout.php:2402 +#: inc/checkout/class-checkout.php:2408 msgid "Payment gateway not registered." msgstr "" -#: inc/checkout/class-checkout.php:1067 +#: inc/checkout/class-checkout.php:1069 msgid "The email address you entered is already in use." msgstr "" @@ -6933,99 +7145,99 @@ msgstr "" msgid "Something wrong happened while attempting to save the customer billing address" msgstr "" -#: inc/checkout/class-checkout.php:1674 +#: inc/checkout/class-checkout.php:1676 msgid "Invalid request" msgstr "" -#: inc/checkout/class-checkout.php:1686 +#: inc/checkout/class-checkout.php:1688 msgid "Too many requests. Please try again later." msgstr "" -#: inc/checkout/class-checkout.php:1728 +#: inc/checkout/class-checkout.php:1730 msgid "Please provide both username/email and password." msgstr "" -#: inc/checkout/class-checkout.php:1741 +#: inc/checkout/class-checkout.php:1743 msgid "Too many login attempts. Please try again in a few minutes." msgstr "" -#: inc/checkout/class-checkout.php:1770 +#: inc/checkout/class-checkout.php:1772 msgid "Invalid username or password." msgstr "" -#: inc/checkout/class-checkout.php:1788 +#: inc/checkout/class-checkout.php:1790 msgid "Login successful!" msgstr "" -#: inc/checkout/class-checkout.php:1811 +#: inc/checkout/class-checkout.php:1813 msgid "The item was added!" msgstr "" -#: inc/checkout/class-checkout.php:1812 +#: inc/checkout/class-checkout.php:1814 msgid "The Password entered is too weak." msgstr "" -#: inc/checkout/class-checkout.php:1813 +#: inc/checkout/class-checkout.php:1815 msgid "Password is required" msgstr "" -#: inc/checkout/class-checkout.php:1814 +#: inc/checkout/class-checkout.php:1816 msgid "Login failed. Please try again." msgstr "" -#: inc/checkout/class-checkout.php:1815 +#: inc/checkout/class-checkout.php:1817 msgid "Logging in..." msgstr "" -#: inc/checkout/class-checkout.php:1816 +#: inc/checkout/class-checkout.php:1818 #: views/checkout/partials/inline-login-prompt.php:17 msgid "Already have an account?" msgstr "" -#: inc/checkout/class-checkout.php:1817 +#: inc/checkout/class-checkout.php:1819 #: views/checkout/partials/inline-login-prompt.php:59 msgid "Sign in" msgstr "" -#: inc/checkout/class-checkout.php:1818 +#: inc/checkout/class-checkout.php:1820 #: views/checkout/partials/inline-login-prompt.php:51 msgid "Forgot password?" msgstr "" -#: inc/checkout/class-checkout.php:1819 +#: inc/checkout/class-checkout.php:1821 #: inc/list-tables/class-payment-list-table.php:111 #: views/dashboard-widgets/current-membership.php:315 msgid "Cancel" msgstr "" -#: inc/checkout/class-checkout.php:2121 +#: inc/checkout/class-checkout.php:2174 msgid "Password confirmation" msgstr "" -#: inc/checkout/class-checkout.php:2122 +#: inc/checkout/class-checkout.php:2175 msgid "Email confirmation" msgstr "" -#: inc/checkout/class-checkout.php:2123 +#: inc/checkout/class-checkout.php:2176 msgid "Template ID" msgstr "" -#: inc/checkout/class-checkout.php:2124 +#: inc/checkout/class-checkout.php:2177 msgid "Valid password" msgstr "" -#: inc/checkout/class-checkout.php:2126 +#: inc/checkout/class-checkout.php:2179 msgid "Payment Gateway" msgstr "" #. translators: %s payment id. -#: inc/checkout/class-checkout.php:2318 +#: inc/checkout/class-checkout.php:2371 #, php-format msgid "Payment (%s) not found." msgstr "" #. translators: %s is the membership ID -#: inc/checkout/class-checkout.php:2453 +#: inc/checkout/class-checkout.php:2506 #, php-format msgid "Checkout failed for customer %s: " msgstr "" @@ -7040,8 +7252,8 @@ msgstr "" #: inc/checkout/class-legacy-checkout.php:252 #: inc/models/class-membership.php:941 -#: inc/models/class-product.php:695 -#: inc/models/class-product.php:776 +#: inc/models/class-product.php:747 +#: inc/models/class-product.php:839 #: views/checkout/templates/pricing-table/legacy.php:163 #: views/legacy/signup/pricing-table/plan.php:29 #: views/legacy/signup/pricing-table/plan.php:48 @@ -7963,24 +8175,24 @@ msgid "Minimal" msgstr "" #. translators: %s the url for login. -#: inc/class-addon-repository.php:166 +#: inc/class-addon-repository.php:172 #, php-format msgid "You must Connect to UltimateMultisite.com first." msgstr "" -#: inc/class-addon-repository.php:185 +#: inc/class-addon-repository.php:191 msgid "403 Access Denied returned from server. Ensure you have an active subscription for this addon." msgstr "" -#: inc/class-addon-repository.php:189 +#: inc/class-addon-repository.php:195 msgid "Failed to connect to the update server. Please try again later." msgstr "" -#: inc/class-addon-repository.php:235 +#: inc/class-addon-repository.php:241 msgid "Successfully connected your site to UltimateMultisite.com." msgstr "" -#: inc/class-addon-repository.php:244 +#: inc/class-addon-repository.php:250 msgid "Failed to authenticate with UltimateMultisite.com." msgstr "" @@ -8363,7 +8575,7 @@ msgstr "" #: inc/class-orphaned-tables-manager.php:140 #: inc/class-orphaned-users-manager.php:133 -#: inc/class-settings.php:1645 +#: inc/class-settings.php:1661 msgid "Warning:" msgstr "" @@ -8647,7 +8859,7 @@ msgid "Currency Options" msgstr "" #: inc/class-settings.php:636 -#: inc/class-settings.php:1368 +#: inc/class-settings.php:1384 msgid "The following options affect how prices are displayed on the frontend, the backend and in reports." msgstr "" @@ -8698,627 +8910,627 @@ msgstr "" msgid "Number of Decimals" msgstr "" -#: inc/class-settings.php:719 -#: inc/class-settings.php:720 +#: inc/class-settings.php:715 +#: views/settings/widget-settings-body.php:278 +#: views/settings/widget-settings-body.php:283 +msgid "Help Improve Ultimate Multisite" +msgstr "" + +#. translators: %s is a link to the privacy policy +#: inc/class-settings.php:718 +#, php-format +msgid "Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more." +msgstr "" + +#: inc/class-settings.php:735 +#: inc/class-settings.php:736 msgid "Login & Registration" msgstr "" -#: inc/class-settings.php:729 +#: inc/class-settings.php:745 msgid "Login and Registration Options" msgstr "" -#: inc/class-settings.php:730 +#: inc/class-settings.php:746 msgid "Options related to registration and login behavior." msgstr "" -#: inc/class-settings.php:739 +#: inc/class-settings.php:755 msgid "Enable Registration" msgstr "" -#: inc/class-settings.php:740 +#: inc/class-settings.php:756 msgid "Turning this toggle off will disable registration in all checkout forms across the network." msgstr "" -#: inc/class-settings.php:750 +#: inc/class-settings.php:766 msgid "Email verification" msgstr "" -#: inc/class-settings.php:751 +#: inc/class-settings.php:767 msgid "Controls if email verification is required during registration. If set, sites will not be created until the customer email verification status is changed to verified." msgstr "" -#: inc/class-settings.php:754 +#: inc/class-settings.php:770 msgid "Never require email verification" msgstr "" -#: inc/class-settings.php:755 +#: inc/class-settings.php:771 msgid "Only for free plans" msgstr "" -#: inc/class-settings.php:756 +#: inc/class-settings.php:772 msgid "Always require email verification" msgstr "" -#: inc/class-settings.php:777 +#: inc/class-settings.php:793 msgid "Default Registration Page" msgstr "" -#: inc/class-settings.php:778 -#: inc/class-settings.php:810 -#: inc/class-settings.php:962 -#: inc/class-settings.php:1219 +#: inc/class-settings.php:794 +#: inc/class-settings.php:826 +#: inc/class-settings.php:978 +#: inc/class-settings.php:1235 msgid "Search pages on the main site..." msgstr "" -#: inc/class-settings.php:779 -#: inc/class-settings.php:963 -#: inc/class-settings.php:1220 +#: inc/class-settings.php:795 +#: inc/class-settings.php:979 +#: inc/class-settings.php:1236 msgid "Only published pages on the main site are available for selection, and you need to make sure they contain a [wu_checkout] shortcode." msgstr "" -#: inc/class-settings.php:797 +#: inc/class-settings.php:813 msgid "Use Custom Login Page" msgstr "" -#: inc/class-settings.php:798 +#: inc/class-settings.php:814 msgid "Turn this toggle on to select a custom page to be used as the login page." msgstr "" -#: inc/class-settings.php:809 +#: inc/class-settings.php:825 msgid "Default Login Page" msgstr "" -#: inc/class-settings.php:811 +#: inc/class-settings.php:827 msgid "Only published pages on the main site are available for selection, and you need to make sure they contain a [wu_login_form] shortcode." msgstr "" -#: inc/class-settings.php:831 +#: inc/class-settings.php:847 msgid "Obfuscate the Original Login URL (wp-login.php)" msgstr "" -#: inc/class-settings.php:832 +#: inc/class-settings.php:848 msgid "If this option is enabled, we will display a 404 error when a user tries to access the original wp-login.php link. This is useful to prevent brute-force attacks." msgstr "" -#: inc/class-settings.php:845 +#: inc/class-settings.php:861 msgid "Use Sub-site logo on Login Page" msgstr "" -#: inc/class-settings.php:846 +#: inc/class-settings.php:862 msgid "Toggle this option to replace the WordPress logo on the sub-site login page with the logo set for that sub-site. If unchecked, the network logo will be used instead." msgstr "" -#: inc/class-settings.php:859 -msgid "Force Synchronous Site Publication " +#: inc/class-settings.php:875 +msgid "Force Synchronous Site Publication" msgstr "" -#: inc/class-settings.php:860 +#: inc/class-settings.php:876 msgid "By default, when a new pending site needs to be converted into a real network site, the publishing process happens via Job Queue, asynchronously. Enable this option to force the publication to happen in the same request as the signup. Be careful, as this can cause timeouts depending on the size of the site templates being copied." msgstr "" -#: inc/class-settings.php:870 +#: inc/class-settings.php:886 msgid "Password Strength" msgstr "" -#: inc/class-settings.php:871 +#: inc/class-settings.php:887 msgid "Configure password strength requirements for user registration." msgstr "" -#: inc/class-settings.php:880 +#: inc/class-settings.php:896 msgid "Minimum Password Strength" msgstr "" -#: inc/class-settings.php:881 +#: inc/class-settings.php:897 msgid "Set the minimum password strength required during registration and password reset. \"Super Strong\" requires at least 12 characters, including uppercase, lowercase, numbers, and special characters." msgstr "" -#: inc/class-settings.php:885 +#: inc/class-settings.php:901 msgid "Medium" msgstr "" -#: inc/class-settings.php:886 +#: inc/class-settings.php:902 msgid "Strong" msgstr "" -#: inc/class-settings.php:887 +#: inc/class-settings.php:903 msgid "Super Strong (12+ chars, mixed case, numbers, symbols)" msgstr "" -#: inc/class-settings.php:896 -#: inc/class-settings.php:1663 -#: inc/class-settings.php:1664 +#: inc/class-settings.php:912 +#: inc/class-settings.php:1679 +#: inc/class-settings.php:1680 msgid "Other Options" msgstr "" -#: inc/class-settings.php:897 +#: inc/class-settings.php:913 msgid "Other registration-related options." msgstr "" -#: inc/class-settings.php:906 +#: inc/class-settings.php:922 msgid "Default Role" msgstr "" -#: inc/class-settings.php:907 +#: inc/class-settings.php:923 msgid "Set the role to be applied to the user during the signup process." msgstr "" -#: inc/class-settings.php:918 +#: inc/class-settings.php:934 msgid "Add Users to the Main Site as well?" msgstr "" -#: inc/class-settings.php:919 +#: inc/class-settings.php:935 msgid "Enabling this option will also add the user to the main site of your network." msgstr "" -#: inc/class-settings.php:929 +#: inc/class-settings.php:945 msgid "Add to Main Site with Role..." msgstr "" -#: inc/class-settings.php:930 +#: inc/class-settings.php:946 msgid "Select the role Ultimate Multisite should use when adding the user to the main site of your network. Be careful." msgstr "" -#: inc/class-settings.php:961 +#: inc/class-settings.php:977 msgid "Default Membership Update Page" msgstr "" -#: inc/class-settings.php:981 +#: inc/class-settings.php:997 msgid "Block Frontend Access" msgstr "" -#: inc/class-settings.php:982 +#: inc/class-settings.php:998 msgid "Block the frontend access of network sites after a membership is no longer active." msgstr "" -#: inc/class-settings.php:983 +#: inc/class-settings.php:999 msgid "By default, if a user does not pay and the account goes inactive, only the admin panel will be blocked, but the user's site will still be accessible on the frontend. If enabled, this option will also block frontend access in those cases." msgstr "" -#: inc/class-settings.php:993 +#: inc/class-settings.php:1009 msgid "Frontend Block Grace Period" msgstr "" -#: inc/class-settings.php:994 +#: inc/class-settings.php:1010 msgid "Select the number of days Ultimate Multisite should wait after the membership goes inactive before blocking the frontend access. Leave 0 to block immediately after the membership becomes inactive." msgstr "" -#: inc/class-settings.php:1008 +#: inc/class-settings.php:1024 msgid "Frontend Block Page" msgstr "" -#: inc/class-settings.php:1009 +#: inc/class-settings.php:1025 msgid "Select a page on the main site to redirect user if access is blocked" msgstr "" -#: inc/class-settings.php:1029 +#: inc/class-settings.php:1045 msgid "Enable Multiple Memberships per Customer" msgstr "" -#: inc/class-settings.php:1030 +#: inc/class-settings.php:1046 msgid "Enabling this option will allow your users to create more than one membership." msgstr "" -#: inc/class-settings.php:1040 +#: inc/class-settings.php:1056 msgid "Enable Multiple Sites per Membership" msgstr "" -#: inc/class-settings.php:1041 +#: inc/class-settings.php:1057 msgid "Enabling this option will allow your customers to create more than one site. You can limit how many sites your users can create in a per plan basis." msgstr "" -#: inc/class-settings.php:1051 +#: inc/class-settings.php:1067 msgid "Block Sites on Downgrade" msgstr "" -#: inc/class-settings.php:1052 +#: inc/class-settings.php:1068 msgid "Choose how Ultimate Multisite should handle client sites above their plan quota on downgrade." msgstr "" -#: inc/class-settings.php:1056 +#: inc/class-settings.php:1072 msgid "Keep sites as is (do nothing)" msgstr "" -#: inc/class-settings.php:1057 +#: inc/class-settings.php:1073 msgid "Block only frontend access" msgstr "" -#: inc/class-settings.php:1058 +#: inc/class-settings.php:1074 msgid "Block only backend access" msgstr "" -#: inc/class-settings.php:1059 +#: inc/class-settings.php:1075 msgid "Block both frontend and backend access" msgstr "" -#: inc/class-settings.php:1071 +#: inc/class-settings.php:1087 msgid "Move Posts on Downgrade" msgstr "" -#: inc/class-settings.php:1072 +#: inc/class-settings.php:1088 msgid "Select how you want to handle the posts above the quota on downgrade. This will apply to all post types with quotas set." msgstr "" -#: inc/class-settings.php:1076 +#: inc/class-settings.php:1092 msgid "Keep posts as is (do nothing)" msgstr "" -#: inc/class-settings.php:1077 +#: inc/class-settings.php:1093 msgid "Move posts above the new quota to the Trash" msgstr "" -#: inc/class-settings.php:1078 +#: inc/class-settings.php:1094 msgid "Mark posts above the new quota as Drafts" msgstr "" -#: inc/class-settings.php:1088 +#: inc/class-settings.php:1104 msgid "Emulated Post Types" msgstr "" -#: inc/class-settings.php:1089 +#: inc/class-settings.php:1105 msgid "Emulates the registering of a custom post type to be able to create limits for it without having to activate plugins on the main site." msgstr "" -#: inc/class-settings.php:1098 +#: inc/class-settings.php:1114 msgid "By default, Ultimate Multisite only allows super admins to limit post types that are registered on the main site. This makes sense from a technical stand-point but it also forces you to have plugins network-activated in order to be able to set limitations for their custom post types. Using this option, you can emulate the registering of a post type. This will register them on the main site and allow you to create limits for them on your products." msgstr "" -#: inc/class-settings.php:1109 +#: inc/class-settings.php:1125 msgid "Add the first post type using the button below." msgstr "" -#: inc/class-settings.php:1143 +#: inc/class-settings.php:1159 msgid "Post Type Slug" msgstr "" -#: inc/class-settings.php:1144 +#: inc/class-settings.php:1160 msgid "e.g. product" msgstr "" -#: inc/class-settings.php:1153 +#: inc/class-settings.php:1169 msgid "Post Type Label" msgstr "" -#: inc/class-settings.php:1154 +#: inc/class-settings.php:1170 msgid "e.g. Products" msgstr "" -#: inc/class-settings.php:1170 +#: inc/class-settings.php:1186 msgid "+ Add Post Type" msgstr "" -#: inc/class-settings.php:1208 +#: inc/class-settings.php:1224 msgid "Configure certain aspects of how network Sites behave." msgstr "" -#: inc/class-settings.php:1218 +#: inc/class-settings.php:1234 msgid "Default New Site Page" msgstr "" -#: inc/class-settings.php:1238 +#: inc/class-settings.php:1254 msgid "Enable Visits Limitation & Counting" msgstr "" -#: inc/class-settings.php:1239 +#: inc/class-settings.php:1255 msgid "Enabling this option will add visits limitation settings to the plans and add the functionality necessary to count site visits on the front-end." msgstr "" -#: inc/class-settings.php:1249 +#: inc/class-settings.php:1265 msgid "Enable Screenshot Generator" msgstr "" -#: inc/class-settings.php:1250 +#: inc/class-settings.php:1266 msgid "With this option is enabled, Ultimate Multisite will take a screenshot for every newly created site on your network and set the resulting image as that site's featured image. This features requires a valid license key to work and it is not supported for local sites." msgstr "" -#: inc/class-settings.php:1260 +#: inc/class-settings.php:1276 msgid "WordPress Features" msgstr "" -#: inc/class-settings.php:1261 +#: inc/class-settings.php:1277 msgid "Override default WordPress settings for network Sites." msgstr "" -#: inc/class-settings.php:1270 +#: inc/class-settings.php:1286 msgid "Enable Plugins Menu" msgstr "" -#: inc/class-settings.php:1271 +#: inc/class-settings.php:1287 msgid "Do you want to let users on the network to have access to the Plugins page, activating plugins for their sites? If this option is disabled, the customer will not be able to manage the site plugins." msgstr "" -#: inc/class-settings.php:1272 +#: inc/class-settings.php:1288 msgid "You can select which plugins the user will be able to use for each plan." msgstr "" -#: inc/class-settings.php:1282 +#: inc/class-settings.php:1298 msgid "Add New Users" msgstr "" -#: inc/class-settings.php:1283 +#: inc/class-settings.php:1299 msgid "Allow site administrators to add new users to their site via the \"Users → Add New\" page." msgstr "" -#: inc/class-settings.php:1284 +#: inc/class-settings.php:1300 msgid "You can limit the number of users allowed for each plan." msgstr "" -#: inc/class-settings.php:1294 +#: inc/class-settings.php:1310 msgid "Site Template Options" msgstr "" -#: inc/class-settings.php:1295 +#: inc/class-settings.php:1311 msgid "Configure certain aspects of how Site Templates behave." msgstr "" -#: inc/class-settings.php:1304 +#: inc/class-settings.php:1320 msgid "Allow Template Switching" msgstr "" -#: inc/class-settings.php:1305 +#: inc/class-settings.php:1321 msgid "Enabling this option will add an option on your client's dashboard to switch their site template to another one available on the catalog of available templates. The data is lost after a switch as the data from the new template is copied over." msgstr "" -#: inc/class-settings.php:1315 +#: inc/class-settings.php:1331 msgid "Allow Users to use their own Sites as Templates" msgstr "" -#: inc/class-settings.php:1316 +#: inc/class-settings.php:1332 msgid "Enabling this option will add the user own sites to the template screen, allowing them to create a new site based on the content and customizations they made previously." msgstr "" -#: inc/class-settings.php:1329 +#: inc/class-settings.php:1345 msgid "Copy Media on Template Duplication?" msgstr "" -#: inc/class-settings.php:1330 +#: inc/class-settings.php:1346 msgid "Checking this option will copy the media uploaded on the template site to the newly created site. This can be overridden on each of the plans." msgstr "" -#: inc/class-settings.php:1340 +#: inc/class-settings.php:1356 msgid "Prevent Search Engines from indexing Site Templates" msgstr "" -#: inc/class-settings.php:1341 +#: inc/class-settings.php:1357 msgid "Checking this option will discourage search engines from indexing all the Site Templates on your network." msgstr "" -#: inc/class-settings.php:1367 +#: inc/class-settings.php:1383 msgid "Payment Settings" msgstr "" -#: inc/class-settings.php:1378 +#: inc/class-settings.php:1394 msgid "Force Auto-Renew" msgstr "" -#: inc/class-settings.php:1379 +#: inc/class-settings.php:1395 msgid "Enable this option if you want to make sure memberships are created with auto-renew activated whenever the selected gateway supports it. Disabling this option will show an auto-renew option during checkout." msgstr "" -#: inc/class-settings.php:1390 +#: inc/class-settings.php:1406 msgid "Allow Trials without Payment Method" msgstr "" -#: inc/class-settings.php:1391 +#: inc/class-settings.php:1407 msgid "By default, Ultimate Multisite asks customers to add a payment method on sign-up even if a trial period is present. Enable this option to only ask for a payment method when the trial period is over." msgstr "" -#: inc/class-settings.php:1402 +#: inc/class-settings.php:1418 msgid "Send Invoice on Payment Confirmation" msgstr "" -#: inc/class-settings.php:1403 +#: inc/class-settings.php:1419 msgid "Enabling this option will attach a PDF invoice (marked paid) with the payment confirmation email. This option does not apply to the Manual Gateway, which sends invoices regardless of this option." msgstr "" -#: inc/class-settings.php:1404 +#: inc/class-settings.php:1420 msgid "The invoice files will be saved on the wp-content/uploads/wu-invoices folder." msgstr "" -#: inc/class-settings.php:1414 +#: inc/class-settings.php:1430 msgid "Invoice Numbering Scheme" msgstr "" -#: inc/class-settings.php:1415 +#: inc/class-settings.php:1431 msgid "What should Ultimate Multisite use as the invoice number?" msgstr "" -#: inc/class-settings.php:1420 +#: inc/class-settings.php:1436 msgid "Payment Reference Code" msgstr "" -#: inc/class-settings.php:1421 +#: inc/class-settings.php:1437 msgid "Sequential Number" msgstr "" -#: inc/class-settings.php:1430 +#: inc/class-settings.php:1446 msgid "Next Invoice Number" msgstr "" -#: inc/class-settings.php:1431 +#: inc/class-settings.php:1447 msgid "This number will be used as the invoice number for the next invoice generated on the system. It is incremented by one every time a new invoice is created. You can change it and save it to reset the invoice sequential number to a specific value." msgstr "" -#: inc/class-settings.php:1445 +#: inc/class-settings.php:1461 msgid "Invoice Number Prefix" msgstr "" -#: inc/class-settings.php:1446 +#: inc/class-settings.php:1462 msgid "INV00" msgstr "" #. translators: %%YEAR%%, %%MONTH%%, and %%DAY%% are placeholders but are replaced before shown to the user but are used as examples. -#: inc/class-settings.php:1448 +#: inc/class-settings.php:1464 #, php-format msgid "Use %%YEAR%%, %%MONTH%%, and %%DAY%% to create a dynamic placeholder. E.g. %%YEAR%%-%%MONTH%%-INV will become %s." msgstr "" -#: inc/class-settings.php:1462 +#: inc/class-settings.php:1478 #: inc/ui/class-jumper.php:209 msgid "Payment Gateways" msgstr "" -#: inc/class-settings.php:1463 +#: inc/class-settings.php:1479 msgid "Activate and configure the installed payment gateways in this section." msgstr "" -#: inc/class-settings.php:1478 -#: inc/class-settings.php:1479 +#: inc/class-settings.php:1494 +#: inc/class-settings.php:1495 #: inc/list-tables/class-broadcast-list-table.php:481 #: inc/list-tables/class-email-list-table.php:40 #: inc/ui/class-jumper.php:211 msgid "Emails" msgstr "" -#: inc/class-settings.php:1494 -#: inc/class-settings.php:1495 +#: inc/class-settings.php:1510 +#: inc/class-settings.php:1511 msgid "Domain Mapping" msgstr "" -#: inc/class-settings.php:1510 -#: inc/class-settings.php:1511 +#: inc/class-settings.php:1526 +#: inc/class-settings.php:1527 msgid "Single Sign-On" msgstr "" -#: inc/class-settings.php:1536 +#: inc/class-settings.php:1552 msgid "Hosting or Panel Providers" msgstr "" -#: inc/class-settings.php:1537 +#: inc/class-settings.php:1553 msgid "Configure and manage the integration with your Hosting or Panel Provider." msgstr "" -#: inc/class-settings.php:1553 +#: inc/class-settings.php:1569 msgid "Import/Export" msgstr "" -#: inc/class-settings.php:1554 +#: inc/class-settings.php:1570 msgid "Export your settings to a JSON file or import settings from a previously exported file." msgstr "" -#: inc/class-settings.php:1565 -#: inc/class-settings.php:1590 +#: inc/class-settings.php:1581 +#: inc/class-settings.php:1606 msgid "Export Settings" msgstr "" -#: inc/class-settings.php:1566 +#: inc/class-settings.php:1582 msgid "Download all your Ultimate Multisite settings as a JSON file for backup or migration purposes." msgstr "" -#: inc/class-settings.php:1578 +#: inc/class-settings.php:1594 msgid "The exported file will contain all ultimate multisite settings defined on this page. This includes general settings, payment gateway configurations, email settings, domain mapping settings, and all other plugin configurations. It does not include products, sites, domains, customers and other entities." msgstr "" -#: inc/class-settings.php:1609 +#: inc/class-settings.php:1625 msgid "Upload a previously exported JSON file to restore settings." msgstr "" -#: inc/class-settings.php:1622 +#: inc/class-settings.php:1638 msgid "Import and Replace All Settings" msgstr "" -#: inc/class-settings.php:1646 +#: inc/class-settings.php:1662 msgid "Importing settings will replace ALL current settings with the values from the uploaded file. This action cannot be undone. We recommend exporting your current settings as a backup before importing." msgstr "" -#: inc/class-settings.php:1674 +#: inc/class-settings.php:1690 msgid "Miscellaneous" msgstr "" -#: inc/class-settings.php:1675 +#: inc/class-settings.php:1691 msgid "Other options that do not fit anywhere else." msgstr "" -#: inc/class-settings.php:1686 +#: inc/class-settings.php:1702 msgid "Hide UI Tours" msgstr "" -#: inc/class-settings.php:1687 +#: inc/class-settings.php:1703 msgid "The UI tours showed by Ultimate Multisite should permanently hide themselves after being seen but if they persist for whatever reason, toggle this option to force them into their viewed state - which will prevent them from showing up again." msgstr "" -#: inc/class-settings.php:1699 +#: inc/class-settings.php:1715 msgid "Disable \"Hover to Zoom\"" msgstr "" -#: inc/class-settings.php:1700 +#: inc/class-settings.php:1716 msgid "By default, Ultimate Multisite adds a \"hover to zoom\" feature, allowing network admins to see larger version of site screenshots and other images across the UI in full-size when hovering over them. You can disable that feature here. Preview tags like the above are not affected." msgstr "" -#: inc/class-settings.php:1710 +#: inc/class-settings.php:1726 msgid "Logging" msgstr "" -#: inc/class-settings.php:1711 +#: inc/class-settings.php:1727 msgid "Log Ultimate Multisite data. This is useful for debugging purposes." msgstr "" -#: inc/class-settings.php:1720 +#: inc/class-settings.php:1736 msgid "Logging Level" msgstr "" -#: inc/class-settings.php:1721 +#: inc/class-settings.php:1737 msgid "Select the level of logging you want to use." msgstr "" -#: inc/class-settings.php:1725 +#: inc/class-settings.php:1741 msgid "PHP Default" msgstr "" -#: inc/class-settings.php:1727 +#: inc/class-settings.php:1743 msgid "Errors Only" msgstr "" -#: inc/class-settings.php:1728 +#: inc/class-settings.php:1744 msgid "Everything" msgstr "" -#: inc/class-settings.php:1737 -#: views/settings/widget-settings-body.php:278 -#: views/settings/widget-settings-body.php:283 -msgid "Help Improve Ultimate Multisite" -msgstr "" - -#. translators: %s is a link to the privacy policy -#: inc/class-settings.php:1740 -#, php-format -msgid "Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. Learn more." -msgstr "" - -#: inc/class-settings.php:1753 +#: inc/class-settings.php:1754 msgid "Change the plugin and wordpress behavior." msgstr "" -#: inc/class-settings.php:1768 +#: inc/class-settings.php:1769 msgid "Run Migration Again" msgstr "" -#: inc/class-settings.php:1770 +#: inc/class-settings.php:1771 msgid "Rerun the Migration Wizard if you experience data-loss after migrate." msgstr "" -#: inc/class-settings.php:1773 +#: inc/class-settings.php:1774 msgid "Important: This process can have unexpected behavior with your current Ultimo models.
We recommend that you create a backup before continue." msgstr "" -#: inc/class-settings.php:1776 +#: inc/class-settings.php:1777 msgid "Migrate" msgstr "" -#: inc/class-settings.php:1799 +#: inc/class-settings.php:1800 msgid "Security Mode" msgstr "" #. Translators: Placeholder adds the security mode key and current site url with query string -#: inc/class-settings.php:1801 +#: inc/class-settings.php:1802 #, php-format msgid "Only Ultimate Multisite and other must-use plugins will run on your WordPress install while this option is enabled.
Important: Copy the following URL to disable security mode if something goes wrong and this page becomes unavailable:%2$s
" msgstr "" -#: inc/class-settings.php:1812 +#: inc/class-settings.php:1813 msgid "Remove Data on Uninstall" msgstr "" -#: inc/class-settings.php:1813 +#: inc/class-settings.php:1814 msgid "Remove all saved data for Ultimate Multisite when the plugin is uninstalled." msgstr "" @@ -9508,12 +9720,6 @@ msgstr "" msgid "Quarterly" msgstr "" -#: inc/compat/class-legacy-shortcodes.php:362 -#: inc/models/class-checkout-form.php:851 -#: views/legacy/signup/pricing-table/frequency-selector.php:33 -msgid "Yearly" -msgstr "" - #: inc/compat/class-legacy-shortcodes.php:372 #: inc/list-tables/class-product-list-table.php:313 #: inc/models/class-checkout-form.php:551 @@ -14926,20 +15132,6 @@ msgstr "" msgid "We were not able to find a user with the given user_id." msgstr "" -#: inc/functions/date.php:115 -#: views/dashboard-statistics/widget-tax-by-day.php:19 -#: views/dashboard-statistics/widget-tax-by-day.php:52 -msgid "Day" -msgstr "" - -#: inc/functions/date.php:118 -msgid "Month" -msgstr "" - -#: inc/functions/date.php:121 -msgid "Year" -msgstr "" - #. translators: %s: date. #: inc/functions/date.php:148 #, php-format @@ -15343,7 +15535,8 @@ msgstr "" #: inc/managers/class-form-manager.php:499 #: inc/ui/class-billing-info-element.php:383 #: inc/ui/class-current-site-element.php:487 -#: inc/ui/class-site-actions-element.php:698 +#: inc/ui/class-site-actions-element.php:725 +#: inc/ui/class-site-actions-element.php:841 msgid "Something went wrong." msgstr "" @@ -15682,7 +15875,7 @@ msgid "Oops! Your %1$s and %2$s don’t match." msgstr "" #: inc/helpers/class-validator.php:97 -#: inc/models/class-discount-code.php:670 +#: inc/models/class-discount-code.php:745 #: views/base/filter.php:123 #: views/base/filter.php:131 #: views/wizards/host-integrations/cloudflare-instructions.php:14 @@ -17344,7 +17537,7 @@ msgstr "" #: inc/list-tables/class-membership-list-table.php:139 #: inc/list-tables/class-product-list-table.php:146 #: inc/models/class-membership.php:874 -#: inc/models/class-product.php:838 +#: inc/models/class-product.php:901 #, php-format msgid "every %2$s" msgid_plural "every %1$s %2$s" @@ -17356,7 +17549,7 @@ msgstr[1] "" #: inc/list-tables/class-membership-list-table.php:147 #: inc/list-tables/class-product-list-table.php:154 #: inc/models/class-membership.php:926 -#: inc/models/class-product.php:795 +#: inc/models/class-product.php:858 #, php-format msgid "for %s cycle" msgid_plural "for %s cycles" @@ -18322,9 +18515,9 @@ msgstr "" #: inc/managers/class-membership-manager.php:387 #: inc/managers/class-payment-manager.php:336 #: inc/managers/class-payment-manager.php:381 -#: inc/ui/class-site-actions-element.php:594 -#: inc/ui/class-site-actions-element.php:940 -#: inc/ui/class-site-actions-element.php:1132 +#: inc/ui/class-site-actions-element.php:621 +#: inc/ui/class-site-actions-element.php:1112 +#: inc/ui/class-site-actions-element.php:1304 msgid "An unexpected error happened." msgstr "" @@ -18556,25 +18749,29 @@ msgstr "" msgid "none" msgstr "" -#: inc/models/class-discount-code.php:478 -#: inc/models/class-discount-code.php:500 #: inc/models/class-discount-code.php:508 -#: inc/models/class-discount-code.php:526 +#: inc/models/class-discount-code.php:530 +#: inc/models/class-discount-code.php:538 +#: inc/models/class-discount-code.php:555 msgid "This coupon code is not valid." msgstr "" -#: inc/models/class-discount-code.php:485 +#: inc/models/class-discount-code.php:515 msgid "This discount code was already redeemed the maximum amount of times allowed." msgstr "" +#: inc/models/class-discount-code.php:567 +msgid "This coupon code is not valid for the selected billing period." +msgstr "" + #. translators: placeholder is the value off. Can be wither $X.XX or X% -#: inc/models/class-discount-code.php:651 +#: inc/models/class-discount-code.php:726 #, php-format msgid "%1$s OFF on Subscriptions" msgstr "" #. translators: placeholder is the value off. Can be wither $X.XX or X% -#: inc/models/class-discount-code.php:665 +#: inc/models/class-discount-code.php:740 #, php-format msgid "%1$s OFF on Setup Fees" msgstr "" @@ -18622,7 +18819,7 @@ msgstr "" #. translators: %1$s is the formatted price, %2$s the duration, and %3$s the duration unit (day, week, month, etc) #: inc/models/class-membership.php:915 -#: inc/models/class-product.php:784 +#: inc/models/class-product.php:847 #, php-format msgid "%1$s every %3$s" msgid_plural "%1$s every %2$s %3$s" @@ -18631,7 +18828,7 @@ msgstr[1] "" #. translators: %1$s is the formatted price of the product #: inc/models/class-membership.php:935 -#: inc/models/class-product.php:804 +#: inc/models/class-product.php:867 #, php-format msgid "%1$s one time payment" msgstr "" @@ -18654,17 +18851,27 @@ msgstr "" msgid "Processed on %s" msgstr "" -#: inc/models/class-product.php:772 +#. translators: %s is the minimum amount formatted as currency +#: inc/models/class-product.php:759 +#, php-format +msgid "From %s" +msgstr "" + +#: inc/models/class-product.php:762 +msgid "Name Your Price" +msgstr "" + +#: inc/models/class-product.php:835 msgid "Contact us" msgstr "" #. translators: %1$s is the formatted price of the setup fee -#: inc/models/class-product.php:812 +#: inc/models/class-product.php:875 #, php-format msgid "Setup Fee of %1$s" msgstr "" -#: inc/models/class-product.php:833 +#: inc/models/class-product.php:896 msgid "one-time payment" msgstr "" @@ -18819,7 +19026,7 @@ msgid "Regular" msgstr "" #: inc/tax/class-tax.php:231 -#: inc/ui/class-site-actions-element.php:185 +#: inc/ui/class-site-actions-element.php:193 #: views/limitations/plugin-selector.php:80 msgid "Default" msgstr "" @@ -18914,7 +19121,7 @@ msgstr "" #: inc/traits/trait-wp-ultimo-settings-deprecated.php:79 #: inc/traits/trait-wp-ultimo-settings-deprecated.php:80 -#: inc/ui/class-site-actions-element.php:1050 +#: inc/ui/class-site-actions-element.php:1222 #: views/dashboard-statistics/widget-countries.php:95 msgid "Other" msgstr "" @@ -19137,8 +19344,8 @@ msgid "How many columns to use." msgstr "" #: inc/ui/class-current-membership-element.php:368 -#: inc/ui/class-site-actions-element.php:842 -#: inc/ui/class-site-actions-element.php:993 +#: inc/ui/class-site-actions-element.php:1014 +#: inc/ui/class-site-actions-element.php:1165 msgid "Membership not selected." msgstr "" @@ -19148,12 +19355,12 @@ msgstr "" #: inc/ui/class-current-membership-element.php:380 #: inc/ui/class-current-membership-element.php:535 -#: inc/ui/class-site-actions-element.php:503 -#: inc/ui/class-site-actions-element.php:600 -#: inc/ui/class-site-actions-element.php:848 -#: inc/ui/class-site-actions-element.php:950 -#: inc/ui/class-site-actions-element.php:999 -#: inc/ui/class-site-actions-element.php:1142 +#: inc/ui/class-site-actions-element.php:530 +#: inc/ui/class-site-actions-element.php:627 +#: inc/ui/class-site-actions-element.php:1020 +#: inc/ui/class-site-actions-element.php:1122 +#: inc/ui/class-site-actions-element.php:1171 +#: inc/ui/class-site-actions-element.php:1314 msgid "You are not allowed to do this." msgstr "" @@ -19733,172 +19940,225 @@ msgid "Toggle to show/hide the password link." msgstr "" #: inc/ui/class-site-actions-element.php:163 -msgid "Show Change Default Site" +msgid "Show Change Email" msgstr "" #: inc/ui/class-site-actions-element.php:164 -msgid "Toggle to show/hide the change default site link." +msgid "Toggle to show/hide the change email link." msgstr "" #: inc/ui/class-site-actions-element.php:171 -msgid "Show Change Payment Method" +msgid "Show Change Default Site" msgstr "" #: inc/ui/class-site-actions-element.php:172 +msgid "Toggle to show/hide the change default site link." +msgstr "" + +#: inc/ui/class-site-actions-element.php:179 +msgid "Show Change Payment Method" +msgstr "" + +#: inc/ui/class-site-actions-element.php:180 msgid "Toggle to show/hide the option to cancel the current payment method." msgstr "" -#: inc/ui/class-site-actions-element.php:193 +#: inc/ui/class-site-actions-element.php:201 msgid "Redirect After Delete" msgstr "" -#: inc/ui/class-site-actions-element.php:195 +#: inc/ui/class-site-actions-element.php:203 msgid "The page to redirect user after delete current site." msgstr "" -#: inc/ui/class-site-actions-element.php:381 +#: inc/ui/class-site-actions-element.php:399 msgid "Change Site Template" msgstr "" -#: inc/ui/class-site-actions-element.php:394 -#: inc/ui/class-site-actions-element.php:772 +#: inc/ui/class-site-actions-element.php:412 +#: inc/ui/class-site-actions-element.php:944 msgid "Change Default Site" msgstr "" -#: inc/ui/class-site-actions-element.php:403 +#: inc/ui/class-site-actions-element.php:421 msgid "Change Password" msgstr "" -#: inc/ui/class-site-actions-element.php:414 +#: inc/ui/class-site-actions-element.php:430 +#: inc/ui/class-site-actions-element.php:805 +msgid "Change Email" +msgstr "" + +#: inc/ui/class-site-actions-element.php:441 msgid "Cancel Current Payment Method" msgstr "" -#: inc/ui/class-site-actions-element.php:465 -#: inc/ui/class-site-actions-element.php:1089 -#: inc/ui/class-site-actions-element.php:1090 +#: inc/ui/class-site-actions-element.php:492 +#: inc/ui/class-site-actions-element.php:1261 +#: inc/ui/class-site-actions-element.php:1262 msgid "Cancel Membership" msgstr "" -#: inc/ui/class-site-actions-element.php:497 +#: inc/ui/class-site-actions-element.php:524 msgid "Site not selected." msgstr "" -#: inc/ui/class-site-actions-element.php:540 +#: inc/ui/class-site-actions-element.php:567 msgid "Confirm Site Deletion" msgstr "" -#: inc/ui/class-site-actions-element.php:646 +#: inc/ui/class-site-actions-element.php:673 +#: inc/ui/class-site-actions-element.php:789 msgid "Current Password" msgstr "" -#: inc/ui/class-site-actions-element.php:647 -#: inc/ui/class-site-actions-element.php:652 -#: inc/ui/class-site-actions-element.php:657 +#: inc/ui/class-site-actions-element.php:674 +#: inc/ui/class-site-actions-element.php:679 +#: inc/ui/class-site-actions-element.php:684 +#: inc/ui/class-site-actions-element.php:790 msgid "******" msgstr "" -#: inc/ui/class-site-actions-element.php:651 +#: inc/ui/class-site-actions-element.php:678 msgid "New Password" msgstr "" -#: inc/ui/class-site-actions-element.php:658 +#: inc/ui/class-site-actions-element.php:685 msgid "Confirm New Password" msgstr "" -#: inc/ui/class-site-actions-element.php:662 +#: inc/ui/class-site-actions-element.php:689 msgid "Reset Password" msgstr "" -#: inc/ui/class-site-actions-element.php:706 +#: inc/ui/class-site-actions-element.php:733 +#: inc/ui/class-site-actions-element.php:849 msgid "Your current password is wrong." msgstr "" -#: inc/ui/class-site-actions-element.php:715 +#: inc/ui/class-site-actions-element.php:742 msgid "The new password must be at least 6 characters long." msgstr "" -#: inc/ui/class-site-actions-element.php:721 +#: inc/ui/class-site-actions-element.php:748 msgid "New passwords do not match." msgstr "" -#: inc/ui/class-site-actions-element.php:762 +#: inc/ui/class-site-actions-element.php:781 +msgid "Current Email" +msgstr "" + +#: inc/ui/class-site-actions-element.php:791 +msgid "Enter your password to confirm this change." +msgstr "" + +#: inc/ui/class-site-actions-element.php:795 +msgid "New Email" +msgstr "" + +#: inc/ui/class-site-actions-element.php:796 +#: inc/ui/class-site-actions-element.php:800 +msgid "newemail@example.com" +msgstr "" + +#: inc/ui/class-site-actions-element.php:801 +msgid "Confirm New Email" +msgstr "" + +#: inc/ui/class-site-actions-element.php:858 +msgid "Please enter a valid email address." +msgstr "" + +#: inc/ui/class-site-actions-element.php:864 +msgid "Email addresses do not match." +msgstr "" + +#: inc/ui/class-site-actions-element.php:870 +msgid "The new email address is the same as your current email." +msgstr "" + +#: inc/ui/class-site-actions-element.php:879 +msgid "This email address is already in use." +msgstr "" + +#: inc/ui/class-site-actions-element.php:934 msgid "Primary Site" msgstr "" -#: inc/ui/class-site-actions-element.php:763 +#: inc/ui/class-site-actions-element.php:935 msgid "Change the primary site of your network." msgstr "" -#: inc/ui/class-site-actions-element.php:824 +#: inc/ui/class-site-actions-element.php:996 msgid "You need to select a new primary site." msgstr "" -#: inc/ui/class-site-actions-element.php:885 +#: inc/ui/class-site-actions-element.php:1057 msgid "Confirm Payment Method Cancellation" msgstr "" -#: inc/ui/class-site-actions-element.php:897 -#: inc/ui/class-site-actions-element.php:898 +#: inc/ui/class-site-actions-element.php:1069 +#: inc/ui/class-site-actions-element.php:1070 msgid "Cancel Payment Method" msgstr "" -#: inc/ui/class-site-actions-element.php:1036 +#: inc/ui/class-site-actions-element.php:1208 msgid "Please tell us why you are cancelling." msgstr "" -#: inc/ui/class-site-actions-element.php:1037 +#: inc/ui/class-site-actions-element.php:1209 msgid "We would love your feedback." msgstr "" -#: inc/ui/class-site-actions-element.php:1043 +#: inc/ui/class-site-actions-element.php:1215 msgid "Select a reason" msgstr "" -#: inc/ui/class-site-actions-element.php:1044 -#: inc/ui/class-site-actions-element.php:1150 +#: inc/ui/class-site-actions-element.php:1216 +#: inc/ui/class-site-actions-element.php:1322 msgid "I no longer need it" msgstr "" -#: inc/ui/class-site-actions-element.php:1045 -#: inc/ui/class-site-actions-element.php:1151 +#: inc/ui/class-site-actions-element.php:1217 +#: inc/ui/class-site-actions-element.php:1323 msgid "It's too expensive" msgstr "" -#: inc/ui/class-site-actions-element.php:1046 -#: inc/ui/class-site-actions-element.php:1152 +#: inc/ui/class-site-actions-element.php:1218 +#: inc/ui/class-site-actions-element.php:1324 msgid "I need more features" msgstr "" -#: inc/ui/class-site-actions-element.php:1047 -#: inc/ui/class-site-actions-element.php:1153 +#: inc/ui/class-site-actions-element.php:1219 +#: inc/ui/class-site-actions-element.php:1325 msgid "Switched to another service" msgstr "" -#: inc/ui/class-site-actions-element.php:1048 -#: inc/ui/class-site-actions-element.php:1154 +#: inc/ui/class-site-actions-element.php:1220 +#: inc/ui/class-site-actions-element.php:1326 msgid "Customer support is less than expected" msgstr "" -#: inc/ui/class-site-actions-element.php:1049 -#: inc/ui/class-site-actions-element.php:1155 +#: inc/ui/class-site-actions-element.php:1221 +#: inc/ui/class-site-actions-element.php:1327 msgid "Too complex" msgstr "" -#: inc/ui/class-site-actions-element.php:1055 +#: inc/ui/class-site-actions-element.php:1227 msgid "Please provide additional details." msgstr "" -#: inc/ui/class-site-actions-element.php:1066 +#: inc/ui/class-site-actions-element.php:1238 msgid "Type CANCEL 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']); + ?> print_html_attributes(); ?>> + html_attr['v-bind:name']); + ?> + + name="id); ?>" type="type); ?>" placeholder="placeholder); ?>" value="value); ?>" print_html_attributes(); ?>> suffix) : ?> diff --git a/views/checkout/templates/pricing-table/list.php b/views/checkout/templates/pricing-table/list.php index 450e4486..487cb816 100644 --- a/views/checkout/templates/pricing-table/list.php +++ b/views/checkout/templates/pricing-table/list.php @@ -55,8 +55,42 @@ class="wu-relative wu-block wu-rounded-lg wu-border wu-border-gray-300 wu-bg-whi
get_recurring_description()); ?>
+ is_pay_what_you_want()) : ?> +
+ +
+ + +
+ + allows_customer_recurring_choice()) : ?> + + + get_pwyw_recurring_mode()) : ?> + + +
+ + diff --git a/views/ui/selectize-templates.php b/views/ui/selectize-templates.php index 64589be1..5fef5c89 100644 --- a/views/ui/selectize-templates.php +++ b/views/ui/selectize-templates.php @@ -301,7 +301,7 @@
-
+
{{ avatar }}