diff --git a/inc/admin-pages/class-product-edit-admin-page.php b/inc/admin-pages/class-product-edit-admin-page.php index b19438f8..b394ce80 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/checkout/class-checkout.php b/inc/checkout/class-checkout.php index b2c8f238..2433be88 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 1abfe424..f682a6e6 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -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;', + ], + ] + ); + + $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;', + ], + ] + ); + + $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. diff --git a/inc/database/products/class-product-type.php b/inc/database/products/class-product-type.php index 3c0a9527..eb15c30c 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 f66aa39a..0b227ef0 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 04efc484..09f3a65f 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 ff54262b..78a15e16 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 6857829a..82ec1305 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 295717f9..5bf5ab98 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 09a58dff..00427d67 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -102,6 +102,21 @@ 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); + + // 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']); } /** @@ -249,6 +264,35 @@ 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. + * Skip if the demo product is configured to keep until the customer goes live. + */ + if ($site->is_demo()) { + 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( wu_generate_event_payload('site', $site), @@ -279,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; } @@ -876,4 +946,418 @@ 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) { + // 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. + 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) { + // 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. + 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 + ) + ); + } + + /** + * 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 24019711..203fa46d 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 070976a3..158ebe89 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,159 @@ 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; + } + + /** + * 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. + * + * @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 00000000..f37761a5 --- /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 00000000..44579087 --- /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}} +