From 96b5969abeed0eb03bf04397b2f3f238b8d96c25 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 7 Mar 2026 09:21:46 -0700 Subject: [PATCH 1/2] add support for demo products --- inc/checkout/class-checkout.php | 13 +- inc/class-settings.php | 84 +++++++ inc/database/products/class-product-type.php | 4 + inc/database/sites/class-site-type.php | 4 + inc/functions/product.php | 5 +- inc/list-tables/class-site-list-table.php | 6 + inc/managers/class-email-manager.php | 26 ++ inc/managers/class-event-manager.php | 23 ++ inc/managers/class-site-manager.php | 243 +++++++++++++++++++ inc/models/class-site.php | 139 +++++++++++ views/emails/admin/demo-site-expiring.php | 92 +++++++ views/emails/customer/demo-site-expiring.php | 53 ++++ 12 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 views/emails/admin/demo-site-expiring.php create mode 100644 views/emails/customer/demo-site-expiring.php diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index b2c8f2389..2433be883 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -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, @@ -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); diff --git a/inc/class-settings.php b/inc/class-settings.php index 1abfe4242..779bb077d 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1372,6 +1372,90 @@ 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;', + ], + ] + ); + + $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;', + ], + ] + ); + + do_action('wu_settings_demo_sites'); + /* * Payment Gateways * This section holds the Payment Gateways settings of the Ultimate Multisite Plugin. diff --git a/inc/database/products/class-product-type.php b/inc/database/products/class-product-type.php index 3c0a95272..eb15c30c9 100644 --- a/inc/database/products/class-product-type.php +++ b/inc/database/products/class-product-type.php @@ -32,6 +32,8 @@ class Product_Type extends Enum { const SERVICE = 'service'; + const DEMO = 'demo'; + /** * Returns an array with values => CSS Classes. * @@ -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', ]; } @@ -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'), ]; } } diff --git a/inc/database/sites/class-site-type.php b/inc/database/sites/class-site-type.php index f66aa39a8..0b227ef04 100644 --- a/inc/database/sites/class-site-type.php +++ b/inc/database/sites/class-site-type.php @@ -38,6 +38,8 @@ class Site_Type extends Enum { const MAIN = 'main'; + const DEMO = 'demo'; + /** * Returns an array with values => CSS Classes. * @@ -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', ]; } @@ -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'), ]; } } diff --git a/inc/functions/product.php b/inc/functions/product.php index 04efc4847..09f3a65fe 100644 --- a/inc/functions/product.php +++ b/inc/functions/product.php @@ -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']); return in_array($type, $plan_types, true); } diff --git a/inc/list-tables/class-site-list-table.php b/inc/list-tables/class-site-list-table.php index ff54262b6..78a15e16c 100644 --- a/inc/list-tables/class-site-list-table.php +++ b/inc/list-tables/class-site-list-table.php @@ -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, + ], ]; } diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index 6857829a1..82ec13053 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -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'), + ] + ); + do_action('wu_system_emails_after_register'); } diff --git a/inc/managers/class-event-manager.php b/inc/managers/class-event-manager.php index 295717f98..5bf5ab986 100644 --- a/inc/managers/class-event-manager.php +++ b/inc/managers/class-event-manager.php @@ -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) { diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 09a58dff5..32d7882f8 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -102,6 +102,15 @@ public function init(): void { add_filter('wpmu_validate_blog_signup', [$this, 'allow_hyphens_in_site_name'], 10, 1); add_action('wu_daily', [$this, 'delete_pending_sites']); + + // Demo site cleanup - runs hourly to check for expired demo sites. + add_action('wu_hourly', [$this, 'check_expired_demo_sites']); + + // Demo site expiring notification - runs hourly to send warning emails. + add_action('wu_hourly', [$this, 'check_expiring_demo_sites']); + + // Async handler for demo site deletion. + add_action('wu_async_delete_demo_site', [$this, 'async_delete_demo_site'], 10, 1); } /** @@ -249,6 +258,23 @@ public function maybe_add_new_site(): void { * @return void */ public function handle_site_published($site, $membership): void { + /* + * If this is a demo site, set the expiration time. + */ + if ($site->is_demo()) { + $expires_at = $site->calculate_demo_expiration(); + $site->set_demo_expires_at($expires_at); + + wu_log_add( + 'demo-sites', + sprintf( + // translators: %1$d is site ID, %2$s is expiration datetime. + __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'), + $site->get_id(), + $expires_at + ) + ); + } $payload = array_merge( wu_generate_event_payload('site', $site), @@ -876,4 +902,221 @@ public function delete_pending_sites(): void { } } } + + /** + * Check for expired demo sites and schedule their deletion. + * + * This method runs hourly via the wu_hourly cron hook. + * It finds all demo sites that have passed their expiration time + * and schedules async deletion for each one. + * + * @since 2.5.0 + * @return void + */ + public function check_expired_demo_sites(): void { + + $demo_sites = Site::get_all_by_type(Site_Type::DEMO); + + if (empty($demo_sites)) { + return; + } + + $current_time = wu_get_current_time('mysql', true); + + foreach ($demo_sites as $site) { + $expires_at = $site->get_meta('wu_demo_expires_at'); + + // Skip sites without expiration set. + if (empty($expires_at)) { + continue; + } + + // Check if the demo has expired. + if ($expires_at <= $current_time) { + wu_enqueue_async_action( + 'wu_async_delete_demo_site', + ['site_id' => $site->get_id()], + 'wu_demo_cleanup' + ); + } + } + } + + /** + * Check for demo sites that are about to expire and send notification emails. + * + * This method runs hourly via the wu_hourly cron hook. + * It finds all demo sites that will expire within the configured warning window + * and fires the demo_site_expiring event for each one. + * + * @since 2.5.0 + * @return void + */ + public function check_expiring_demo_sites(): void { + + // Check if expiration notifications are enabled. + if ( ! wu_get_setting('demo_expiring_notification', false)) { + return; + } + + $demo_sites = Site::get_all_by_type(Site_Type::DEMO); + + if (empty($demo_sites)) { + return; + } + + // Get the warning time in hours. + $warning_hours = (int) wu_get_setting('demo_expiring_warning_time', 24); + + // Calculate warning threshold based on current time. + $current_time = wu_get_current_time('timestamp', true); + $warning_seconds = $warning_hours * HOUR_IN_SECONDS; + + foreach ($demo_sites as $site) { + $expires_at = $site->get_meta('wu_demo_expires_at'); + + // Skip sites without expiration set. + if (empty($expires_at)) { + continue; + } + + // Skip sites already notified. + if ($site->get_meta('wu_demo_expiring_notified')) { + continue; + } + + // Convert expiration to timestamp for comparison. + $expires_timestamp = strtotime($expires_at); + + // Skip if already expired (handled by check_expired_demo_sites). + if ($expires_timestamp <= $current_time) { + continue; + } + + // Calculate time remaining until expiration. + $time_remaining = $expires_timestamp - $current_time; + + // Check if site is within the warning window. + if ($time_remaining > $warning_seconds) { + continue; + } + + // Get associated data for the event payload. + $membership = $site->get_membership(); + $customer = $membership ? $membership->get_customer() : null; + + // Skip if no customer to notify. + if (empty($customer)) { + continue; + } + + // Build human-readable time remaining. + $time_remaining_human = human_time_diff($current_time, $expires_timestamp); + + // Build the event payload. + $payload = [ + 'site' => $site->to_array(), + 'membership' => $membership ? $membership->to_array() : [], + 'customer' => $customer->to_array(), + 'demo_expires_at' => $expires_at, + 'demo_time_remaining' => $time_remaining_human, + 'site_admin_url' => get_admin_url($site->get_blog_id()), + 'site_url' => $site->get_active_site_url(), + ]; + + // Fire the demo_site_expiring event. + wu_do_event('demo_site_expiring', $payload); + + // Mark the site as notified to prevent duplicate emails. + $site->update_meta('wu_demo_expiring_notified', 1); + } + } + + /** + * Async handler to delete a demo site. + * + * This method handles the actual deletion of a demo site, + * including the WordPress blog and associated membership/customer + * data if configured to do so. + * + * @since 2.5.0 + * + * @param int $site_id The site ID to delete. + * @return void + */ + public function async_delete_demo_site($site_id): void { + + $site = wu_get_site($site_id); + + if (empty($site)) { + return; + } + + // Verify it's still a demo site (could have been converted). + if ($site->get_type() !== Site_Type::DEMO) { + return; + } + + // Get associated data before deletion. + $membership = $site->get_membership(); + $customer = $site->get_customer(); + $blog_id = $site->get_blog_id(); + + // Fire pre-deletion hook for extensibility. + do_action('wu_before_demo_site_deleted', $site, $membership, $customer); + + // Delete the WordPress blog if it exists. + if ($blog_id) { + wpmu_delete_blog($blog_id, true); + } + + // Delete the WP Ultimo site record. + $result = $site->delete(); + + // Optionally delete the membership if it only has this demo site. + $delete_membership = apply_filters('wu_demo_site_delete_membership', true, $membership, $site); + + if ($delete_membership && $membership) { + $membership_sites = $membership->get_sites(); + + // Only delete if this was the only site on the membership. + if (empty($membership_sites) || count($membership_sites) === 0) { + $membership->delete(); + } + } + + // Optionally delete the customer if they only had this demo. + $delete_customer = apply_filters('wu_demo_site_delete_customer', wu_get_setting('demo_delete_customer', false), $customer, $site); + + if ($delete_customer && $customer) { + $customer_memberships = $customer->get_memberships(); + + // Only delete if this was their only membership. + if (empty($customer_memberships) || count($customer_memberships) === 0) { + $user_id = $customer->get_user_id(); + + $customer->delete(); + + // Delete the WordPress user as well. + if ($user_id && apply_filters('wu_demo_site_delete_user', true, $user_id)) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + wpmu_delete_user($user_id); + } + } + } + + // Fire post-deletion hook. + do_action('wu_after_demo_site_deleted', $site_id, $blog_id, $membership, $customer); + + // Log the deletion. + wu_log_add( + 'demo-cleanup', + sprintf( + // translators: %1$d is site ID, %2$d is blog ID. + __('Deleted expired demo site #%1$d (blog_id: %2$d)', 'ultimate-multisite'), + $site_id, + $blog_id ?: 0 + ) + ); + } } diff --git a/inc/models/class-site.php b/inc/models/class-site.php index 070976a36..f2bb5a152 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -69,6 +69,13 @@ class Site extends Base_Model implements Limitable, Notable { */ const META_TRANSIENT = 'wu_transient'; + /** + * Meta key for demo expiration timestamp. + * + * @since 2.5.0 + */ + const META_DEMO_EXPIRES_AT = 'wu_demo_expires_at'; + /** DEFAULT WP_SITE COLUMNS */ /** @@ -1417,6 +1424,138 @@ public function get_type_class() { return $type->get_classes(); } + /** + * Check if this is a demo site. + * + * @since 2.5.0 + * @return bool + */ + public function is_demo(): bool { + + return $this->get_type() === Site_Type::DEMO; + } + + /** + * Get the demo expiration date/time. + * + * @since 2.5.0 + * @return string|null MySQL datetime string or null if not set. + */ + public function get_demo_expires_at(): ?string { + + $expires_at = $this->get_meta(self::META_DEMO_EXPIRES_AT); + + return $expires_at ?: null; + } + + /** + * Set the demo expiration date/time. + * + * @since 2.5.0 + * + * @param string $expires_at MySQL datetime string for when the demo expires. + * @return void + */ + public function set_demo_expires_at(string $expires_at): void { + + $this->update_meta(self::META_DEMO_EXPIRES_AT, $expires_at); + } + + /** + * Check if this demo site has expired. + * + * @since 2.5.0 + * @return bool True if expired, false if still active or not a demo. + */ + public function is_demo_expired(): bool { + + if ( ! $this->is_demo()) { + return false; + } + + $expires_at = $this->get_demo_expires_at(); + + if (empty($expires_at)) { + return false; + } + + return $expires_at <= wu_get_current_time('mysql', true); + } + + /** + * Calculate and set demo expiration based on settings. + * + * This method calculates the expiration time using the global + * demo_duration and demo_duration_unit settings. + * + * @since 2.5.0 + * + * @param int|null $duration Optional custom duration. Defaults to settings value. + * @param string|null $duration_unit Optional custom unit (hour, day, week). Defaults to settings value. + * @return string The calculated expiration datetime. + */ + public function calculate_demo_expiration(?int $duration = null, ?string $duration_unit = null): string { + + $duration = $duration ?? (int) wu_get_setting('demo_duration', 2); + $duration_unit = $duration_unit ?? wu_get_setting('demo_duration_unit', 'hour'); + + // Convert to seconds. + $seconds_map = [ + 'hour' => HOUR_IN_SECONDS, + 'day' => DAY_IN_SECONDS, + 'week' => WEEK_IN_SECONDS, + ]; + + $multiplier = $seconds_map[ $duration_unit ] ?? HOUR_IN_SECONDS; + $expires_at = gmdate('Y-m-d H:i:s', time() + ($duration * $multiplier)); + + return $expires_at; + } + + /** + * Get time remaining until demo expiration. + * + * @since 2.5.0 + * @return int|null Seconds remaining, or null if not a demo or no expiration set. + */ + public function get_demo_time_remaining(): ?int { + + if ( ! $this->is_demo()) { + return null; + } + + $expires_at = $this->get_demo_expires_at(); + + if (empty($expires_at)) { + return null; + } + + $remaining = strtotime($expires_at) - time(); + + return max(0, $remaining); + } + + /** + * Get human-readable time remaining for demo. + * + * @since 2.5.0 + * @return string|null Human-readable time string, or null if not applicable. + */ + public function get_demo_time_remaining_human(): ?string { + + $remaining = $this->get_demo_time_remaining(); + + if (null === $remaining) { + return null; + } + + if ($remaining <= 0) { + return __('Expired', 'ultimate-multisite'); + } + + return human_time_diff(time(), time() + $remaining); + } + /** * Adds magic methods to return options. * diff --git a/views/emails/admin/demo-site-expiring.php b/views/emails/admin/demo-site-expiring.php new file mode 100644 index 000000000..f37761a5b --- /dev/null +++ b/views/emails/admin/demo-site-expiring.php @@ -0,0 +1,92 @@ + +

+ +

%1$s will expire in %2$s.', 'ultimate-multisite'), '{{site_title}}', '{{demo_time_remaining}}'), 'pre_user_description'); ?>

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{site_title}} +
+ {{site_id}} +
+ +
+ +
+ {{demo_expires_at}} +
+ {{demo_time_remaining}} +
+ +
+ +

+ + + + + + + + + + + + + + + + + + + + +
+ {{customer_avatar}}
+ {{customer_name}} +
+ {{customer_user_email}} +
+ {{customer_id}} +
+ +
diff --git a/views/emails/customer/demo-site-expiring.php b/views/emails/customer/demo-site-expiring.php new file mode 100644 index 000000000..445790870 --- /dev/null +++ b/views/emails/customer/demo-site-expiring.php @@ -0,0 +1,53 @@ + + +

+ +

%1$s will expire in %2$s.', 'ultimate-multisite'), '{{site_title}}', '{{demo_time_remaining}}'), 'pre_user_description'); ?>

+ +

+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ {{site_title}} +
+ +
+ +
+ {{demo_expires_at}} +
+ {{demo_time_remaining}} +
From 21d31e04965066225861ee78ad94300c2370172d Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 8 Mar 2026 18:37:05 -0600 Subject: [PATCH 2/2] feat: add keep-until-live mode for demo products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new demo expiry behavior where the site persists indefinitely with the frontend blocked until the customer explicitly activates it, as an alternative to the existing auto-delete-after-time behavior. - Product: new demo_behavior meta (delete_after_time | keep_until_live) with get/set methods and is_keep_until_live() helper - Site: is_keep_until_live() delegates to the plan product's behavior - lock_site(): blocks frontend for keep-until-live demo sites; site admins and super admins are allowed through to preview their work - handle_site_published(): skips expiration timer for keep-until-live - Cron checks: skip keep-until-live sites (no auto-deletion/warnings) - add_demo_admin_bar_menu(): shows "Demo Mode / Go Live" in admin bar on the frontend for site admins - handle_go_live_action(): nonce-protected ?wu_go_live=ID handler for instant activation when no external Go Live URL is configured - convert_demo_to_live(): converts DEMO → CUSTOMER_OWNED, clears meta, fires wu_before/after_demo_site_converted hooks - Settings: new demo_go_live_url field to configure the checkout URL customers are sent to when they click "Go Live" - Product edit page: new "Demo Settings" section (visible for demo products) with the Demo Expiry Behavior select field Co-Authored-By: Claude Sonnet 4.6 --- .../class-product-edit-admin-page.php | 25 ++ inc/class-settings.php | 11 + inc/managers/class-site-manager.php | 265 +++++++++++++++++- inc/models/class-product.php | 57 ++++ inc/models/class-site.php | 21 ++ 5 files changed, 367 insertions(+), 12 deletions(-) diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php index b19438f8f..b394ce807 100644 --- a/inc/admin-pages/class-product-edit-admin-page.php +++ b/inc/admin-pages/class-product-edit-admin-page.php @@ -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. Delete after time automatically removes the site after the configured duration. Keep until live 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()); } diff --git a/inc/class-settings.php b/inc/class-settings.php index 779bb077d..f682a6e6b 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1454,6 +1454,17 @@ public function default_sections(): void { ] ); + $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'); /* diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 32d7882f8..00427d672 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -111,6 +111,12 @@ public function init(): void { // Async handler for demo site deletion. add_action('wu_async_delete_demo_site', [$this, 'async_delete_demo_site'], 10, 1); + + // Admin bar notice for keep-until-live demo sites. + add_action('admin_bar_menu', [$this, 'add_demo_admin_bar_menu'], 999); + + // Handle "go live" action requests from site admins. + add_action('wp', [$this, 'handle_go_live_action']); } /** @@ -260,20 +266,32 @@ public function maybe_add_new_site(): void { public function handle_site_published($site, $membership): void { /* * If this is a demo site, set the expiration time. + * Skip if the demo product is configured to keep until the customer goes live. */ if ($site->is_demo()) { - $expires_at = $site->calculate_demo_expiration(); - $site->set_demo_expires_at($expires_at); - - wu_log_add( - 'demo-sites', - sprintf( - // translators: %1$d is site ID, %2$s is expiration datetime. - __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'), - $site->get_id(), - $expires_at - ) - ); + if ($site->is_keep_until_live()) { + wu_log_add( + 'demo-sites', + sprintf( + // translators: %d is the site ID. + __('Demo site #%d created in keep-until-live mode (no expiration set).', 'ultimate-multisite'), + $site->get_id() + ) + ); + } else { + $expires_at = $site->calculate_demo_expiration(); + $site->set_demo_expires_at($expires_at); + + wu_log_add( + 'demo-sites', + sprintf( + // translators: %1$d is site ID, %2$s is expiration datetime. + __('Demo site #%1$d created, expires at %2$s', 'ultimate-multisite'), + $site->get_id(), + $expires_at + ) + ); + } } $payload = array_merge( @@ -305,6 +323,32 @@ public function lock_site(): void { $site = wu_get_current_site(); + /* + * Block frontend for keep-until-live demo sites. + * + * These sites remain accessible in wp-admin (the admin can still build + * their site) but the public-facing frontend is blocked until the customer + * explicitly activates the site. Site administrators are allowed through + * so they can preview their work. + */ + if ($site->is_keep_until_live()) { + if (current_user_can('manage_options') || is_super_admin()) { + // Site admins can see the frontend — let them through. + return; + } + + wp_die( + wp_kses_post( + sprintf( + // translators: %s: link to the login page + __('This site is currently in demo mode and is not yet available to the public.
If you are the site owner, log in to access your dashboard.', 'ultimate-multisite'), + esc_url(wp_login_url(get_permalink())) + ) + ), + esc_html__('Site in Demo Mode', 'ultimate-multisite'), + ); + } + if ( ! $site->is_active()) { $can_access = false; } @@ -924,6 +968,11 @@ public function check_expired_demo_sites(): void { $current_time = wu_get_current_time('mysql', true); foreach ($demo_sites as $site) { + // Skip keep-until-live sites — they never auto-expire. + if ($site->is_keep_until_live()) { + continue; + } + $expires_at = $site->get_meta('wu_demo_expires_at'); // Skip sites without expiration set. @@ -973,6 +1022,11 @@ public function check_expiring_demo_sites(): void { $warning_seconds = $warning_hours * HOUR_IN_SECONDS; foreach ($demo_sites as $site) { + // Skip keep-until-live sites — they don't have an expiration. + if ($site->is_keep_until_live()) { + continue; + } + $expires_at = $site->get_meta('wu_demo_expires_at'); // Skip sites without expiration set. @@ -1119,4 +1173,191 @@ public function async_delete_demo_site($site_id): void { ) ); } + + /** + * Add an admin bar notice for keep-until-live demo sites. + * + * When a site administrator views the frontend of a site that is in + * "keep until live" demo mode, this adds an admin bar item informing + * them that the site is in demo mode and providing a "Go Live" link. + * + * @since 2.5.0 + * + * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance. + * @return void + */ + public function add_demo_admin_bar_menu(\WP_Admin_Bar $wp_admin_bar): void { + + if (is_admin()) { + return; + } + + $site = wu_get_current_site(); + + if ( ! $site || ! $site->is_keep_until_live()) { + return; + } + + // Only show to users who have admin access to this site. + if ( ! current_user_can('manage_options') && ! is_super_admin()) { + return; + } + + $go_live_url = wu_get_setting('demo_go_live_url', ''); + + if (empty($go_live_url)) { + // Fall back to the direct go-live action URL. + $go_live_url = wp_nonce_url( + add_query_arg( + [ + 'wu_go_live' => $site->get_id(), + ], + get_home_url() + ), + 'wu_go_live_' . $site->get_id() + ); + } + + $go_live_url = apply_filters('wu_demo_go_live_url', $go_live_url, $site); + + // Parent node: "Demo Mode" label. + $wp_admin_bar->add_node( + [ + 'id' => 'wu-demo-mode', + 'title' => '★ ' . esc_html__('Demo Mode', 'ultimate-multisite') . '', + 'href' => false, + 'meta' => [ + 'title' => __('This site is in demo mode. The frontend is not visible to visitors.', 'ultimate-multisite'), + ], + ] + ); + + // Child node: "Go Live" link. + $wp_admin_bar->add_node( + [ + 'parent' => 'wu-demo-mode', + 'id' => 'wu-demo-go-live', + 'title' => esc_html__('Go Live →', 'ultimate-multisite'), + 'href' => esc_url($go_live_url), + 'meta' => [ + 'title' => __('Activate your site and make it visible to visitors.', 'ultimate-multisite'), + ], + ] + ); + + // Child node: informational note. + $wp_admin_bar->add_node( + [ + 'parent' => 'wu-demo-mode', + 'id' => 'wu-demo-mode-info', + 'title' => esc_html__('Visitors cannot see this site yet.', 'ultimate-multisite'), + 'href' => false, + ] + ); + } + + /** + * Handle the "go live" direct action when no external URL is configured. + * + * Listens for `?wu_go_live=SITE_ID` on the frontend. Verifies the nonce, + * checks permissions, and converts the demo site to a customer-owned site. + * + * @since 2.5.0 + * @return void + */ + public function handle_go_live_action(): void { + + $site_id = wu_request('wu_go_live'); + + if ( ! $site_id) { + return; + } + + $site_id = absint($site_id); + + if ( ! wp_verify_nonce(wu_request('_wpnonce'), 'wu_go_live_' . $site_id)) { + wp_die(esc_html__('Security check failed. Please try again.', 'ultimate-multisite')); + } + + if ( ! current_user_can('manage_options') && ! is_super_admin()) { + wp_die(esc_html__('You do not have permission to activate this site.', 'ultimate-multisite')); + } + + $result = $this->convert_demo_to_live($site_id); + + if (is_wp_error($result)) { + wp_die(esc_html($result->get_error_message())); + } + + // Redirect back to the home page without the query arg. + wp_safe_redirect(remove_query_arg(['wu_go_live', '_wpnonce'])); + + exit; + } + + /** + * Convert a keep-until-live demo site to a fully live customer-owned site. + * + * Changes the site type from DEMO to CUSTOMER_OWNED, clears demo meta, + * and fires before/after hooks for extensibility. + * + * @since 2.5.0 + * + * @param int $site_id The WP Ultimo site ID. + * @return true|\WP_Error True on success, WP_Error on failure. + */ + public function convert_demo_to_live(int $site_id) { + + $site = wu_get_site($site_id); + + if ( ! $site) { + return new \WP_Error('site_not_found', __('Demo site not found.', 'ultimate-multisite')); + } + + if ( ! $site->is_keep_until_live()) { + return new \WP_Error('not_demo_site', __('This site is not a keep-until-live demo site.', 'ultimate-multisite')); + } + + /** + * Fires before a keep-until-live demo site is converted to live. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Site $site The site being converted. + */ + do_action('wu_before_demo_site_converted', $site); + + // Convert site type from demo to customer-owned. + $site->set_type(Site_Type::CUSTOMER_OWNED); + + // Clear demo-specific meta. + $site->delete_meta(Site::META_DEMO_EXPIRES_AT); + $site->delete_meta('wu_demo_expiring_notified'); + + $saved = $site->save(); + + if ( ! $saved) { + return new \WP_Error('save_failed', __('Failed to activate the demo site. Please try again.', 'ultimate-multisite')); + } + + wu_log_add( + 'demo-sites', + sprintf( + // translators: %d is the site ID. + __('Demo site #%d converted to live (customer-owned).', 'ultimate-multisite'), + $site_id + ) + ); + + /** + * Fires after a keep-until-live demo site has been successfully converted to live. + * + * @since 2.5.0 + * + * @param \WP_Ultimo\Models\Site $site The site that was converted. + */ + do_action('wu_after_demo_site_converted', $site); + + return true; + } } diff --git a/inc/models/class-product.php b/inc/models/class-product.php index 240197117..203fa46d8 100644 --- a/inc/models/class-product.php +++ b/inc/models/class-product.php @@ -75,6 +75,13 @@ class Product extends Base_Model implements Limitable { */ const META_LEGACY_OPTIONS = 'legacy_options'; + /** + * Meta key for demo behavior setting. + * + * @since 2.5.0 + */ + const META_DEMO_BEHAVIOR = 'wu_demo_behavior'; + /** * Meta key for PWYW minimum amount. */ @@ -310,6 +317,14 @@ class Product extends Base_Model implements Limitable { */ protected $network_id; + /** + * Demo behavior (delete_after_time or keep_until_live). + * + * @since 2.5.0 + * @var string|null + */ + protected $demo_behavior = null; + /** * Contact us Label. * @@ -426,6 +441,7 @@ public function validation_rules() { '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', + 'demo_behavior' => 'in:delete_after_time,keep_until_live|default:delete_after_time', ]; } @@ -1314,6 +1330,47 @@ public function set_contact_us_link($contact_us_link): void { $this->contact_us_link = $this->meta[ self::META_CONTACT_US_LINK ]; } + /** + * Get the demo behavior setting for this product. + * + * @since 2.5.0 + * @return string 'delete_after_time' or 'keep_until_live'. + */ + public function get_demo_behavior(): string { + + if (null === $this->demo_behavior) { + $this->demo_behavior = $this->get_meta(self::META_DEMO_BEHAVIOR, 'delete_after_time'); + } + + return $this->demo_behavior ?: 'delete_after_time'; + } + + /** + * Set the demo behavior for this product. + * + * @since 2.5.0 + * + * @param string $behavior Either 'delete_after_time' or 'keep_until_live'. + * @return void + */ + public function set_demo_behavior(string $behavior): void { + + $this->meta[ self::META_DEMO_BEHAVIOR ] = $behavior; + + $this->demo_behavior = $behavior; + } + + /** + * Check if this demo product uses the "keep until live" behavior. + * + * @since 2.5.0 + * @return bool + */ + public function is_keep_until_live(): bool { + + return $this->get_type() === Product_Type::DEMO && $this->get_demo_behavior() === 'keep_until_live'; + } + /** * Get feature list for pricing tables.. * diff --git a/inc/models/class-site.php b/inc/models/class-site.php index f2bb5a152..158ebe899 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1435,6 +1435,27 @@ public function is_demo(): bool { return $this->get_type() === Site_Type::DEMO; } + /** + * Check if this demo site is configured to stay until the customer goes live. + * + * Returns true when the associated plan product has demo_behavior = 'keep_until_live'. + * In this mode, the site has no expiration timer and the frontend is blocked + * until the customer explicitly activates the site. + * + * @since 2.5.0 + * @return bool + */ + public function is_keep_until_live(): bool { + + if ( ! $this->is_demo()) { + return false; + } + + $plan = $this->get_plan(); + + return $plan && $plan->is_keep_until_live(); + } + /** * Get the demo expiration date/time. *