Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions inc/admin-pages/class-product-edit-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,31 @@ protected function get_product_option_sections() {
],
];

$sections['demo-settings'] = [
'title' => __('Demo Settings', 'ultimate-multisite'),
'desc' => __('Configure how this demo product behaves. These settings only apply when the product type is "Demo".', 'ultimate-multisite'),
'icon' => 'dashicons-wu-clock',
'v-show' => 'product_type === "demo"',
'state' => [
'demo_behavior' => $this->get_object()->get_demo_behavior(),
],
'fields' => [
'demo_behavior' => [
'type' => 'select',
'title' => __('Demo Expiry Behavior', 'ultimate-multisite'),
'desc' => __('Choose what happens when the customer\'s demo period ends. <strong>Delete after time</strong> automatically removes the site after the configured duration. <strong>Keep until live</strong> keeps the site indefinitely with the frontend blocked — the customer must explicitly activate it to make it visible to visitors.', 'ultimate-multisite'),
'value' => $this->get_object()->get_demo_behavior(),
'options' => [
'delete_after_time' => __('Delete after time (auto-expire)', 'ultimate-multisite'),
'keep_until_live' => __('Keep until customer goes live', 'ultimate-multisite'),
],
'html_attr' => [
'v-model' => 'demo_behavior',
],
],
],
];

return apply_filters('wu_product_options_sections', $sections, $this->get_object());
}

Expand Down
13 changes: 12 additions & 1 deletion inc/checkout/class-checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,17 @@ protected function maybe_create_site() {
*/
$template_id = apply_filters('wu_checkout_template_id', (int) $this->request_or_session('template_id'), $this->membership, $this);

/*
* Determine the site type based on the product type.
* Demo products create demo sites that auto-expire.
*/
$site_type = Site_Type::CUSTOMER_OWNED;
$plan = $this->order->get_plan();

if ($plan && $plan->get_type() === \WP_Ultimo\Database\Products\Product_Type::DEMO) {
$site_type = Site_Type::DEMO;
}

$site_data = [
'domain' => $d->domain,
'path' => $d->path,
Expand All @@ -1446,7 +1457,7 @@ protected function maybe_create_site() {
'transient' => $transient,
'signup_options' => $this->get_site_meta_fields($form_slug, 'site_option'),
'signup_meta' => $this->get_site_meta_fields($form_slug, 'site_meta'),
'type' => Site_Type::CUSTOMER_OWNED,
'type' => $site_type,
];

return $this->membership->create_pending_site($site_data);
Expand Down
95 changes: 95 additions & 0 deletions inc/class-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -1372,6 +1372,101 @@ public function default_sections(): void {

do_action('wu_settings_site_templates');

/*
* Demo Sites
* Settings for demo/sandbox site functionality.
*/
$this->add_field(
'sites',
'demo_sites_heading',
[
'title' => __('Demo Sites', 'ultimate-multisite'),
'desc' => __('Configure demo/sandbox site behavior. Demo sites are temporary sites that automatically expire and get deleted after a set period.', 'ultimate-multisite'),
'type' => 'header',
]
);

$this->add_field(
'sites',
'demo_duration',
[
'title' => __('Demo Duration', 'ultimate-multisite'),
'desc' => __('How long demo sites should remain active before being automatically deleted. Set to 0 to disable automatic deletion.', 'ultimate-multisite'),
'type' => 'number',
'default' => 2,
'min' => 0,
'html_attr' => [
'style' => 'width: 80px;',
],
]
);
Comment on lines +1389 to +1402
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

0 currently means “expire immediately”, not “disable”.

This field advertises 0 as “disable automatic deletion”, but Site::calculate_demo_expiration() multiplies the configured duration and returns now + 0, so new demo sites become immediately eligible for cleanup. Either handle 0 as “no expiration” downstream or remove 0 from the allowed UI here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-settings.php` around lines 1389 - 1402, Site demo duration of 0 is
currently treated as "now" because Site::calculate_demo_expiration multiplies
the duration, so fix by making calculate_demo_expiration treat a configured
duration of 0 as "no expiration" (return null / no timestamp) instead of
returning now + 0; also update the add_field call for 'sites','demo_duration'
(title/desc/default/min) to either remove the suggestion that 0 disables
deletion or set min to 1 if you prefer to forbid 0 in the UI so the behavior and
UI are consistent.


$this->add_field(
'sites',
'demo_duration_unit',
[
'title' => __('Demo Duration Unit', 'ultimate-multisite'),
'desc' => __('The time unit for demo duration.', 'ultimate-multisite'),
'type' => 'select',
'default' => 'hour',
'options' => [
'hour' => __('Hours', 'ultimate-multisite'),
'day' => __('Days', 'ultimate-multisite'),
'week' => __('Weeks', 'ultimate-multisite'),
],
]
);

$this->add_field(
'sites',
'demo_delete_customer',
[
'title' => __('Delete Customer After Demo Expires', 'ultimate-multisite'),
'desc' => __('When enabled, the customer account will also be deleted when their demo site expires (only if they have no other memberships).', 'ultimate-multisite'),
'type' => 'toggle',
'default' => 0,
]
);

$this->add_field(
'sites',
'demo_expiring_notification',
[
'title' => __('Send Expiration Warning Email', 'ultimate-multisite'),
'desc' => __('When enabled, customers will receive an email notification before their demo site expires.', 'ultimate-multisite'),
'type' => 'toggle',
'default' => 1,
]
);

$this->add_field(
'sites',
'demo_expiring_warning_time',
[
'title' => __('Warning Time Before Expiration', 'ultimate-multisite'),
'desc' => __('How long before the demo expires should the warning email be sent. Uses the same time unit as demo duration.', 'ultimate-multisite'),
'type' => 'number',
'default' => 1,
'min' => 1,
'html_attr' => [
'style' => 'width: 80px;',
],
]
);
Comment on lines +1431 to +1455
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Align the warning settings with the runtime contract.

check_expiring_demo_sites() reads demo_expiring_notification with a runtime default of false, and it interprets demo_expiring_warning_time as hours with a default of 24. Here the UI defaults are enabled/1, and the description says the warning uses the same unit as demo_duration. On a fresh install, the behavior will not match what this screen advertises. Please add these keys to Settings::get_setting_defaults() and either rename this field to hours or convert it using demo_duration_unit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/class-settings.php` around lines 1431 - 1455, The UI defaults for
demo_expiring_notification and demo_expiring_warning_time conflict with runtime
expectations in check_expiring_demo_sites(); add these keys to
Settings::get_setting_defaults() setting demo_expiring_notification => false and
demo_expiring_warning_time => 24, and update the runtime consumer: when
retrieving demo_expiring_warning_time in check_expiring_demo_sites() convert the
value to hours using demo_duration_unit (or alternatively rename the UI field to
explicitly state “hours”) so the UI, defaults, and runtime all agree; reference
add_field('sites','demo_expiring_notification'),
add_field('sites','demo_expiring_warning_time'),
Settings::get_setting_defaults(), check_expiring_demo_sites(), and
demo_duration_unit to locate the changes.


$this->add_field(
'sites',
'demo_go_live_url',
[
'title' => __('Go Live URL', 'ultimate-multisite'),
'desc' => __('The URL customers are sent to when they click "Go Live" on a keep-until-live demo site. Typically this is your checkout form page URL. Leave empty to use the built-in instant activation (no payment collected).', 'ultimate-multisite'),
'type' => 'url',
'default' => '',
]
);

do_action('wu_settings_demo_sites');

/*
* Payment Gateways
* This section holds the Payment Gateways settings of the Ultimate Multisite Plugin.
Expand Down
4 changes: 4 additions & 0 deletions inc/database/products/class-product-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class Product_Type extends Enum {

const SERVICE = 'service';

const DEMO = 'demo';

/**
* Returns an array with values => CSS Classes.
*
Expand All @@ -44,6 +46,7 @@ protected function classes() {
static::PLAN => 'wu-bg-green-200 wu-text-green-700',
static::PACKAGE => 'wu-bg-gray-200 wu-text-blue-700',
static::SERVICE => 'wu-bg-yellow-200 wu-text-yellow-700',
static::DEMO => 'wu-bg-orange-200 wu-text-orange-700',
];
}

Expand All @@ -59,6 +62,7 @@ protected function labels() {
static::PLAN => __('Plan', 'ultimate-multisite'),
static::PACKAGE => __('Package', 'ultimate-multisite'),
static::SERVICE => __('Service', 'ultimate-multisite'),
static::DEMO => __('Demo', 'ultimate-multisite'),
];
}
}
4 changes: 4 additions & 0 deletions inc/database/sites/class-site-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class Site_Type extends Enum {

const MAIN = 'main';

const DEMO = 'demo';

/**
* Returns an array with values => CSS Classes.
*
Expand All @@ -53,6 +55,7 @@ protected function classes() {
static::PENDING => 'wu-bg-purple-200 wu-text-purple-700',
static::EXTERNAL => 'wu-bg-blue-200 wu-text-blue-700',
static::MAIN => 'wu-bg-pink-200 wu-text-pink-700',
static::DEMO => 'wu-bg-orange-200 wu-text-orange-700',
];
}

Expand All @@ -70,6 +73,7 @@ protected function labels() {
static::CUSTOMER_OWNED => __('Customer-Owned', 'ultimate-multisite'),
static::PENDING => __('Pending', 'ultimate-multisite'),
static::MAIN => __('Main Site', 'ultimate-multisite'),
static::DEMO => __('Demo', 'ultimate-multisite'),
];
}
}
5 changes: 4 additions & 1 deletion inc/functions/product.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,14 @@ function wu_is_plan_type(string $type): bool {
* This filter allows addons to register additional product types
* that should be recognized as plans in validation and segregation.
*
* Demo products are included by default as they function like plans
* but create sites with automatic expiration.
*
* @since 2.3.0
* @param array $plan_types Array of product types to treat as plans.
* @return array
*/
$plan_types = apply_filters('wu_plan_product_types', ['plan']);
$plan_types = apply_filters('wu_plan_product_types', ['plan', 'demo']);
Comment on lines +192 to +199
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep plan-like helpers consistent with wu_is_plan_type().

demo is now treated as a plan in cart validation and product segregation, but wu_get_plans() / wu_get_plans_as_options() in this same file still only return type = 'plan'. That lets other UIs still treat demo products as add-ons — for example, Product_Edit_Admin_Page::get_product_option_sections() excludes only wu_get_plans_as_options() from available_addons — and checkout later rejects those combinations as containing multiple plans. Please either include demo in the plan-query helpers or introduce a separate “plan-like” helper and switch callers to it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/functions/product.php` around lines 192 - 199, The plan-query helpers
(wu_get_plans() and wu_get_plans_as_options()) are still only querying type =
'plan' while wu_is_plan_type() and cart validation treat 'demo' as a plan;
update these helpers to include 'demo' (e.g., apply the same $plan_types filter
used earlier: wu_plan_product_types/wu_plan_type) so their queries return both
'plan' and 'demo', or alternatively create a new helper (e.g.,
wu_get_plan_like_products() used by wu_get_plans_as_options() and callers such
as Product_Edit_Admin_Page::get_product_option_sections()) that queries all
types returned by wu_is_plan_type()/the $plan_types filter and switch callers to
use it.


return in_array($type, $plan_types, true);
}
Expand Down
6 changes: 6 additions & 0 deletions inc/list-tables/class-site-list-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ public function get_views() {
'label' => __('Pending', 'ultimate-multisite'),
'count' => 0,
],
'demo' => [
'field' => 'type',
'url' => add_query_arg('type', 'demo'),
'label' => __('Demo', 'ultimate-multisite'),
'count' => 0,
],
];
}

Expand Down
26 changes: 26 additions & 0 deletions inc/managers/class-email-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,32 @@ public function register_all_default_system_emails(): void {
]
);

/*
* Demo Site Expiring - Customer
*/
$this->register_default_system_email(
[
'event' => 'demo_site_expiring',
'slug' => 'demo_site_expiring_customer',
'target' => 'customer',
'title' => __('Your demo site is about to expire', 'ultimate-multisite'),
'content' => wu_get_template_contents('emails/customer/demo-site-expiring'),
]
);

/*
* Demo Site Expiring - Admin
*/
$this->register_default_system_email(
[
'event' => 'demo_site_expiring',
'slug' => 'demo_site_expiring_admin',
'target' => 'admin',
'title' => __('A demo site is about to expire', 'ultimate-multisite'),
'content' => wu_get_template_contents('emails/admin/demo-site-expiring'),
]
);
Comment on lines +536 to +560
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 "demo_site_expiring_(customer|admin)|create_all_system_emails|create_system_email\(|register_all_default_system_emails|wu_get_default_system_emails" .

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 13555


🏁 Script executed:

#!/bin/bash

# Search for where migrator is called and upgrade hooks
rg -n "create_all_system_emails|_install_create_emails|activation|upgrade|migrat" inc/installers/ inc/managers/ --type php | grep -E "(do_action|apply_filters|add_action|register|hook|activate|upgrade)" | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1659


🏁 Script executed:

#!/bin/bash

# Look for activation and upgrade hooks
rg -n "register_activation_hook|wp_version|upgrade_needed|plugins_loaded|admin_init" inc/ --type php | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3055


🏁 Script executed:

#!/bin/bash

# Check class-default-content-installer.php for where _install_create_emails is defined and called
grep -n "_install_create_emails\|public function\|add_action\|do_action" inc/installers/class-default-content-installer.php | head -30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 435


🏁 Script executed:

#!/bin/bash

# Look for Default_Content_Installer usage
rg -n "Default_Content_Installer|new Default_Content_Installer|->get_instance\(\)" inc/ --type php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1052


🏁 Script executed:

#!/bin/bash

# Check the main plugin file for activation/upgrade hooks
git ls-files | grep -E "(ultimate-multisite\.php|wp-ultimo\.php|main\.php)" | head -5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 247


🏁 Script executed:

#!/bin/bash

# Search for version checks and upgrade logic in main plugin files
rg -n "register_activation_hook|register_deactivation_hook|wp_version|get_option.*version" . --type php --max-count=30

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2290


🏁 Script executed:

#!/bin/bash

# Check what on_activation does in class-hooks.php
sed -n '38,150p' inc/class-hooks.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 2625


🏁 Script executed:

#!/bin/bash

# Search for any automatic email creation or upgrade checks
rg -n "on_activation|_run_migration|create_all_system_emails" inc/class-hooks.php -A 20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1662


🏁 Script executed:

#!/bin/bash

# Search for handlers of wu_activation hook
rg -n "add_action.*wu_activation|do_action.*wu_activation" inc/ --type php -A 5 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 406


🏁 Script executed:

#!/bin/bash

# Search for Migrator instantiation and triggering
rg -n "Migrator|_run_migration|migration" inc/class-wp-ultimo.php -A 3 -B 2 | head -50

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash

# Search for Migrator class instantiation and method calls
rg -n "new Migrator|Migrator::get_instance|->run\(\)|->execute\(\)|_run_migration" . --type php | head -40

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 891


🏁 Script executed:

#!/bin/bash

# Check if there's any automatic email seeding for upgrades
rg -n "version_compare|old_version|new_version|update_network_option.*version" inc/managers/class-email-manager.php inc/installers/ --type php | head -20

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 63


🏁 Script executed:

#!/bin/bash

# Look for any upgrade/delta installation logic in core installer
cat inc/installers/class-core-installer.php | head -100

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3222


🏁 Script executed:

#!/bin/bash

# Look for email reset/import/sync functionality
rg -n "reset.*email|import.*email|sync.*email|reseed|backfill" inc/admin-pages/class-email-list-admin-page.php -B 2 -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3577


🏁 Script executed:

#!/bin/bash

# Check the email reset/import modal to see if it backfills missing emails
sed -n '346,370p' inc/admin-pages/class-email-list-admin-page.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 800


🏁 Script executed:

#!/bin/bash

# Search for AJAX handlers for email import/reset
rg -n "handle_reset|handle_import|import_emails|reset_emails" inc/admin-pages/class-email-list-admin-page.php -A 10

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 4053


Ensure new system emails auto-seed on upgraded installations, or document the manual import step.

New demo_site_expiring_* emails won't auto-seed on existing installs. send_system_email() only backfills defaults when the email table is completely empty, so sites with older system emails already created will remain without these new templates. While a manual "Import System Emails" admin feature exists (admin → Email Settings → Import), this requires admin knowledge and action. Consider either:

  1. Automatically creating missing default emails when registering new ones
  2. Adding a setup/migration step that seeds these for existing installs
  3. Prominently documenting the import requirement for upgrade guides

Without intervention, the demo_site_expiring event will silently have no email to send.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/managers/class-email-manager.php` around lines 536 - 560, The new
demo_site_expiring system emails are only registered via
register_default_system_email but won't be seeded into existing installs because
send_system_email only backfills when the emails table is empty; update the
registration flow to ensure missing defaults are created for upgrades by
checking for the existence of each slug (e.g., 'demo_site_expiring_customer' and
'demo_site_expiring_admin') during registration (in the code that calls
register_default_system_email) and insert the default record if missing, or add
a migration/setup routine that runs once on plugin upgrade to call the same
insert logic for any newly introduced events (reference demo_site_expiring,
register_default_system_email and send_system_email to locate the relevant
logic).


do_action('wu_system_emails_after_register');
}

Expand Down
23 changes: 23 additions & 0 deletions inc/managers/class-event-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,29 @@ public function register_all_events(): void {
]
);

/**
* Demo Site Expiring.
*/
wu_register_event_type(
'demo_site_expiring',
[
'name' => __('Demo Site Expiring', 'ultimate-multisite'),
'desc' => __('Fired when a demo site is about to expire, based on the warning time setting.', 'ultimate-multisite'),
'payload' => fn() => array_merge(
wu_generate_event_payload('site'),
wu_generate_event_payload('membership'),
wu_generate_event_payload('customer'),
[
'demo_expires_at' => '2025-12-31 23:59:59',
'demo_time_remaining' => '1 hour',
'site_admin_url' => 'https://example.com/wp-admin/',
'site_url' => 'https://example.com/',
]
),
'deprecated_args' => [],
]
);

$models = $this->models_events;

foreach ($models as $model => $params) {
Expand Down
Loading
Loading