From 244b8eb9dee034aafb0d75c1e8aa39dfbef8a5c0 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 15 Jan 2026 16:43:35 +0100 Subject: [PATCH 1/9] hello guided tour --- classes/class-base.php | 13 + guided-tour/README.md | 300 ++++++ guided-tour/class-guided-tour.php | 825 +++++++++++++++++ guided-tour/class-integration.php | 291 ++++++ guided-tour/class-tour-launcher.php | 222 +++++ guided-tour/guided-tour.css | 734 +++++++++++++++ guided-tour/guided-tour.js | 1303 +++++++++++++++++++++++++++ phpstan.neon.dist | 1 + 8 files changed, 3689 insertions(+) create mode 100644 guided-tour/README.md create mode 100644 guided-tour/class-guided-tour.php create mode 100644 guided-tour/class-integration.php create mode 100644 guided-tour/class-tour-launcher.php create mode 100644 guided-tour/guided-tour.css create mode 100644 guided-tour/guided-tour.js diff --git a/classes/class-base.php b/classes/class-base.php index 7978a9367..da4d77b52 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -184,6 +184,19 @@ public function init() { // TODO: Decide when this needs to be initialized. $this->get_onboard_wizard(); + + // WIP: Initialize guided tour for hosting partners (on init to avoid early translation loading). + \add_action( 'init', [ $this, 'init_guided_tour' ] ); + } + + /** + * Initialize the guided tour system. + * + * @return void + */ + public function init_guided_tour() { + require_once PROGRESS_PLANNER_DIR . '/guided-tour/class-integration.php'; + \Progress_Planner\Guided_Tour\Integration::init(); } /** diff --git a/guided-tour/README.md b/guided-tour/README.md new file mode 100644 index 000000000..59efcfe42 --- /dev/null +++ b/guided-tour/README.md @@ -0,0 +1,300 @@ +# Guided Tours for WordPress + +A complete solution for creating guided tours that span multiple WordPress admin screens, using driver.js for the UI and server-side state persistence. + +**Note:** This is for hosting partner installations, distinct from the plugin onboarding (which is for users installing from wp.org). + +## Features + +- Multi-page tours - Guide users across different admin screens +- Persistent state - Progress saved in user meta, survives page reloads +- Customizable UI - Clean, modern design with driver.js +- Extensible - Add custom tours via filters +- Responsive - Works on all screen sizes +- Accessible - Keyboard navigation and ARIA support + +## File Structure + +``` +guided-tour/ +├── class-guided-tour.php # Core tour management +├── class-tour-launcher.php # UI components for starting tours +├── class-integration.php # Integration helper with examples +├── guided-tour.css # Styling +└── guided-tour.js # Frontend tour controller +``` + +## Quick Start + +### 1. Include the integration file + +```php +// In your main plugin file +require_once __DIR__ . '/guided-tour/class-integration.php'; +add_action( 'plugins_loaded', [ 'Progress_Planner\Guided_Tour\Integration', 'init' ] ); +``` + +### 2. Define the plugin version constant + +```php +define( 'PROGRESS_PLANNER_VERSION', '1.0.0' ); +``` + +## Defining Tours + +Tours are defined in `class-guided-tour.php` in the `register_tours()` method: + +```php +$this->tours = [ + 'my-tour' => [ + 'id' => 'my-tour', + 'title' => 'My Tour Title', + 'description' => 'Short description of what this tour covers.', + 'steps' => [ + [ + 'page' => 'index.php', // WP admin page + 'element' => '#my-element', // CSS selector + 'title' => 'Step Title', + 'description' => 'Step description text.', + 'side' => 'bottom', // Popover position + ], + // More steps... + ], + ], +]; +``` + +### Step Options + +| Option | Type | Description | +|--------|------|-------------| +| `context` | string | Step context: `admin`, `frontend`, or `editor` (default: `admin`) | +| `page` | string | Page identifier: admin path, `front_page` for homepage/editor | +| `element` | string | CSS selector or special element type (see below) | +| `title` | string | Step title shown in popover | +| `description` | string | Step description text | +| `fallback_description` | string | Alternative description when element is not found | +| `hint` | string | Additional hint text shown below description | +| `side` | string | Popover position: `top`, `bottom`, `left`, `right` | +| `align` | string | Popover alignment: `start`, `center`, `end` | + +### Step Contexts + +Tours can span multiple contexts: + +- **`admin`** - WordPress admin pages (default) +- **`frontend`** - The site frontend (e.g., homepage) +- **`editor`** - Block editor for posts/pages + +### Special Element Types + +For editor context, use these special element identifiers: + +| Element | Description | +|---------|-------------| +| `first-heading` | First heading block (h1-h6) or post title | +| `first-paragraph` | First paragraph block | +| `first-image` | First image or cover block | +| `save-button` | The Save/Update button in editor header | + +These are automatically resolved to the correct elements in both iframe and non-iframe editor modes. + +## Adding Custom Tours via Filter + +```php +add_filter( 'progress_planner_guided_tours', function( $tours ) { + $tours['custom-feature'] = [ + 'id' => 'custom-feature', + 'title' => 'Custom Feature Tour', + 'description' => 'Learn about this feature.', + 'steps' => [ + [ + 'page' => 'admin.php?page=my-plugin&tab=custom', + 'element' => '.custom-feature-widget', + 'title' => 'Custom Feature', + 'description' => 'This widget shows custom data.', + 'side' => 'right', + ], + ], + ]; + return $tours; +} ); +``` + +### Multi-Context Tour Example + +```php +$tours['homepage-setup'] = [ + 'id' => 'homepage-setup', + 'title' => 'Edit Your Homepage', + 'description' => 'Learn how to customize your homepage.', + 'steps' => [ + // Step 1: Frontend - show welcome card + [ + 'context' => 'frontend', + 'page' => 'front_page', + 'element' => '#wp-admin-bar-edit', + 'title' => 'Edit Page', + 'description' => 'Click here to edit your homepage.', + 'side' => 'bottom', + ], + // Step 2: Editor - highlight heading + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'first-heading', + 'title' => 'Your Headline', + 'description' => 'Update this heading to reflect your brand.', + 'side' => 'right', + ], + // Step 3: Editor - highlight image with fallback + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'first-image', + 'title' => 'Add Images', + 'description' => 'Use authentic photos for better engagement.', + 'fallback_description' => 'No images found. Consider adding some photos!', + 'side' => 'right', + ], + // Step 4: Editor - save button + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'save-button', + 'title' => 'Save Changes', + 'description' => 'Click Save to publish your changes.', + 'side' => 'bottom', + ], + ], +]; +``` + +## UI Components + +### Tour Launcher Widget + +Displays all available tours in a card: + +```php +$launcher = Integration::get_launcher(); +$launcher->render( [ + 'title' => 'Get Started', + 'description' => 'Take a guided tour.', + 'tour_ids' => [ 'hosting-setup' ], // Optional: limit to specific tours +] ); +``` + +### Single Tour Button + +```php +$launcher->render_button( 'hosting-setup', [ + 'text' => 'Start Setup', + 'class' => 'button button-primary', +] ); +``` + +### First-Time User Prompt + +```php +$launcher->maybe_show_prompt( 'first-time-setup', [ + 'title' => 'New here?', + 'description' => 'Take a quick tour.', +] ); +``` + +## Programmatic Control + +```php +// Start a tour +Integration::start_tour( 'hosting-setup' ); + +// Check if completed +if ( Integration::is_tour_completed( 'hosting-setup' ) ) { + // Tour was completed +} + +// Reset a tour (allow retaking) +Integration::reset_tour( 'hosting-setup' ); +``` + +## JavaScript API + +The tour manager is exposed globally: + +```javascript +// Start a tour +window.ppGuidedTourManager.startTour( 'hosting-setup' ); + +// Skip current tour +window.ppGuidedTourManager.skipTour(); + +// Check state +console.log( window.ppGuidedTour.state ); +``` + +## Hooks & Filters + +### PHP Filters + +| Filter | Description | +|--------|-------------| +| `progress_planner_guided_tours` | Add/modify tour definitions | +| `progress_planner_is_hosting_install` | Auto-start hosting tour on partner installs | + +### PHP Actions + +| Action | Description | +|--------|-------------| +| `progress_planner_dashboard_sidebar` | Render launcher in dashboard | +| `progress_planner_activated` | Plugin activation hook | + +## Styling Customization + +Override CSS variables for quick theming: + +```css +.pp-guided-tour-popover { + --driver-popover-bg: #fff; + --driver-popover-title-color: #1e1e1e; + --driver-popover-description-color: #50575e; + --driver-popover-border-radius: 8px; +} +``` + +## How It Works + +1. **Tour starts** - State saved to `user_meta` with `tour_id` and `step: 0` +2. **Page loads** - JS checks current context (admin/frontend/editor) and page +3. **Context match** - Initialize driver.js with step(s) for this context +4. **No match** - Show "Continue" prompt to navigate to correct page/context +5. **Step advances** - AJAX updates server state, redirects if needed +6. **Tour completes** - Tour ID added to `completed` array in user meta + +### Multi-Context Flow + +For tours spanning frontend and editor: + +1. Tour starts on **frontend** (e.g., homepage) +2. Welcome card shown, user clicks "Edit Page" in admin bar +3. Progress updated, user redirected to **block editor** +4. Editor steps highlight content blocks (heading, paragraph, image) +5. Special elements resolved inside iframe canvas when applicable +6. Final step highlights Save button, tour completes + +## Browser Support + +- Chrome 80+ +- Firefox 75+ +- Safari 13+ +- Edge 80+ + +## Dependencies + +- [driver.js](https://driverjs.com/) v1.3.1 (loaded from CDN) +- WordPress 5.6+ +- PHP 7.4+ + +## License + +GPL v2 or later diff --git a/guided-tour/class-guided-tour.php b/guided-tour/class-guided-tour.php new file mode 100644 index 000000000..4873e440a --- /dev/null +++ b/guided-tour/class-guided-tour.php @@ -0,0 +1,825 @@ +register_tours(); + } + + /** + * Register WordPress hooks. + */ + public function init(): void { + // Admin assets. + add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_admin_assets' ] ); + add_action( 'admin_footer', [ $this, 'render_continue_prompt' ] ); + + // Frontend assets (for homepage tour). + add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_frontend_assets' ] ); + add_action( 'wp_footer', [ $this, 'render_frontend_tour_card' ] ); + + // Block editor assets. + add_action( 'enqueue_block_editor_assets', [ $this, 'maybe_enqueue_editor_assets' ] ); + + // AJAX handlers. + add_action( 'wp_ajax_pp_guided_tour_update', [ $this, 'ajax_update_progress' ] ); + add_action( 'wp_ajax_pp_guided_tour_start', [ $this, 'ajax_start_tour' ] ); + add_action( 'wp_ajax_pp_guided_tour_skip', [ $this, 'ajax_skip_tour' ] ); + add_action( 'wp_ajax_pp_guided_tour_complete', [ $this, 'ajax_complete_tour' ] ); + + // Admin bar link for starting tour (temporary for testing). + add_action( 'admin_bar_menu', [ $this, 'add_admin_bar_tour_link' ], 100 ); + + // Handle tour start via GET parameter. + add_action( 'template_redirect', [ $this, 'maybe_start_tour_from_url' ] ); + } + + /** + * Add "Start Tour" link to admin bar on frontend homepage. + * + * @param \WP_Admin_Bar $admin_bar Admin bar instance. + */ + public function add_admin_bar_tour_link( $admin_bar ): void { + // Only on frontend. + if ( is_admin() ) { + return; + } + + // Only on front page. + if ( ! is_front_page() ) { + return; + } + + // Only for users who can edit. + if ( ! current_user_can( 'edit_pages' ) ) { + return; + } + + // Check if tour is already active - show reset link instead. + $state = $this->get_state(); + if ( ! empty( $state['active'] ) ) { + $admin_bar->add_node( + [ + 'id' => 'pp-reset-guided-tour', + 'title' => __( 'Reset Tour', 'progress-planner' ), + 'href' => add_query_arg( 'pp_reset_tour', '1', home_url() ), + 'meta' => [ + 'class' => 'pp-guided-tour-admin-bar-link', + ], + ] + ); + return; + } + + $admin_bar->add_node( + [ + 'id' => 'pp-start-guided-tour', + 'title' => __( 'Start Guided Tour', 'progress-planner' ), + 'href' => add_query_arg( 'pp_start_tour', 'go-to-publish', home_url() ), + 'meta' => [ + 'class' => 'pp-guided-tour-admin-bar-link', + ], + ] + ); + } + + /** + * Maybe start tour from URL parameter. + */ + public function maybe_start_tour_from_url(): void { + // Only for users who can edit. + if ( ! current_user_can( 'edit_pages' ) ) { + return; + } + + // Handle reset. + if ( isset( $_GET['pp_reset_tour'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + delete_user_meta( get_current_user_id(), self::META_KEY ); + $this->current_state = null; + wp_safe_redirect( remove_query_arg( 'pp_reset_tour' ) ); + exit; + } + + // Check for the start parameter. + if ( ! isset( $_GET['pp_start_tour'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + return; + } + + $tour_id = sanitize_key( $_GET['pp_start_tour'] ); // phpcs:ignore WordPress.Security.NonceVerification + + // Verify the tour exists. + if ( ! isset( $this->tours[ $tour_id ] ) ) { + return; + } + + // Start the tour. + $this->start_tour( $tour_id ); + + // Redirect to remove the query parameter (clean URL). + wp_safe_redirect( remove_query_arg( 'pp_start_tour' ) ); + exit; + } + + /** + * Register all available tours. + */ + private function register_tours(): void { + $this->tours = [ + 'go-to-publish' => [ + 'id' => 'go-to-publish', + 'title' => __( 'Go to Publish', 'progress-planner' ), + 'description' => __( 'Get your website ready for the world.', 'progress-planner' ), + 'badge' => 'go-to-publish', + 'steps' => [ + // Step 0: Welcome on frontend homepage. + [ + 'context' => 'frontend', + 'page' => 'front_page', + 'element' => '', + 'title' => __( 'Yesssssss! Welcome to your website!', 'progress-planner' ), + 'description' => __( 'And now we\'re going to improve and customize it together so it\'s ready for the world. Are you ready to get started?', 'progress-planner' ), + 'cta' => __( 'Edit Page', 'progress-planner' ), + 'cta_hint' => __( 'Click here to star the guided tour.', 'progress-planner' ), + ], + // Step 1: Check the title in block editor. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'first-heading', + 'title' => __( 'This is your homepage!', 'progress-planner' ), + 'description' => __( 'Looks good, right? Check the title. Are you happy with it, or do you want to adjust it?', 'progress-planner' ), + 'hint' => __( 'Do you want to know more about what exactly a good homepage entails? Click on the Progress Planner logo in the sidebar. There you will find videos and tips.', 'progress-planner' ), + 'side' => 'right', + ], + // Step 2: Check the main message/paragraph. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'first-paragraph', + 'title' => __( 'What is the main message of your website?', 'progress-planner' ), + 'description' => __( 'Is it clearly stated? Go ahead and adjust the text!', 'progress-planner' ), + 'hint' => __( 'Do you want to know more about what exactly a good homepage entails? Click on the Progress Planner logo in the sidebar. There you will find videos and tips.', 'progress-planner' ), + 'side' => 'right', + ], + // Step 3: Check the first image. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'first-image', + 'title' => __( 'Make sure to use authentic photos', 'progress-planner' ), + 'description' => __( 'What do you think of the pictures and photos now? Go ahead and upload some of your own photos and images!', 'progress-planner' ), + 'fallback_description' => __( 'We couldn\'t find any images on this page yet. Consider adding some authentic photos to make your homepage more engaging! You can add an image block using the + button in the editor.', 'progress-planner' ), + 'side' => 'right', + ], + // Step 4: Save the changes. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'save-button', + 'title' => __( 'Save your changes', 'progress-planner' ), + 'description' => __( 'Great job! Now click the Save button to publish your changes and make them live on your website.', 'progress-planner' ), + 'side' => 'bottom', + ], + ], + ], + 'hosting-setup' => [ + 'id' => 'hosting-setup', + 'title' => __( 'Hosting Integration Setup', 'progress-planner' ), + 'description' => __( 'Learn how to connect your hosting account.', 'progress-planner' ), + 'steps' => [ + [ + 'context' => 'admin', + 'page' => 'index.php', + 'element' => '#dashboard-widgets-wrap', + 'title' => __( 'Welcome to Progress Planner!', 'progress-planner' ), + 'description' => __( 'Let\'s set up your hosting integration. This quick tour will guide you through the process.', 'progress-planner' ), + 'side' => 'bottom', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner', + 'element' => '.progress-planner-nav', + 'title' => __( 'Progress Planner Dashboard', 'progress-planner' ), + 'description' => __( 'This is your main dashboard. Navigate using these tabs.', 'progress-planner' ), + 'side' => 'bottom', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner-settings', + 'element' => '.pp-settings-section-hosting', + 'title' => __( 'Hosting Settings', 'progress-planner' ), + 'description' => __( 'Here you\'ll configure your hosting connection.', 'progress-planner' ), + 'side' => 'right', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner-settings', + 'element' => '#pp-hosting-api-key', + 'title' => __( 'Enter Your API Key', 'progress-planner' ), + 'description' => __( 'Paste your hosting API key here. You can find this in your hosting dashboard.', 'progress-planner' ), + 'side' => 'bottom', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner-settings', + 'element' => '#pp-hosting-connect-btn', + 'title' => __( 'Connect Your Account', 'progress-planner' ), + 'description' => __( 'Click this button to verify and connect your hosting account.', 'progress-planner' ), + 'side' => 'top', + ], + ], + ], + 'first-time-setup' => [ + 'id' => 'first-time-setup', + 'title' => __( 'Getting Started', 'progress-planner' ), + 'description' => __( 'Quick introduction to Progress Planner features.', 'progress-planner' ), + 'steps' => [ + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner', + 'element' => '.pp-score-widget', + 'title' => __( 'Your Progress Score', 'progress-planner' ), + 'description' => __( 'This widget shows your overall site health score.', 'progress-planner' ), + 'side' => 'right', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner', + 'element' => '.pp-tasks-widget', + 'title' => __( 'Recommended Tasks', 'progress-planner' ), + 'description' => __( 'Here you\'ll find actionable tasks to improve your site.', 'progress-planner' ), + 'side' => 'left', + ], + [ + 'context' => 'admin', + 'page' => 'admin.php?page=progress-planner-settings', + 'element' => '.pp-settings-general', + 'title' => __( 'Customize Your Experience', 'progress-planner' ), + 'description' => __( 'Adjust settings to match your workflow.', 'progress-planner' ), + 'side' => 'bottom', + ], + ], + ], + ]; + + /** + * Filter to allow adding custom tours. + * + * @param array $tours Registered tours. + */ + $this->tours = apply_filters( 'progress_planner_guided_tours', $this->tours ); + } + + /** + * Get current user's tour state. + * + * @return array Tour state or empty array. + */ + public function get_state(): array { + if ( null === $this->current_state ) { + $state = get_user_meta( + get_current_user_id(), + self::META_KEY, + true + ); + + // Handle cases where meta returns empty string or non-array. + $this->current_state = is_array( $state ) ? $state : []; + } + + return $this->current_state; + } + + /** + * Update tour state. + * + * @param array $state New state data. + * @return bool Success. + */ + public function update_state( array $state ): bool { + $this->current_state = wp_parse_args( + $state, + [ + 'active' => false, + 'tour_id' => '', + 'step' => 0, + 'started_at' => '', + 'completed' => [], + ] + ); + + return (bool) update_user_meta( + get_current_user_id(), + self::META_KEY, + $this->current_state + ); + } + + /** + * Start a tour for the current user. + * + * @param string $tour_id Tour identifier. + * @return bool|array False on failure, tour data on success. + */ + public function start_tour( string $tour_id ) { + if ( ! isset( $this->tours[ $tour_id ] ) ) { + return false; + } + + $state = [ + 'active' => true, + 'tour_id' => $tour_id, + 'step' => 0, + 'started_at' => current_time( 'mysql' ), + 'completed' => $this->get_state()['completed'] ?? [], + ]; + + $this->update_state( $state ); + + return [ + 'state' => $state, + 'tour' => $this->tours[ $tour_id ], + ]; + } + + /** + * Check if admin assets should be enqueued. + * + * @param string $hook Current admin page hook. + */ + public function maybe_enqueue_admin_assets( string $hook ): void { + $state = $this->get_state(); + + // Check if we have an active tour with admin context steps. + if ( ! empty( $state['active'] ) && ! empty( $state['tour_id'] ) ) { + $tour_id = (string) $state['tour_id']; + $tour = $this->tours[ $tour_id ] ?? null; + if ( $tour ) { + $step_index = (int) ( $state['step'] ?? 0 ); + $current_step = $tour['steps'][ $step_index ] ?? null; + if ( $current_step && ( $current_step['context'] ?? 'admin' ) === 'admin' ) { + $this->enqueue_assets( $state, 'admin' ); + return; + } + } + } + + // Enqueue on Progress Planner pages for starting tours. + if ( str_contains( $hook, 'progress-planner' ) ) { + $this->enqueue_assets( $state, 'admin' ); + } + } + + /** + * Check if frontend assets should be enqueued (for homepage tour). + */ + public function maybe_enqueue_frontend_assets(): void { + // Only on the front page. + if ( ! is_front_page() ) { + return; + } + + // Only for logged-in users who can edit. + if ( ! is_user_logged_in() || ! current_user_can( 'edit_pages' ) ) { + return; + } + + $state = $this->get_state(); + + // Check if there's an active tour with frontend step. + if ( ! empty( $state['active'] ) && ! empty( $state['tour_id'] ) ) { + $tour_id = (string) $state['tour_id']; + $tour = $this->tours[ $tour_id ] ?? null; + if ( $tour ) { + $step_index = (int) ( $state['step'] ?? 0 ); + $current_step = $tour['steps'][ $step_index ] ?? null; + if ( $current_step && ( $current_step['context'] ?? '' ) === 'frontend' ) { + $this->enqueue_assets( $state, 'frontend' ); + } + } + } + } + + /** + * Check if block editor assets should be enqueued. + */ + public function maybe_enqueue_editor_assets(): void { + $state = $this->get_state(); + + // Check if we have an active tour with editor context. + if ( empty( $state['active'] ) || empty( $state['tour_id'] ) ) { + return; + } + + $tour_id = (string) $state['tour_id']; + $tour = $this->tours[ $tour_id ] ?? null; + if ( ! $tour ) { + return; + } + + $step_index = (int) ( $state['step'] ?? 0 ); + $current_step = $tour['steps'][ $step_index ] ?? null; + + if ( ! $current_step || ( $current_step['context'] ?? '' ) !== 'editor' ) { + return; + } + + $this->enqueue_assets( $state, 'editor' ); + } + + /** + * Enqueue tour assets. + * + * @param array $state Current tour state. + * @param string $context Current context (admin, frontend, editor). + */ + private function enqueue_assets( array $state, string $context = 'admin' ): void { + $base_url = plugins_url( '', __FILE__ ); + + // Driver.js from CDN (needed for highlights). + wp_enqueue_style( + 'driver-js', + 'https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.css', + [], + '1.3.1' + ); + + wp_enqueue_script( + 'driver-js', + 'https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.js.iife.js', + [], + '1.3.1', + true + ); + + // Our guided tour styles. + wp_enqueue_style( + 'pp-guided-tour', + $base_url . '/guided-tour.css', + [ 'driver-js' ], + '1.0' + ); + + // Our guided tour scripts. + $script_deps = [ 'wp-i18n', 'driver-js' ]; + if ( 'editor' === $context ) { + $script_deps[] = 'wp-blocks'; + $script_deps[] = 'wp-data'; + $script_deps[] = 'wp-dom-ready'; + } + + wp_enqueue_script( + 'pp-guided-tour', + $base_url . '/guided-tour.js', + $script_deps, + '1.0', + true + ); + + // Prepare current page identifier. + $current_page = $this->get_current_page_identifier( $context ); + + // Get active tour data if any. + $active_tour = null; + if ( ! empty( $state['active'] ) && ! empty( $state['tour_id'] ) ) { + $tour_id = (string) $state['tour_id']; + $active_tour = $this->tours[ $tour_id ] ?? null; + } + + // Get the edit URL for the front page (needed for step 0 CTA). + $front_page_edit_url = ''; + $front_page_id = (int) get_option( 'page_on_front' ); + if ( $front_page_id ) { + $front_page_edit_url = get_edit_post_link( $front_page_id, 'raw' ); + } + + wp_localize_script( + 'pp-guided-tour', + 'ppGuidedTour', + [ + 'state' => $state, + 'context' => $context, + 'currentPage' => $current_page, + 'activeTour' => $active_tour, + 'tours' => $this->get_available_tours(), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'pp_guided_tour' ), + 'adminUrl' => admin_url(), + 'frontPageEditUrl' => $front_page_edit_url, + 'i18n' => [ + 'next' => __( 'Next', 'progress-planner' ), + 'prev' => __( 'Previous', 'progress-planner' ), + 'done' => __( 'Done', 'progress-planner' ), + 'skip' => __( 'Skip Tour', 'progress-planner' ), + 'skipStep' => __( 'Skip', 'progress-planner' ), + 'continueTitle' => __( 'Continue Tour?', 'progress-planner' ), + 'continueMessage' => __( 'The next step is on a different page. Click continue to proceed.', 'progress-planner' ), + 'continueCta' => __( 'Continue', 'progress-planner' ), + 'dismiss' => __( 'Dismiss', 'progress-planner' ), + 'letsGo' => __( 'Let\'s Go!', 'progress-planner' ), + 'editPageTitle' => __( 'Edit Page', 'progress-planner' ), + 'editPageDesc' => __( 'Click here to start editing your homepage.', 'progress-planner' ), + ], + ] + ); + } + + /** + * Get the current page identifier based on context. + * + * @param string $context Current context. + * @return string Page identifier. + */ + private function get_current_page_identifier( string $context ): string { + if ( 'frontend' === $context ) { + return is_front_page() ? 'front_page' : 'other'; + } + + if ( 'editor' === $context ) { + global $post; + $front_page_id = (int) get_option( 'page_on_front' ); + if ( $post instanceof \WP_Post && $post->ID === $front_page_id ) { + return 'front_page'; + } + return 'post_' . ( $post instanceof \WP_Post ? $post->ID : '0' ); + } + + // Admin context. + $current_page = $GLOBALS['pagenow'] ?? ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just reading page identifier. + if ( ! empty( $_GET['page'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_page .= '?page=' . sanitize_text_field( wp_unslash( $_GET['page'] ) ); + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Just reading tab identifier. + if ( ! empty( $_GET['tab'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_page .= '&tab=' . sanitize_text_field( wp_unslash( $_GET['tab'] ) ); + } + + return $current_page; + } + + /** + * Get tours available to current user (excluding completed ones). + * + * @return array Available tours. + */ + public function get_available_tours(): array { + $state = $this->get_state(); + $completed = $state['completed'] ?? []; + $available = []; + + foreach ( $this->tours as $id => $tour ) { + if ( ! in_array( $id, $completed, true ) ) { + $available[ $id ] = [ + 'id' => $tour['id'], + 'title' => $tour['title'], + 'description' => $tour['description'], + 'stepCount' => count( $tour['steps'] ), + ]; + } + } + + return $available; + } + + /** + * AJAX: Update tour progress. + */ + public function ajax_update_progress(): void { + check_ajax_referer( 'pp_guided_tour', 'nonce' ); + + $step = isset( $_POST['step'] ) ? absint( $_POST['step'] ) : 0; + + $state = $this->get_state(); + $state['step'] = $step; + + $this->update_state( $state ); + + // Get next step's page URL. + $next_page = ''; + $tour_id = (string) ( $state['tour_id'] ?? '' ); + if ( $tour_id && isset( $this->tours[ $tour_id ] ) ) { + $tour = $this->tours[ $tour_id ]; + if ( isset( $tour['steps'][ $step ] ) ) { + $next_page = admin_url( $tour['steps'][ $step ]['page'] ); + } + } + + wp_send_json_success( + [ + 'state' => $state, + 'nextPage' => $next_page, + ] + ); + } + + /** + * AJAX: Start a tour. + */ + public function ajax_start_tour(): void { + check_ajax_referer( 'pp_guided_tour', 'nonce' ); + + $tour_id = isset( $_POST['tour_id'] ) ? sanitize_key( $_POST['tour_id'] ) : ''; + + $result = $this->start_tour( $tour_id ); + + if ( false === $result ) { + wp_send_json_error( [ 'message' => __( 'Invalid tour.', 'progress-planner' ) ] ); + } + + // Get first step's page URL. + $first_page = ''; + if ( isset( $this->tours[ $tour_id ]['steps'][0] ) ) { + $first_page = admin_url( $this->tours[ $tour_id ]['steps'][0]['page'] ); + } + + wp_send_json_success( + [ + 'state' => $result['state'], + 'tour' => $result['tour'], + 'firstPage' => $first_page, + ] + ); + } + + /** + * AJAX: Skip/dismiss current tour. + */ + public function ajax_skip_tour(): void { + check_ajax_referer( 'pp_guided_tour', 'nonce' ); + + $state = $this->get_state(); + $state['active'] = false; + + $this->update_state( $state ); + + wp_send_json_success( [ 'state' => $state ] ); + } + + /** + * AJAX: Complete current tour. + */ + public function ajax_complete_tour(): void { + check_ajax_referer( 'pp_guided_tour', 'nonce' ); + + $state = $this->get_state(); + + // Add to completed list. + if ( ! empty( $state['tour_id'] ) ) { + $state['completed'] = $state['completed'] ?? []; + $state['completed'][] = $state['tour_id']; + $state['completed'] = array_unique( $state['completed'] ); + } + + $state['active'] = false; + $state['tour_id'] = ''; + $state['step'] = 0; + + $this->update_state( $state ); + + wp_send_json_success( [ 'state' => $state ] ); + } + + /** + * Render the "continue tour" prompt container. + */ + public function render_continue_prompt(): void { + $state = $this->get_state(); + if ( empty( $state['active'] ) ) { + return; + } + ?> + + get_state(); + + // Check if there's an active tour at frontend step. + if ( empty( $state['active'] ) || empty( $state['tour_id'] ) ) { + return; + } + + $tour_id = (string) $state['tour_id']; + $tour = $this->tours[ $tour_id ] ?? null; + if ( ! $tour ) { + return; + } + + $step_index = (int) ( $state['step'] ?? 0 ); + $current_step = $tour['steps'][ $step_index ] ?? null; + if ( ! $current_step || ( $current_step['context'] ?? '' ) !== 'frontend' ) { + return; + } + + ?> +
+
+ + +
+ +
+ + + + + +
+
+ +
+

+ +

+

+ +

+ + +

+ +

+ +
+ +
+ +
+
+
+ init(); + + // Add AJAX handler for dismissing tour prompts. + add_action( 'wp_ajax_pp_dismiss_guided_tour_prompt', [ __CLASS__, 'ajax_dismiss_prompt' ] ); + + // Add tour launcher to dashboard widget. + add_action( 'progress_planner_dashboard_sidebar', [ __CLASS__, 'render_dashboard_launcher' ] ); + + // Maybe show first-time user prompt. + add_action( 'admin_notices', [ __CLASS__, 'maybe_show_first_time_prompt' ] ); + + // Auto-start tour for new installations. + add_action( 'progress_planner_activated', [ __CLASS__, 'maybe_auto_start_tour' ] ); + } + + /** + * Get the tour instance. + * + * @return Guided_Tour Tour instance. + */ + public static function get_tour(): Guided_Tour { + return self::$tour; + } + + /** + * Get the launcher instance. + * + * @return Tour_Launcher Launcher instance. + */ + public static function get_launcher(): Tour_Launcher { + return self::$launcher; + } + + /** + * Render tour launcher in dashboard sidebar. + */ + public static function render_dashboard_launcher(): void { + if ( ! self::$launcher ) { + return; + } + + self::$launcher->render( + [ + 'title' => __( 'Guided Tours', 'progress-planner' ), + 'description' => __( 'Learn Progress Planner with interactive walkthroughs.', 'progress-planner' ), + ] + ); + } + + /** + * Show prompt for first-time users on Progress Planner pages. + */ + public static function maybe_show_first_time_prompt(): void { + if ( ! self::$launcher ) { + return; + } + + // Only on Progress Planner pages. + $screen = get_current_screen(); + if ( ! $screen || ! str_contains( $screen->id, 'progress-planner' ) ) { + return; + } + + // Show first-time setup tour prompt. + self::$launcher->maybe_show_prompt( + 'first-time-setup', + [ + 'title' => __( 'Welcome to Progress Planner!', 'progress-planner' ), + 'description' => __( 'Take a quick tour to learn how to get the most out of your new tool.', 'progress-planner' ), + ] + ); + } + + /** + * Maybe auto-start the hosting setup tour for new installations. + */ + public static function maybe_auto_start_tour(): void { + if ( ! self::$tour ) { + return; + } + + // Check if this is a hosting partner installation. + $is_hosting_install = apply_filters( 'progress_planner_is_hosting_install', false ); + + if ( $is_hosting_install ) { + // Start the hosting setup tour automatically. + self::$tour->start_tour( 'hosting-setup' ); + } + } + + /** + * AJAX handler for dismissing tour prompts. + */ + public static function ajax_dismiss_prompt(): void { + check_ajax_referer( 'pp_dismiss_guided_tour_prompt', 'nonce' ); + + $tour_id = isset( $_POST['tour_id'] ) ? sanitize_key( $_POST['tour_id'] ) : ''; + + if ( $tour_id ) { + update_user_meta( + get_current_user_id(), + 'pp_guided_tour_prompt_dismissed_' . $tour_id, + true + ); + } + + wp_send_json_success(); + } + + /** + * Programmatically start a tour. + * + * Useful for triggering tours from other parts of the plugin. + * + * @param string $tour_id Tour identifier. + * @return bool Success. + */ + public static function start_tour( string $tour_id ): bool { + if ( ! self::$tour ) { + return false; + } + + $result = self::$tour->start_tour( $tour_id ); + return false !== $result; + } + + /** + * Check if a tour has been completed. + * + * @param string $tour_id Tour identifier. + * @return bool True if completed. + */ + public static function is_tour_completed( string $tour_id ): bool { + if ( ! self::$tour ) { + return false; + } + + $state = self::$tour->get_state(); + return in_array( $tour_id, $state['completed'] ?? [], true ); + } + + /** + * Reset a tour so it can be taken again. + * + * @param string $tour_id Tour identifier. + * @return bool Success. + */ + public static function reset_tour( string $tour_id ): bool { + if ( ! self::$tour ) { + return false; + } + + $state = self::$tour->get_state(); + + // Remove from completed list. + $state['completed'] = array_filter( + $state['completed'] ?? [], + fn( $id ) => $id !== $tour_id + ); + + // Clear dismissed prompt. + delete_user_meta( get_current_user_id(), 'pp_guided_tour_prompt_dismissed_' . $tour_id ); + + return self::$tour->update_state( $state ); + } +} + +/* + * ========================================================================== + * USAGE EXAMPLES + * ========================================================================== + * + * 1. Initialize in main plugin file: + * ---------------------------------- + * + * add_action( 'plugins_loaded', [ 'Progress_Planner\Guided_Tour\Integration', 'init' ] ); + * + * + * 2. Render tour launcher widget anywhere: + * ---------------------------------------- + * + * $launcher = Progress_Planner\Guided_Tour\Integration::get_launcher(); + * $launcher->render(); + * + * + * 3. Render a single tour button: + * ------------------------------- + * + * $launcher = Progress_Planner\Guided_Tour\Integration::get_launcher(); + * $launcher->render_button( 'hosting-setup', [ + * 'text' => 'Setup Hosting Integration', + * 'class' => 'button button-primary button-hero', + * ] ); + * + * + * 4. Programmatically start a tour: + * --------------------------------- + * + * Progress_Planner\Guided_Tour\Integration::start_tour( 'hosting-setup' ); + * + * + * 5. Check if user completed a tour: + * ---------------------------------- + * + * if ( ! Progress_Planner\Guided_Tour\Integration::is_tour_completed( 'first-time-setup' ) ) { + * // Show guided tour prompt + * } + * + * + * 6. Add custom tour via filter: + * ------------------------------ + * + * add_filter( 'progress_planner_guided_tours', function( $tours ) { + * $tours['my-custom-tour'] = [ + * 'id' => 'my-custom-tour', + * 'title' => 'Custom Feature Tour', + * 'description' => 'Learn about this custom feature.', + * 'steps' => [ + * [ + * 'page' => 'admin.php?page=progress-planner', + * 'element' => '.my-feature-element', + * 'title' => 'Step 1', + * 'description' => 'This is the first step.', + * 'side' => 'bottom', + * ], + * // ... more steps + * ], + * ]; + * return $tours; + * } ); + * + * + * 7. Start tour on hosting partner installs: + * ------------------------------------------ + * + * add_filter( 'progress_planner_is_hosting_install', function( $is_hosting ) { + * // Check for hosting-specific constant or option + * return defined( 'HOSTING_PARTNER_INSTALL' ) && HOSTING_PARTNER_INSTALL; + * } ); + * + */ diff --git a/guided-tour/class-tour-launcher.php b/guided-tour/class-tour-launcher.php new file mode 100644 index 000000000..cbf7ef53b --- /dev/null +++ b/guided-tour/class-tour-launcher.php @@ -0,0 +1,222 @@ +tour = $tour; + } + + /** + * Render the tour launcher widget. + * + * @param array $args Widget arguments. + */ + public function render( array $args = [] ): void { + $args = wp_parse_args( + $args, + [ + 'title' => __( 'Guided Tours', 'progress-planner' ), + 'description' => __( 'Get started with interactive walkthroughs.', 'progress-planner' ), + 'show_all' => true, + 'tour_ids' => [], // Specific tours to show, empty = all. + ] + ); + + $available_tours = $this->tour->get_available_tours(); + + // Filter to specific tours if requested. + if ( ! empty( $args['tour_ids'] ) ) { + $available_tours = array_intersect_key( + $available_tours, + array_flip( $args['tour_ids'] ) + ); + } + + if ( empty( $available_tours ) ) { + return; + } + + ?> +
+

+

+ + +
+ tour->get_available_tours(); + + if ( ! isset( $available_tours[ $tour_id ] ) ) { + return; + } + + $tour = $available_tours[ $tour_id ]; + $args = wp_parse_args( + $args, + [ + 'text' => sprintf( + /* translators: %s: tour title */ + __( 'Take the %s tour', 'progress-planner' ), + $tour['title'] + ), + 'class' => 'button button-secondary', + ] + ); + + ?> + + tour->get_available_tours(); + return isset( $available_tours[ $tour_id ] ); + } + + /** + * Maybe show tour prompt for first-time users. + * + * @param string $tour_id Tour identifier. + * @param array $args Prompt arguments. + */ + public function maybe_show_prompt( string $tour_id, array $args = [] ): void { + // Check if this is a new user who should see the prompt. + $user_id = get_current_user_id(); + $dismissed_key = 'pp_guided_tour_prompt_dismissed_' . $tour_id; + + if ( get_user_meta( $user_id, $dismissed_key, true ) ) { + return; + } + + if ( ! $this->is_tour_available( $tour_id ) ) { + return; + } + + $available_tours = $this->tour->get_available_tours(); + $tour = $available_tours[ $tour_id ]; + + $args = wp_parse_args( + $args, + [ + 'title' => sprintf( + /* translators: %s: tour title */ + __( 'New here? Try the %s tour!', 'progress-planner' ), + $tour['title'] + ), + 'description' => $tour['description'], + 'dismissible' => true, + ] + ); + + ?> +
+

+
+ +

+

+ +

+
+ + { + this.bindFrontendEvents(); + } ); + }, + + /** + * Initialize block editor context. + */ + initEditor() { + console.log( 'PP Guided Tour: Initializing editor context', config ); + + // Wait for editor to be ready. + if ( typeof wp !== 'undefined' && wp.domReady ) { + wp.domReady( () => { + console.log( 'PP Guided Tour: wp.domReady fired' ); + // Open Progress Planner sidebar. + this.openProgressPlannerSidebar(); + // Wait for driver.js and editor to be ready. + this.waitForDriver( () => { + setTimeout( () => this.resumeEditorTour(), 1000 ); + } ); + } ); + } else { + // Fallback. + console.log( 'PP Guided Tour: Using fallback timeout' ); + this.openProgressPlannerSidebar(); + this.waitForDriver( () => { + setTimeout( () => this.resumeEditorTour(), 2000 ); + } ); + } + }, + + /** + * Open the Progress Planner sidebar in the block editor. + */ + openProgressPlannerSidebar() { + // Find the Progress Planner sidebar toggle button. + const sidebarButton = document.querySelector( + 'button[aria-label="Progress Planner Sidebar"]' + ); + + if ( ! sidebarButton ) { + // Button not found yet, retry after a short delay. + setTimeout( () => this.openProgressPlannerSidebar(), 500 ); + return; + } + + // Check if sidebar is already open. + if ( sidebarButton.getAttribute( 'aria-expanded' ) === 'true' ) { + return; + } + + // Click to open the sidebar. + sidebarButton.click(); + }, + + /** + * Wait for driver.js to be available. + * + * @param {Function} callback Callback when ready. + * @param {number} attempts Number of attempts. + */ + waitForDriver( callback, attempts = 0 ) { + const driver = this.getDriver(); + if ( driver ) { + callback(); + return; + } + + if ( attempts > 20 ) { + console.error( 'PP Guided Tour: driver.js not available after 20 attempts' ); + return; + } + + console.log( 'PP Guided Tour: Waiting for driver.js...', window.driver ); + setTimeout( () => this.waitForDriver( callback, attempts + 1 ), 250 ); + }, + + /** + * Get the driver function from window.driver. + * + * @return {Function|null} The driver function or null. + */ + getDriver() { + // Try different possible locations + if ( typeof window.driver === 'function' ) { + return window.driver; + } + if ( typeof window.driver?.driver === 'function' ) { + return window.driver.driver; + } + if ( typeof window.driver?.js?.driver === 'function' ) { + return window.driver.js.driver; + } + return null; + }, + + /** + * Initialize admin context. + */ + initAdmin() { + // Driver.js will be checked when needed. + + // Check if there's an active tour. + if ( config.state?.active && config.activeTour ) { + this.resumeTour(); + } + + // Set up event listeners for tour triggers. + this.bindEvents(); + }, + + /** + * Bind frontend event listeners. + */ + bindFrontendEvents() { + const welcomeCard = document.getElementById( 'pp-guided-tour-welcome' ); + if ( ! welcomeCard ) { + return; + } + + // Close button. + const closeBtn = welcomeCard.querySelector( '.pp-guided-tour-welcome-close' ); + if ( closeBtn ) { + closeBtn.addEventListener( 'click', () => this.skipTour() ); + } + + // Skip button. + const skipBtn = welcomeCard.querySelector( '.pp-guided-tour-welcome-skip' ); + if ( skipBtn ) { + skipBtn.addEventListener( 'click', () => this.skipTour() ); + } + + // Click on the card content to highlight the Edit Page link. + const cardContent = welcomeCard.querySelector( '.pp-guided-tour-welcome-text' ); + if ( cardContent ) { + cardContent.style.cursor = 'pointer'; + cardContent.addEventListener( 'click', () => this.highlightEditPageLink() ); + } + + // Watch for clicks on admin bar "Edit Page" link. + const editPageLink = document.querySelector( '#wp-admin-bar-edit a' ); + if ( editPageLink ) { + editPageLink.addEventListener( 'click', async ( e ) => { + e.preventDefault(); + const href = editPageLink.href; + + // Destroy any active driver instance. + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + + // Update progress to next step before navigating. + await this.updateProgress( 1 ); + + // Navigate to the editor. + window.location.href = href; + } ); + } + }, + + /** + * Highlight the Edit Page link in the admin bar. + */ + highlightEditPageLink() { + const driver = this.getDriver(); + if ( ! driver ) { + console.error( 'PP Guided Tour: driver.js not available for highlight' ); + return; + } + + const editPageLink = document.querySelector( '#wp-admin-bar-edit' ); + if ( ! editPageLink ) { + console.error( 'PP Guided Tour: Edit Page link not found in admin bar' ); + return; + } + + // Hide the welcome card. + const welcomeCard = document.getElementById( 'pp-guided-tour-welcome' ); + if ( welcomeCard ) { + welcomeCard.style.display = 'none'; + } + + // Add active class to body to adjust z-indexes. + document.body.classList.add( 'pp-guided-tour-active' ); + + // Track if user completed via Done button vs closed via X. + let completedViaDone = false; + + // Use driver.js to highlight the Edit Page link. + this.driverInstance = driver( { + showProgress: false, + steps: [ + { + element: '#wp-admin-bar-edit', + popover: { + title: config.i18n.editPageTitle || 'Edit Page', + description: config.i18n.editPageDesc || 'Click here to edit your homepage.', + side: 'bottom', + align: 'center', + }, + }, + ], + popoverClass: 'pp-guided-tour-popover', + stagePadding: 4, + stageRadius: 4, + allowClose: true, + onNextClick: () => { + // Done button clicked - user wants to proceed. + completedViaDone = true; + document.body.classList.remove( 'pp-guided-tour-active' ); + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + // Don't show welcome card - user is ready to click Edit Page. + }, + onCloseClick: () => { + // X button clicked - user wants to dismiss temporarily. + document.body.classList.remove( 'pp-guided-tour-active' ); + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + // Show the welcome card again. + if ( welcomeCard ) { + welcomeCard.style.display = 'block'; + } + }, + onDestroyStarted: () => { + // Only show welcome card if not completed via Done. + document.body.classList.remove( 'pp-guided-tour-active' ); + if ( ! completedViaDone && welcomeCard ) { + welcomeCard.style.display = 'block'; + } + }, + } ); + + this.driverInstance.drive(); + }, + + /** + * Resume tour in block editor. + */ + resumeEditorTour() { + console.log( 'PP Guided Tour: resumeEditorTour called', { + activeTour: config.activeTour, + state: config.state, + } ); + + if ( ! config.activeTour || ! config.state?.active ) { + console.log( 'PP Guided Tour: No active tour, exiting' ); + return; + } + + const currentStepIndex = config.state.step || 0; + const step = config.activeTour.steps[ currentStepIndex ]; + + console.log( 'PP Guided Tour: Current step', { currentStepIndex, step } ); + + if ( ! step || step.context !== 'editor' ) { + console.log( 'PP Guided Tour: Step not for editor context, exiting' ); + return; + } + + // Find the element based on step configuration. + const result = this.findEditorElement( step.element ); + + console.log( 'PP Guided Tour: Found element', { elementType: step.element, result } ); + + if ( ! result.element ) { + console.warn( 'Guided Tour: Could not find element:', step.element ); + // Show a fallback popover or skip to next step. + this.showEditorFallbackPopover( step ); + return; + } + + // Show popover - handle iframe case differently. + if ( result.isInIframe ) { + this.showEditorPopoverForIframe( result, step, currentStepIndex ); + } else { + this.showEditorPopover( result.element, step, currentStepIndex ); + } + }, + + /** + * Find element in block editor. + * + * @param {string} elementType Type of element to find. + * @return {Object} Object with element, isInIframe, and iframe reference. + */ + findEditorElement( elementType ) { + const editorCanvas = document.querySelector( 'iframe[name="editor-canvas"]' ); + const isInIframe = !! editorCanvas; + const doc = editorCanvas ? editorCanvas.contentDocument : document; + + let element = null; + + switch ( elementType ) { + case 'first-heading': + // Look for first heading block in editor. + element = doc.querySelector( + '.wp-block-heading, ' + + '[data-type="core/heading"], ' + + '.editor-post-title__input, ' + + '.wp-block-post-title' + ); + break; + + case 'first-paragraph': + // Look for first paragraph block in editor. + element = doc.querySelector( + '.wp-block-paragraph, ' + + '[data-type="core/paragraph"]' + ); + break; + + case 'first-image': + // Look for first image block in editor. + element = doc.querySelector( + '.wp-block-image, ' + + '[data-type="core/image"], ' + + '.wp-block-cover, ' + + '[data-type="core/cover"]' + ); + break; + + case 'save-button': + // Save/Update button is in the main document, not the iframe. + element = document.querySelector( + '.editor-post-publish-button, ' + + '.editor-post-save-draft, ' + + 'button.editor-post-publish-button__button' + ); + // Return early with isInIframe false since save button is in main UI. + return { + element, + isInIframe: false, + iframe: null, + doc: document, + }; + + default: + // Try as a CSS selector. + element = doc.querySelector( elementType ); + } + + return { + element, + isInIframe, + iframe: editorCanvas, + doc, + }; + }, + + /** + * Show popover for editor step when content is in an iframe. + * + * @param {Object} result Result from findEditorElement. + * @param {Object} step Step configuration. + * @param {number} stepIndex Current step index. + */ + showEditorPopoverForIframe( result, step, stepIndex ) { + const { element, iframe, doc } = result; + + // Add highlight class to element inside iframe. + element.classList.add( 'pp-guided-tour-highlight' ); + + // Inject highlight styles into iframe if not already there. + if ( ! doc.getElementById( 'pp-guided-tour-iframe-styles' ) ) { + const style = doc.createElement( 'style' ); + style.id = 'pp-guided-tour-iframe-styles'; + style.textContent = ` + .pp-guided-tour-highlight { + outline: 3px solid #667eea !important; + outline-offset: 4px !important; + border-radius: 4px !important; + animation: pp-highlight-pulse 1.5s ease-in-out infinite !important; + } + @keyframes pp-highlight-pulse { + 0%, 100% { outline-color: #667eea; } + 50% { outline-color: #764ba2; } + } + `; + doc.head.appendChild( style ); + } + + // Scroll element into view. + element.scrollIntoView( { behavior: 'smooth', block: 'center' } ); + + // Create custom popover positioned relative to iframe. + this.showCustomEditorPopover( element, iframe, step, stepIndex ); + }, + + /** + * Show custom popover for iframe editor. + * + * @param {HTMLElement} element Target element inside iframe. + * @param {HTMLElement} iframe The iframe element. + * @param {Object} step Step configuration. + * @param {number} stepIndex Current step index. + */ + showCustomEditorPopover( element, iframe, step, stepIndex ) { + // Remove any existing custom popover. + const existingPopover = document.getElementById( 'pp-guided-tour-custom-popover' ); + if ( existingPopover ) { + existingPopover.remove(); + } + + // Build description with optional hint. + let hintHtml = ''; + if ( step.hint ) { + hintHtml = `
${step.hint}
`; + } + + // Check if there are more steps. + const hasMoreSteps = config.activeTour.steps.length > stepIndex + 1; + + // Create popover element. + const popover = document.createElement( 'div' ); + popover.id = 'pp-guided-tour-custom-popover'; + popover.className = 'pp-guided-tour-custom-popover'; + popover.innerHTML = ` +
+ +

${step.title}

+

${step.description}

+ ${hintHtml} + +
+ `; + + document.body.appendChild( popover ); + + // Position the popover. + this.positionCustomPopover( popover, element, iframe ); + + // Bind events. + popover.querySelector( '.pp-guided-tour-custom-popover-close' ).addEventListener( 'click', () => { + this.cleanupIframeHighlight( element ); + popover.remove(); + this.skipTour(); + } ); + + popover.querySelector( '.pp-guided-tour-custom-popover-skip' ).addEventListener( 'click', () => { + this.cleanupIframeHighlight( element ); + popover.remove(); + this.handleEditorStepComplete( stepIndex ); + } ); + + popover.querySelector( '.pp-guided-tour-custom-popover-next' ).addEventListener( 'click', () => { + this.cleanupIframeHighlight( element ); + popover.remove(); + this.handleEditorStepComplete( stepIndex ); + } ); + + // Reposition on scroll/resize. + const repositionHandler = () => this.positionCustomPopover( popover, element, iframe ); + window.addEventListener( 'resize', repositionHandler ); + iframe.contentWindow?.addEventListener( 'scroll', repositionHandler ); + + // Store cleanup function. + this.customPopoverCleanup = () => { + window.removeEventListener( 'resize', repositionHandler ); + iframe.contentWindow?.removeEventListener( 'scroll', repositionHandler ); + }; + }, + + /** + * Position custom popover relative to iframe element. + * + * @param {HTMLElement} popover The popover element. + * @param {HTMLElement} element Target element inside iframe. + * @param {HTMLElement} iframe The iframe element. + */ + positionCustomPopover( popover, element, iframe ) { + const iframeRect = iframe.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + // Calculate position relative to viewport. + const top = iframeRect.top + elementRect.top; + const left = iframeRect.left + elementRect.left; + const width = elementRect.width; + const height = elementRect.height; + + // Position popover to the right of the element. + popover.style.position = 'fixed'; + popover.style.top = `${top}px`; + popover.style.left = `${left + width + 20}px`; + popover.style.zIndex = '100001'; + + // If popover goes off screen to the right, position it to the left. + const popoverRect = popover.getBoundingClientRect(); + if ( popoverRect.right > window.innerWidth ) { + popover.style.left = `${left - popoverRect.width - 20}px`; + } + + // If popover goes off screen at bottom, adjust top. + if ( popoverRect.bottom > window.innerHeight ) { + popover.style.top = `${window.innerHeight - popoverRect.height - 20}px`; + } + }, + + /** + * Clean up iframe highlight. + * + * @param {HTMLElement} element The highlighted element. + */ + cleanupIframeHighlight( element ) { + if ( element ) { + element.classList.remove( 'pp-guided-tour-highlight' ); + } + if ( this.customPopoverCleanup ) { + this.customPopoverCleanup(); + this.customPopoverCleanup = null; + } + }, + + /** + * Show popover for editor step (non-iframe). + * + * @param {HTMLElement} element Target element. + * @param {Object} step Step configuration. + * @param {number} stepIndex Current step index. + */ + showEditorPopover( element, step, stepIndex ) { + const driver = this.getDriver(); + if ( ! driver ) { + console.error( 'PP Guided Tour: driver.js not available' ); + this.showEditorFallbackPopover( step ); + return; + } + + // Build description with optional hint. + let description = step.description; + if ( step.hint ) { + description += `

${step.hint}

`; + } + + // Check if there are more steps after this one + const hasMoreSteps = config.activeTour.steps.length > stepIndex + 1; + + this.driverInstance = driver( { + showProgress: false, + showButtons: [ 'next', 'close' ], + steps: [ + { + element: element, + popover: { + title: step.title, + description: description, + side: step.side || 'right', + align: step.align || 'start', + }, + }, + ], + nextBtnText: config.i18n.next, + doneBtnText: hasMoreSteps ? config.i18n.next : config.i18n.done, + popoverClass: 'pp-guided-tour-popover pp-guided-tour-editor-popover', + stagePadding: 10, + stageRadius: 8, + allowClose: true, + // Allow clicking on highlighted element to edit + disableActiveInteraction: false, + // Don't close on overlay click - let user edit + overlayClickBehavior: 'none', + + onNextClick: () => { + this.handleEditorStepComplete( stepIndex ); + }, + + onCloseClick: () => { + this.skipTour(); + }, + + onPopoverRender: ( popover ) => { + this.addEditorPopoverButtons( popover.wrapper, stepIndex ); + }, + } ); + + this.driverInstance.drive(); + }, + + /** + * Add custom buttons to editor popover. + * + * @param {HTMLElement} wrapper Popover wrapper. + * @param {number} stepIndex Current step index. + */ + addEditorPopoverButtons( wrapper, stepIndex ) { + const footer = wrapper.querySelector( '.driver-popover-footer' ); + if ( ! footer ) { + return; + } + + // Add skip button. + const skipBtn = document.createElement( 'button' ); + skipBtn.className = 'pp-guided-tour-skip'; + skipBtn.textContent = config.i18n.skipStep; + skipBtn.type = 'button'; + + skipBtn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + e.stopPropagation(); + this.handleEditorStepComplete( stepIndex ); + } ); + + footer.insertBefore( skipBtn, footer.firstChild ); + }, + + /** + * Handle editor step completion. + * + * @param {number} stepIndex Completed step index. + */ + async handleEditorStepComplete( stepIndex ) { + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + + const nextIndex = stepIndex + 1; + const tour = config.activeTour; + + // Check if tour is complete. + if ( ! tour.steps[ nextIndex ] ) { + this.completeTour(); + return; + } + + // Update progress. + await this.updateProgress( nextIndex ); + + // Check if next step is also in editor. + const nextStep = tour.steps[ nextIndex ]; + if ( nextStep.context === 'editor' && nextStep.page === 'front_page' ) { + // Show next step. + config.state.step = nextIndex; + setTimeout( () => this.resumeEditorTour(), 100 ); + } else { + // Need to navigate elsewhere - show continue prompt or auto-navigate. + this.showContinuePrompt( nextStep ); + } + }, + + /** + * Show fallback popover when element not found. + * + * @param {Object} step Step configuration. + */ + showEditorFallbackPopover( step ) { + // Use fallback description if available, otherwise use regular description. + const description = step.fallback_description || step.description; + + const currentStepIndex = config.state.step || 0; + const hasMoreSteps = config.activeTour.steps.length > currentStepIndex + 1; + const nextButtonText = hasMoreSteps ? config.i18n.next : config.i18n.done; + + // Create a floating popover without highlighting. + const popover = document.createElement( 'div' ); + popover.className = 'pp-guided-tour-fallback-popover'; + popover.innerHTML = ` +
+

${ step.title }

+

${ description }

+ ${ step.hint ? `

${ step.hint }

` : '' } +
+ + +
+
+ `; + + document.body.appendChild( popover ); + + popover.querySelector( '.pp-guided-tour-fallback-next' ).addEventListener( 'click', () => { + popover.remove(); + this.handleEditorStepComplete( currentStepIndex ); + } ); + + popover.querySelector( '.pp-guided-tour-fallback-skip' ).addEventListener( 'click', () => { + popover.remove(); + this.handleEditorStepComplete( currentStepIndex ); + } ); + }, + + /** + * Bind event listeners (admin context). + */ + bindEvents() { + // Tour starter buttons. + document.querySelectorAll( '[data-start-tour]' ).forEach( btn => { + btn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + const tourId = btn.dataset.startTour; + this.startTour( tourId ); + } ); + } ); + + // Continue prompt buttons. + const continueBtn = document.querySelector( '.pp-guided-tour-continue-btn' ); + const dismissBtn = document.querySelector( '.pp-guided-tour-continue-dismiss' ); + + if ( continueBtn ) { + continueBtn.addEventListener( 'click', () => this.navigateToNextStep() ); + } + if ( dismissBtn ) { + dismissBtn.addEventListener( 'click', () => this.skipTour() ); + } + + // Handle page visibility changes (tab switching). + document.addEventListener( 'visibilitychange', () => { + if ( ! document.hidden && config.state?.active ) { + this.resumeTour(); + } + } ); + }, + + /** + * Start a new tour. + * + * @param {string} tourId Tour identifier. + */ + async startTour( tourId ) { + try { + const response = await this.apiCall( 'start', { tour_id: tourId } ); + + if ( response.success ) { + config.state = response.data.state; + config.activeTour = response.data.tour; + + // Navigate to first step's page if different. + const firstStep = response.data.tour.steps[0]; + if ( firstStep ) { + this.navigateToStep( firstStep ); + } + } + } catch ( error ) { + console.error( 'Failed to start tour:', error ); + } + }, + + /** + * Navigate to a step's location. + * + * @param {Object} step Step configuration. + */ + navigateToStep( step ) { + const stepContext = step.context || 'admin'; + + switch ( stepContext ) { + case 'frontend': + if ( step.page === 'front_page' ) { + window.location.href = config.adminUrl.replace( '/wp-admin/', '/' ); + } + break; + + case 'editor': + if ( step.page === 'front_page' && config.frontPageEditUrl ) { + window.location.href = config.frontPageEditUrl; + } + break; + + case 'admin': + default: + window.location.href = config.adminUrl + step.page; + break; + } + }, + + /** + * Resume an active tour (admin context). + */ + resumeTour() { + if ( ! config.activeTour || ! config.state?.active ) { + return; + } + + const currentStep = config.state.step || 0; + const step = config.activeTour.steps[ currentStep ]; + + if ( ! step ) { + this.completeTour(); + return; + } + + // Check context matches. + const stepContext = step.context || 'admin'; + if ( stepContext !== 'admin' ) { + // Show continue prompt to navigate to correct context. + this.showContinuePrompt( step ); + return; + } + + // Check if we're on the correct page for this step. + if ( this.isOnPage( step.page ) ) { + // Small delay to ensure DOM is ready. + setTimeout( () => this.runStep( currentStep ), 100 ); + } else { + // Show continue prompt. + this.showContinuePrompt( step ); + } + }, + + /** + * Run a specific tour step (admin context). + * + * @param {number} stepIndex Step index. + */ + runStep( stepIndex ) { + const driver = this.getDriver(); + if ( ! driver ) { + console.error( 'PP Guided Tour: driver.js not available for admin step' ); + return; + } + const tour = config.activeTour; + const step = tour.steps[ stepIndex ]; + + if ( ! step ) { + this.completeTour(); + return; + } + + // Check if element exists. + const element = document.querySelector( step.element ); + if ( ! element ) { + console.warn( `Element not found: ${step.element}. Skipping to next step.` ); + // Element doesn't exist, try next step on same page or show continue prompt. + const nextStep = tour.steps[ stepIndex + 1 ]; + if ( nextStep && this.isOnPage( nextStep.page ) ) { + this.updateProgress( stepIndex + 1 ); + this.runStep( stepIndex + 1 ); + } else if ( nextStep ) { + config.state.step = stepIndex + 1; + this.showContinuePrompt( nextStep ); + } else { + this.completeTour(); + } + return; + } + + // Configure driver.js. + this.driverInstance = driver( { + showProgress: true, + showButtons: [ 'next', 'close' ], + steps: this.buildDriverSteps( tour.steps, stepIndex ), + progressText: `{{current}} / {{total}}`, + nextBtnText: config.i18n.next, + doneBtnText: config.i18n.done, + popoverClass: 'pp-guided-tour-popover', + stagePadding: 10, + stageRadius: 8, + + onNextClick: ( element, step, opts ) => { + const nextIndex = stepIndex + opts.state.activeIndex + 1; + this.handleStepTransition( nextIndex ); + }, + + onCloseClick: () => { + this.skipTour(); + }, + + onDestroyStarted: () => { + // Called when tour is being destroyed. + }, + + onDestroyed: () => { + // Cleanup. + }, + + onPopoverRender: ( popover, opts ) => { + // Add skip button to popover. + this.addSkipButton( popover.wrapper ); + }, + } ); + + // Start the tour. + this.driverInstance.drive(); + }, + + /** + * Build driver.js steps array for current page. + * + * @param {Array} allSteps All tour steps. + * @param {number} startIndex Starting index. + * @return {Array} Steps for driver.js. + */ + buildDriverSteps( allSteps, startIndex ) { + const driverSteps = []; + const currentPageSteps = []; + + // Collect consecutive steps on the same page. + for ( let i = startIndex; i < allSteps.length; i++ ) { + const step = allSteps[ i ]; + const stepContext = step.context || 'admin'; + + if ( stepContext === 'admin' && this.isOnPage( step.page ) && document.querySelector( step.element ) ) { + currentPageSteps.push( { + index: i, + step: step, + } ); + } else { + break; + } + } + + // Convert to driver.js format. + currentPageSteps.forEach( ( { step }, idx ) => { + driverSteps.push( { + element: step.element, + popover: { + title: step.title, + description: step.description, + side: step.side || 'bottom', + align: step.align || 'center', + }, + } ); + } ); + + return driverSteps; + }, + + /** + * Handle transition between steps. + * + * @param {number} nextIndex Next step index. + */ + async handleStepTransition( nextIndex ) { + if ( this.isNavigating ) { + return; + } + + const tour = config.activeTour; + const nextStep = tour.steps[ nextIndex ]; + + // Tour completed. + if ( ! nextStep ) { + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + this.completeTour(); + return; + } + + // Update server state. + await this.updateProgress( nextIndex ); + + const nextContext = nextStep.context || 'admin'; + + // Check if next step is on current page and same context. + if ( nextContext === 'admin' && this.isOnPage( nextStep.page ) ) { + // Just move driver to next step (it handles this internally). + if ( this.driverInstance ) { + this.driverInstance.moveNext(); + } + } else { + // Need to navigate to different page/context. + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + this.navigateToStep( nextStep ); + } + }, + + /** + * Update tour progress on server. + * + * @param {number} step Step index. + * @return {Promise} API response. + */ + async updateProgress( step ) { + config.state.step = step; + + try { + const response = await this.apiCall( 'update', { step } ); + return response; + } catch ( error ) { + console.error( 'Failed to update progress:', error ); + } + }, + + /** + * Skip/dismiss the current tour. + */ + async skipTour() { + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + + this.hideContinuePrompt(); + this.hideWelcomeCard(); + + try { + await this.apiCall( 'skip' ); + config.state.active = false; + } catch ( error ) { + console.error( 'Failed to skip tour:', error ); + } + }, + + /** + * Complete the current tour. + */ + async completeTour() { + if ( this.driverInstance ) { + this.driverInstance.destroy(); + } + + this.hideContinuePrompt(); + + try { + await this.apiCall( 'complete' ); + config.state.active = false; + + // Show completion message. + this.showCompletionMessage(); + } catch ( error ) { + console.error( 'Failed to complete tour:', error ); + } + }, + + /** + * Show continue prompt when next step is on different page. + * + * @param {Object} nextStep Next step configuration. + */ + showContinuePrompt( nextStep ) { + const prompt = document.getElementById( 'pp-guided-tour-continue-prompt' ); + if ( ! prompt ) { + return; + } + + const title = prompt.querySelector( '.pp-guided-tour-continue-title' ); + const message = prompt.querySelector( '.pp-guided-tour-continue-message' ); + + if ( title ) { + title.textContent = config.i18n.continueTitle; + } + if ( message ) { + message.textContent = config.i18n.continueMessage; + } + + prompt.style.display = 'block'; + + // Determine next page URL based on context. + const nextContext = nextStep.context || 'admin'; + let nextPageUrl = ''; + + switch ( nextContext ) { + case 'frontend': + if ( nextStep.page === 'front_page' ) { + nextPageUrl = config.adminUrl.replace( '/wp-admin/', '/' ); + } + break; + + case 'editor': + if ( nextStep.page === 'front_page' ) { + nextPageUrl = config.frontPageEditUrl; + } + break; + + case 'admin': + default: + nextPageUrl = config.adminUrl + nextStep.page; + break; + } + + prompt.dataset.nextPage = nextPageUrl; + }, + + /** + * Hide continue prompt. + */ + hideContinuePrompt() { + const prompt = document.getElementById( 'pp-guided-tour-continue-prompt' ); + if ( prompt ) { + prompt.style.display = 'none'; + } + }, + + /** + * Hide welcome card (frontend). + */ + hideWelcomeCard() { + const card = document.getElementById( 'pp-guided-tour-welcome' ); + if ( card ) { + card.style.display = 'none'; + } + }, + + /** + * Navigate to the next step's page. + */ + navigateToNextStep() { + const prompt = document.getElementById( 'pp-guided-tour-continue-prompt' ); + if ( prompt && prompt.dataset.nextPage ) { + window.location.href = prompt.dataset.nextPage; + } + }, + + /** + * Navigate to a specific page (admin context). + * + * @param {string} page Page path. + */ + navigateToPage( page ) { + this.isNavigating = true; + window.location.href = config.adminUrl + page; + }, + + /** + * Check if current page matches a step's page. + * + * @param {string} stepPage Step page path. + * @return {boolean} True if on the same page. + */ + isOnPage( stepPage ) { + const current = config.currentPage; + + // Parse both URLs for comparison. + const currentParams = this.parsePageUrl( current ); + const stepParams = this.parsePageUrl( stepPage ); + + // Must match base page. + if ( currentParams.base !== stepParams.base ) { + return false; + } + + // Check required query params from step. + for ( const [ key, value ] of Object.entries( stepParams.params ) ) { + if ( currentParams.params[ key ] !== value ) { + return false; + } + } + + return true; + }, + + /** + * Parse a page URL into base and params. + * + * @param {string} url URL string. + * @return {Object} Parsed URL. + */ + parsePageUrl( url ) { + const [ base, queryString ] = url.split( '?' ); + const params = {}; + + if ( queryString ) { + queryString.split( '&' ).forEach( pair => { + const [ key, value ] = pair.split( '=' ); + params[ key ] = value; + } ); + } + + return { base, params }; + }, + + /** + * Add skip button to popover. + * + * @param {HTMLElement} wrapper Popover wrapper element. + */ + addSkipButton( wrapper ) { + // Check if already added. + if ( wrapper.querySelector( '.pp-guided-tour-skip' ) ) { + return; + } + + const skipBtn = document.createElement( 'button' ); + skipBtn.className = 'pp-guided-tour-skip'; + skipBtn.textContent = config.i18n.skip; + skipBtn.type = 'button'; + + skipBtn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + e.stopPropagation(); + this.skipTour(); + } ); + + const footer = wrapper.querySelector( '.driver-popover-footer' ); + if ( footer ) { + footer.insertBefore( skipBtn, footer.firstChild ); + } + }, + + /** + * Show tour completion message. + */ + showCompletionMessage() { + // Create a simple notification. + const notification = document.createElement( 'div' ); + notification.className = 'pp-guided-tour-complete-notice notice notice-success is-dismissible'; + notification.innerHTML = ` +

${config.activeTour?.title || 'Tour'} completed!

+ + `; + + // Insert after the first h1 or at start of content. + const target = document.querySelector( '.wrap h1' ) || document.querySelector( '#wpbody-content' ); + if ( target ) { + target.parentNode.insertBefore( notification, target.nextSibling ); + + // Handle dismiss. + notification.querySelector( '.notice-dismiss' ).addEventListener( 'click', () => { + notification.remove(); + } ); + + // Auto-dismiss after 5 seconds. + setTimeout( () => notification.remove(), 5000 ); + } + }, + + /** + * Make API call to tour endpoints. + * + * @param {string} action Action name. + * @param {Object} data Additional data. + * @return {Promise} Response promise. + */ + async apiCall( action, data = {} ) { + const formData = new FormData(); + formData.append( 'action', `pp_guided_tour_${action}` ); + formData.append( 'nonce', config.nonce ); + + Object.entries( data ).forEach( ( [ key, value ] ) => { + formData.append( key, value ); + } ); + + const response = await fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: formData, + } ); + + return response.json(); + }, + }; + + // Initialize when DOM is ready. + if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', () => TourManager.init() ); + } else { + TourManager.init(); + } + + // Expose for external use. + window.ppGuidedTourManager = TourManager; + +} )(); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ca24110c3..ecff7c247 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,7 @@ parameters: paths: - ./classes - ./views + - ./guided-tour - ./progress-planner.php - ./uninstall.php ignoreErrors: From 28bef27f8e5b047347d6eebd8efa22a86c84f945 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Thu, 15 Jan 2026 16:54:57 +0100 Subject: [PATCH 2/9] remove "previous step" button --- guided-tour/README.md | 35 +++++++++++++++++++++++++++++++++++ guided-tour/guided-tour.js | 1 + 2 files changed, 36 insertions(+) diff --git a/guided-tour/README.md b/guided-tour/README.md index 59efcfe42..c1541b6bc 100644 --- a/guided-tour/README.md +++ b/guided-tour/README.md @@ -282,6 +282,41 @@ For tours spanning frontend and editor: 5. Special elements resolved inside iframe canvas when applicable 6. Final step highlights Save button, tour completes +## Testing + +### Local Development + +1. Add this filter to enable the tour: + ```php + add_filter( 'progress_planner_is_hosting_install', '__return_true' ); + ``` + +2. Visit the site's homepage (not wp-admin) while logged in as admin + +3. A welcome card appears in the bottom-right corner - click it to start the tour + +4. Follow the tour: Edit Page link → heading → paragraph → image → Save button + +5. To reset and test again: + ```php + delete_user_meta( get_current_user_id(), 'pp_guided_tour_state' ); + ``` + +### Playground / Admin Bar Testing + +On the frontend homepage, an admin bar link provides easy tour control: + +| Tour State | Admin Bar Link | Action | +|------------|----------------|--------| +| No active tour | **Start Guided Tour** | Starts the `go-to-publish` tour | +| Tour in progress | **Reset Tour** | Clears tour state, allows restart | + +**URLs used:** +- Start tour: `?pp_start_tour=go-to-publish` +- Reset tour: `?pp_reset_tour=1` + +This allows testing without code changes - simply click the admin bar link to start or reset the tour. + ## Browser Support - Chrome 80+ diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index 81463c9e2..a634c9fa0 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -241,6 +241,7 @@ // Use driver.js to highlight the Edit Page link. this.driverInstance = driver( { showProgress: false, + showButtons: [ 'next', 'close' ], steps: [ { element: '#wp-admin-bar-edit', From ac405ae5b3b59e2204f22210b112620d3e0fd9fa Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 10:22:53 +0100 Subject: [PATCH 3/9] re-order blocks & add new one --- guided-tour/class-guided-tour.php | 23 +++- guided-tour/guided-tour.css | 141 +++++++++++++++++++++++ guided-tour/guided-tour.js | 185 +++++++++++++++++++++++++++++- 3 files changed, 343 insertions(+), 6 deletions(-) diff --git a/guided-tour/class-guided-tour.php b/guided-tour/class-guided-tour.php index 4873e440a..225f091b4 100644 --- a/guided-tour/class-guided-tour.php +++ b/guided-tour/class-guided-tour.php @@ -205,13 +205,34 @@ private function register_tours(): void { 'fallback_description' => __( 'We couldn\'t find any images on this page yet. Consider adding some authentic photos to make your homepage more engaging! You can add an image block using the + button in the editor.', 'progress-planner' ), 'side' => 'right', ], - // Step 4: Save the changes. + // Step 4: Reorder blocks. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'list-view-button', + 'title' => __( 'Reorder your blocks', 'progress-planner' ), + 'description' => __( 'The page consists of a number of blocks. You can change the order of these blocks. Are your blocks in the order you want them to be? Try reordering them!', 'progress-planner' ), + 'hint' => __( 'Click here to open the List View where you can drag and drop blocks to reorder them.', 'progress-planner' ), + 'side' => 'bottom', + ], + // Step 5: Add or remove blocks. + [ + 'context' => 'editor', + 'page' => 'front_page', + 'element' => 'block-inserter', + 'title' => __( 'Add or remove blocks', 'progress-planner' ), + 'description' => __( 'Perhaps you want to delete some of the blocks. Or add some! Check whether you need all the blocks that are now on your homepage.', 'progress-planner' ), + 'hint' => __( 'Click the + button to add new blocks, or select a block and press Delete to remove it.', 'progress-planner' ), + 'side' => 'bottom', + ], + // Step 6: Save the changes. [ 'context' => 'editor', 'page' => 'front_page', 'element' => 'save-button', 'title' => __( 'Save your changes', 'progress-planner' ), 'description' => __( 'Great job! Now click the Save button to publish your changes and make them live on your website.', 'progress-planner' ), + 'hint' => __( 'Your changes won\'t be visible to visitors until you save them.', 'progress-planner' ), 'side' => 'bottom', ], ], diff --git a/guided-tour/guided-tour.css b/guided-tour/guided-tour.css index 564d5f513..6da32838a 100644 --- a/guided-tour/guided-tour.css +++ b/guided-tour/guided-tour.css @@ -310,6 +310,8 @@ body.pp-guided-tour-active #wpadminbar { ========================================================================== */ .pp-guided-tour-editor-popover .pp-guided-tour-hint { + display: block; + width: 100%; font-size: 12px; line-height: 1.5; color: #757575; @@ -317,7 +319,21 @@ body.pp-guided-tour-active #wpadminbar { padding: 10px 12px; background: #f6f7f7; border-radius: 4px; + border: none; border-left: 3px solid #667eea; + text-align: left; +} + +/* Clickable hint styling */ +.pp-guided-tour-hint-clickable { + cursor: pointer; + transition: all 0.2s ease; +} + +.pp-guided-tour-hint-clickable:hover { + background: #e8f0fe; + color: #2271b1; + border-left-color: #2271b1; } /* Fallback popover for when elements aren't found */ @@ -353,13 +369,17 @@ body.pp-guided-tour-active #wpadminbar { } .pp-guided-tour-fallback-content .pp-guided-tour-hint { + display: block; + width: 100%; font-size: 12px; color: #757575; padding: 10px 12px; background: #f6f7f7; border-radius: 4px; + border: none; border-left: 3px solid #667eea; margin-bottom: 16px; + text-align: left; } .pp-guided-tour-fallback-actions { @@ -398,6 +418,123 @@ body.pp-guided-tour-active #wpadminbar { color: #1e1e1e; } +/* ========================================================================== + Fixed Popover (for toolbar buttons - no overlay) + ========================================================================== */ + +.pp-guided-tour-fixed-popover { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 100001; + animation: pp-slide-in 0.3s ease-out; +} + +.pp-guided-tour-fixed-popover-content { + position: relative; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 24px rgba( 0, 0, 0, 0.15 ); + border: 1px solid #e0e0e0; + padding: 20px; + max-width: 360px; + min-width: 300px; +} + +.pp-guided-tour-fixed-popover-close { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 4px; + color: #757575; + font-size: 20px; + line-height: 1; + cursor: pointer; + transition: all 0.2s ease; +} + +.pp-guided-tour-fixed-popover-close:hover { + background: #f0f0f1; + color: #1e1e1e; +} + +.pp-guided-tour-fixed-popover-title { + font-size: 16px; + font-weight: 600; + color: #1e1e1e; + margin: 0 0 8px 0; + padding-right: 24px; +} + +.pp-guided-tour-fixed-popover-description { + font-size: 14px; + line-height: 1.6; + color: #50575e; + margin: 0 0 12px 0; +} + +.pp-guided-tour-fixed-popover .pp-guided-tour-hint { + display: block; + width: 100%; + font-size: 12px; + line-height: 1.5; + color: #757575; + margin: 0 0 16px 0; + padding: 10px 12px; + background: #f6f7f7; + border-radius: 4px; + border: none; + border-left: 3px solid #667eea; + text-align: left; +} + +.pp-guided-tour-fixed-popover-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 12px; + border-top: 1px solid #f0f0f0; +} + +.pp-guided-tour-fixed-popover-skip { + padding: 8px 12px; + font-size: 13px; + font-weight: 500; + color: #757575; + background: transparent; + border: none; + cursor: pointer; + transition: color 0.2s ease; +} + +.pp-guided-tour-fixed-popover-skip:hover { + color: #1e1e1e; +} + +.pp-guided-tour-fixed-popover-next { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: #fff; + background: #2271b1; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s ease; +} + +.pp-guided-tour-fixed-popover-next:hover { + background: #135e96; +} + /* ========================================================================== Custom Editor Popover (for iframe editors) ========================================================================== */ @@ -460,6 +597,8 @@ body.pp-guided-tour-active #wpadminbar { } .pp-guided-tour-custom-popover .pp-guided-tour-hint { + display: block; + width: 100%; font-size: 12px; line-height: 1.5; color: #757575; @@ -467,7 +606,9 @@ body.pp-guided-tour-active #wpadminbar { padding: 10px 12px; background: #f6f7f7; border-radius: 4px; + border: none; border-left: 3px solid #667eea; + text-align: left; } .pp-guided-tour-custom-popover-footer { diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index a634c9fa0..933c28edd 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -24,6 +24,17 @@ const TourManager = { driverInstance: null, isNavigating: false, + currentStepElement: null, + + /** + * Click the current step's target element (called from hint button). + */ + clickCurrentElement() { + if ( this.currentStepElement ) { + this.currentStepElement.click(); + this.currentStepElement.focus(); + } + }, /** * Initialize the tour manager. @@ -325,9 +336,12 @@ return; } - // Show popover - handle iframe case differently. + // Show popover - handle different cases. if ( result.isInIframe ) { this.showEditorPopoverForIframe( result, step, currentStepIndex ); + } else if ( result.useFixedPopover ) { + // Use fixed popover with simple highlight (no overlay). + this.showFixedPopoverWithHighlight( result.element, step, currentStepIndex ); } else { this.showEditorPopover( result.element, step, currentStepIndex ); } @@ -375,6 +389,42 @@ ); break; + case 'list-view-button': + // List View / Document Overview button in editor toolbar (for reordering blocks). + // This is in the main document, not the iframe. + element = document.querySelector( + '.editor-document-tools__document-overview-toggle, ' + + '.edit-post-header-toolbar__document-overview-toggle, ' + + 'button[aria-label="Document Overview"], ' + + 'button[aria-label="List View"], ' + + '.edit-post-header-toolbar__list-view-toggle' + ); + return { + element, + isInIframe: false, + iframe: null, + doc: document, + useFixedPopover: true, // Use fixed popover without overlay. + }; + + case 'block-inserter': + // Block inserter (+ button) in editor toolbar. + // This is in the main document, not the iframe. + element = document.querySelector( + 'button[aria-label="Block Inserter"], ' + + '.editor-document-tools__inserter-toggle, ' + + '.edit-post-header-toolbar__inserter-toggle, ' + + 'button[aria-label="Toggle block inserter"], ' + + '.block-editor-inserter__toggle' + ); + return { + element, + isInIframe: false, + iframe: null, + doc: document, + useFixedPopover: true, // Use fixed popover without overlay. + }; + case 'save-button': // Save/Update button is in the main document, not the iframe. element = document.querySelector( @@ -457,10 +507,10 @@ existingPopover.remove(); } - // Build description with optional hint. + // Build description with optional hint (clickable). let hintHtml = ''; if ( step.hint ) { - hintHtml = `
${step.hint}
`; + hintHtml = ``; } // Check if there are more steps. @@ -507,6 +557,18 @@ this.handleEditorStepComplete( stepIndex ); } ); + // Make hint clickable to interact with the target element. + const hintBtn = popover.querySelector( '.pp-guided-tour-hint-clickable' ); + if ( hintBtn ) { + hintBtn.addEventListener( 'click', () => { + // Click the target element to activate it. + if ( element ) { + element.click(); + element.focus(); + } + } ); + } + // Reposition on scroll/resize. const repositionHandler = () => this.positionCustomPopover( popover, element, iframe ); window.addEventListener( 'resize', repositionHandler ); @@ -584,10 +646,13 @@ return; } - // Build description with optional hint. + // Store element reference for hint click handler. + this.currentStepElement = element; + + // Build description with optional hint (clickable button). let description = step.description; if ( step.hint ) { - description += `

${step.hint}

`; + description += ``; } // Check if there are more steps after this one @@ -634,6 +699,116 @@ this.driverInstance.drive(); }, + /** + * Show fixed popover with simple highlight (no overlay). + * Used for toolbar buttons like List View and Block Inserter. + * + * @param {HTMLElement} element Target element. + * @param {Object} step Step configuration. + * @param {number} stepIndex Current step index. + */ + showFixedPopoverWithHighlight( element, step, stepIndex ) { + // Add highlight class to the element. + element.classList.add( 'pp-guided-tour-highlight' ); + + // Inject highlight styles into document if not already there. + if ( ! document.getElementById( 'pp-guided-tour-highlight-styles' ) ) { + const style = document.createElement( 'style' ); + style.id = 'pp-guided-tour-highlight-styles'; + style.textContent = ` + .pp-guided-tour-highlight { + outline: 3px solid #667eea !important; + outline-offset: 4px !important; + border-radius: 4px !important; + animation: pp-highlight-pulse 1.5s ease-in-out infinite !important; + } + @keyframes pp-highlight-pulse { + 0%, 100% { outline-color: #667eea; } + 50% { outline-color: #764ba2; } + } + `; + document.head.appendChild( style ); + } + + // Store element for cleanup. + this.currentHighlightedElement = element; + + // Remove any existing fixed popover. + const existingPopover = document.getElementById( 'pp-guided-tour-fixed-popover' ); + if ( existingPopover ) { + existingPopover.remove(); + } + + // Build hint HTML (clickable). + let hintHtml = ''; + if ( step.hint ) { + hintHtml = ``; + } + + // Check if there are more steps. + const hasMoreSteps = config.activeTour.steps.length > stepIndex + 1; + + // Create fixed popover element. + const popover = document.createElement( 'div' ); + popover.id = 'pp-guided-tour-fixed-popover'; + popover.className = 'pp-guided-tour-fixed-popover'; + popover.innerHTML = ` +
+ +

${step.title}

+

${step.description}

+ ${hintHtml} + +
+ `; + + document.body.appendChild( popover ); + + // Bind events. + popover.querySelector( '.pp-guided-tour-fixed-popover-close' ).addEventListener( 'click', () => { + this.cleanupFixedPopover( element ); + popover.remove(); + this.skipTour(); + } ); + + popover.querySelector( '.pp-guided-tour-fixed-popover-skip' ).addEventListener( 'click', () => { + this.cleanupFixedPopover( element ); + popover.remove(); + this.handleEditorStepComplete( stepIndex ); + } ); + + popover.querySelector( '.pp-guided-tour-fixed-popover-next' ).addEventListener( 'click', () => { + this.cleanupFixedPopover( element ); + popover.remove(); + this.handleEditorStepComplete( stepIndex ); + } ); + + // Make hint clickable to interact with the target element. + const hintBtn = popover.querySelector( '.pp-guided-tour-hint-clickable' ); + if ( hintBtn ) { + hintBtn.addEventListener( 'click', () => { + if ( element ) { + element.click(); + element.focus(); + } + } ); + } + }, + + /** + * Clean up fixed popover highlight. + * + * @param {HTMLElement} element The highlighted element. + */ + cleanupFixedPopover( element ) { + if ( element ) { + element.classList.remove( 'pp-guided-tour-highlight' ); + } + }, + /** * Add custom buttons to editor popover. * From d035dd936f86ffc7ae5d34f1f916aefb61dfe008 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 11:25:44 +0100 Subject: [PATCH 4/9] WIP: close AI assistant and nudge user to publish the site --- guided-tour/class-guided-tour.php | 1 + guided-tour/guided-tour.css | 105 ++++++++++++++++++++++++++++++ guided-tour/guided-tour.js | 94 ++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/guided-tour/class-guided-tour.php b/guided-tour/class-guided-tour.php index 225f091b4..5b08fb196 100644 --- a/guided-tour/class-guided-tour.php +++ b/guided-tour/class-guided-tour.php @@ -561,6 +561,7 @@ private function enqueue_assets( array $state, string $context = 'admin' ): void 'nonce' => wp_create_nonce( 'pp_guided_tour' ), 'adminUrl' => admin_url(), 'frontPageEditUrl' => $front_page_edit_url, + 'brandingId' => (int) \progress_planner()->get_ui__branding()->get_branding_id(), 'i18n' => [ 'next' => __( 'Next', 'progress-planner' ), 'prev' => __( 'Previous', 'progress-planner' ), diff --git a/guided-tour/guided-tour.css b/guided-tour/guided-tour.css index 6da32838a..a68869c99 100644 --- a/guided-tour/guided-tour.css +++ b/guided-tour/guided-tour.css @@ -813,6 +813,111 @@ body.pp-guided-tour-active #wpadminbar { } } +/* ========================================================================== + YourHosting Completion Card + ========================================================================== */ + +.pp-guided-tour-yourhosting-complete { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 100001; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + animation: pp-slide-in 0.4s ease-out; +} + +.pp-guided-tour-yourhosting-complete-content { + position: relative; + background: linear-gradient( 135deg, #667eea 0%, #764ba2 100% ); + border-radius: 12px; + box-shadow: 0 8px 32px rgba( 102, 126, 234, 0.3 ), 0 4px 16px rgba( 0, 0, 0, 0.15 ); + padding: 28px 24px; + max-width: 360px; + min-width: 300px; + color: #fff; + text-align: center; +} + +.pp-guided-tour-yourhosting-complete-close { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba( 255, 255, 255, 0.2 ); + border: none; + border-radius: 50%; + color: #fff; + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: all 0.2s ease; +} + +.pp-guided-tour-yourhosting-complete-close:hover { + background: rgba( 255, 255, 255, 0.3 ); + transform: scale( 1.1 ); +} + +.pp-guided-tour-yourhosting-complete h3 { + font-size: 20px; + font-weight: 700; + margin: 0 0 12px 0; + line-height: 1.3; + color: #fff; +} + +.pp-guided-tour-yourhosting-complete p { + font-size: 15px; + line-height: 1.6; + margin: 0 0 20px 0; + opacity: 0.95; + color: #fff; +} + +.pp-guided-tour-yourhosting-publish-btn { + display: inline-block; + padding: 14px 28px; + font-size: 15px; + font-weight: 600; + color: #667eea; + background: #fff; + border: none; + border-radius: 8px; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba( 0, 0, 0, 0.15 ); +} + +.pp-guided-tour-yourhosting-publish-btn:hover { + transform: translateY( -2px ); + box-shadow: 0 6px 16px rgba( 0, 0, 0, 0.2 ); + color: #764ba2; +} + +.pp-guided-tour-yourhosting-publish-btn:active { + transform: translateY( 0 ); +} + +@media screen and ( max-width: 782px ) { + + .pp-guided-tour-yourhosting-complete { + bottom: 16px; + right: 16px; + left: 16px; + } + + .pp-guided-tour-yourhosting-complete-content { + max-width: none; + min-width: auto; + } +} + /* ========================================================================== Responsive Adjustments ========================================================================== */ diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index 933c28edd..feb0cdc1f 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -59,12 +59,50 @@ * Initialize frontend context (homepage welcome card). */ initFrontend() { + // Close Extendify AI assistant for YourHosting (branding ID 5159). + if ( config.brandingId && config.brandingId === 5159 ) { + this.closeExtendifyAssistant(); + } + // Wait for driver.js to be ready, then bind events. this.waitForDriver( () => { this.bindFrontendEvents(); } ); }, + /** + * Close the Extendify AI assistant via localStorage. + * Used for YourHosting installations to avoid UI overlap with the tour. + */ + closeExtendifyAssistant() { + try { + // Check if localStorage is available. + if ( typeof localStorage === 'undefined' ) { + return; + } + + // Find any localStorage key matching the Extendify agent pattern. + const keys = Object.keys( localStorage ); + for ( const key of keys ) { + if ( key.startsWith( 'extendify-agent-global-' ) ) { + try { + const data = JSON.parse( localStorage.getItem( key ) ); + if ( data && data.state ) { + data.state.open = false; + data.state.minimized = true; + localStorage.setItem( key, JSON.stringify( data ) ); + } + } catch ( e ) { + // Ignore parse errors for individual keys. + } + } + } + } catch ( e ) { + // Silently fail - don't break tour if Extendify handling fails. + console.warn( 'PP Guided Tour: Could not close Extendify assistant', e ); + } + }, + /** * Initialize block editor context. */ @@ -1415,6 +1453,19 @@ * Show tour completion message. */ showCompletionMessage() { + // Check for YourHosting branding (with safeguard). + if ( config.brandingId && config.brandingId === 5159 ) { + this.showYourHostingCompletionMessage(); + return; + } + + this.showDefaultCompletionMessage(); + }, + + /** + * Show the default tour completion message. + */ + showDefaultCompletionMessage() { // Create a simple notification. const notification = document.createElement( 'div' ); notification.className = 'pp-guided-tour-complete-notice notice notice-success is-dismissible'; @@ -1440,6 +1491,49 @@ } }, + /** + * Show YourHosting-specific completion message with publish CTA. + */ + showYourHostingCompletionMessage() { + try { + const notification = document.createElement( 'div' ); + notification.className = 'pp-guided-tour-complete-notice pp-guided-tour-yourhosting-complete'; + notification.innerHTML = ` +
+ +

Great work! You're ready to go live!

+

You now know your way around your website. Time to share it with the world!

+ + Publish Your Site + +
+ `; + + document.body.appendChild( notification ); + + // Find and link to the publish button (with safeguard). + const publishBtn = notification.querySelector( '#pp-yourhosting-publish-btn' ); + const adminBarPublish = document.querySelector( '#wp-admin-bar-iwp_migration_btn a' ); + + if ( publishBtn && adminBarPublish ) { + publishBtn.href = adminBarPublish.href; + } else if ( publishBtn ) { + // Hide button if publish link not found (local testing, etc.). + publishBtn.style.display = 'none'; + } + + // Close button handler (with safeguard). + const closeBtn = notification.querySelector( '.pp-guided-tour-yourhosting-complete-close' ); + if ( closeBtn ) { + closeBtn.addEventListener( 'click', () => notification.remove() ); + } + } catch ( e ) { + console.warn( 'PP Guided Tour: Could not show YourHosting completion message', e ); + // Fall back to default completion message. + this.showDefaultCompletionMessage(); + } + }, + /** * Make API call to tour endpoints. * From 0251c805b10455d18a5918541e39b5f5e68472b9 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 11:48:15 +0100 Subject: [PATCH 5/9] more yourhosting integration --- guided-tour/guided-tour.js | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index feb0cdc1f..540df5b44 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -109,10 +109,21 @@ initEditor() { console.log( 'PP Guided Tour: Initializing editor context', config ); + // Close Extendify AI assistant for YourHosting (branding ID 5159). + if ( config.brandingId && config.brandingId === 5159 ) { + this.closeExtendifyAssistant(); + } + // Wait for editor to be ready. if ( typeof wp !== 'undefined' && wp.domReady ) { wp.domReady( () => { console.log( 'PP Guided Tour: wp.domReady fired' ); + + // Close Extendify editor sidebar for YourHosting. + if ( config.brandingId && config.brandingId === 5159 ) { + this.closeExtendifyEditorSidebar(); + } + // Open Progress Planner sidebar. this.openProgressPlannerSidebar(); // Wait for driver.js and editor to be ready. @@ -123,6 +134,12 @@ } else { // Fallback. console.log( 'PP Guided Tour: Using fallback timeout' ); + + // Close Extendify editor sidebar for YourHosting. + if ( config.brandingId && config.brandingId === 5159 ) { + this.closeExtendifyEditorSidebar(); + } + this.openProgressPlannerSidebar(); this.waitForDriver( () => { setTimeout( () => this.resumeEditorTour(), 2000 ); @@ -130,6 +147,34 @@ } }, + /** + * Close the Extendify editor sidebar (AI Tools button). + * Used for YourHosting installations to show PP sidebar instead. + */ + closeExtendifyEditorSidebar() { + try { + // Find the Extendify AI Tools button. + const extendifyButton = document.querySelector( + 'button[aria-controls^="extendify-draft"]' + ); + + if ( ! extendifyButton ) { + // Button not found yet, retry after a short delay. + setTimeout( () => this.closeExtendifyEditorSidebar(), 500 ); + return; + } + + // Check if sidebar is open. + if ( extendifyButton.getAttribute( 'aria-expanded' ) === 'true' ) { + // Click to close the sidebar. + extendifyButton.click(); + } + } catch ( e ) { + // Silently fail - don't break tour if Extendify handling fails. + console.warn( 'PP Guided Tour: Could not close Extendify editor sidebar', e ); + } + }, + /** * Open the Progress Planner sidebar in the block editor. */ @@ -1453,6 +1498,11 @@ * Show tour completion message. */ showCompletionMessage() { + console.log( 'PP Guided Tour: showCompletionMessage called', { + brandingId: config.brandingId, + isYourHosting: config.brandingId === 5159, + } ); + // Check for YourHosting branding (with safeguard). if ( config.brandingId && config.brandingId === 5159 ) { this.showYourHostingCompletionMessage(); @@ -1495,6 +1545,8 @@ * Show YourHosting-specific completion message with publish CTA. */ showYourHostingCompletionMessage() { + console.log( 'PP Guided Tour: showYourHostingCompletionMessage called' ); + try { const notification = document.createElement( 'div' ); notification.className = 'pp-guided-tour-complete-notice pp-guided-tour-yourhosting-complete'; @@ -1510,11 +1562,18 @@ `; document.body.appendChild( notification ); + console.log( 'PP Guided Tour: YourHosting completion message appended to body' ); // Find and link to the publish button (with safeguard). const publishBtn = notification.querySelector( '#pp-yourhosting-publish-btn' ); const adminBarPublish = document.querySelector( '#wp-admin-bar-iwp_migration_btn a' ); + console.log( 'PP Guided Tour: Publish button elements', { + publishBtn: !! publishBtn, + adminBarPublish: !! adminBarPublish, + adminBarPublishHref: adminBarPublish?.href, + } ); + if ( publishBtn && adminBarPublish ) { publishBtn.href = adminBarPublish.href; } else if ( publishBtn ) { From 254a7e32f0160f6425b9fffa38afbf559cd3a3e3 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 11:50:17 +0100 Subject: [PATCH 6/9] parseInt --- guided-tour/guided-tour.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index 540df5b44..d00e4ac81 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -60,7 +60,7 @@ */ initFrontend() { // Close Extendify AI assistant for YourHosting (branding ID 5159). - if ( config.brandingId && config.brandingId === 5159 ) { + if ( config.brandingId && parseInt( config.brandingId, 10 ) === 5159 ) { this.closeExtendifyAssistant(); } @@ -110,7 +110,7 @@ console.log( 'PP Guided Tour: Initializing editor context', config ); // Close Extendify AI assistant for YourHosting (branding ID 5159). - if ( config.brandingId && config.brandingId === 5159 ) { + if ( config.brandingId && parseInt( config.brandingId, 10 ) === 5159 ) { this.closeExtendifyAssistant(); } @@ -120,7 +120,7 @@ console.log( 'PP Guided Tour: wp.domReady fired' ); // Close Extendify editor sidebar for YourHosting. - if ( config.brandingId && config.brandingId === 5159 ) { + if ( config.brandingId && parseInt( config.brandingId, 10 ) === 5159 ) { this.closeExtendifyEditorSidebar(); } @@ -136,7 +136,7 @@ console.log( 'PP Guided Tour: Using fallback timeout' ); // Close Extendify editor sidebar for YourHosting. - if ( config.brandingId && config.brandingId === 5159 ) { + if ( config.brandingId && parseInt( config.brandingId, 10 ) === 5159 ) { this.closeExtendifyEditorSidebar(); } @@ -1498,13 +1498,15 @@ * Show tour completion message. */ showCompletionMessage() { + const brandingIdNum = config.brandingId ? parseInt( config.brandingId, 10 ) : 0; console.log( 'PP Guided Tour: showCompletionMessage called', { brandingId: config.brandingId, - isYourHosting: config.brandingId === 5159, + brandingIdNum: brandingIdNum, + isYourHosting: brandingIdNum === 5159, } ); // Check for YourHosting branding (with safeguard). - if ( config.brandingId && config.brandingId === 5159 ) { + if ( brandingIdNum === 5159 ) { this.showYourHostingCompletionMessage(); return; } From e895ccb0ab413603f5aa2b3a35987e12ec8c1e08 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 11:57:17 +0100 Subject: [PATCH 7/9] more tweaks --- guided-tour/guided-tour.js | 69 ++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index d00e4ac81..5ba2d375c 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -71,38 +71,71 @@ }, /** - * Close the Extendify AI assistant via localStorage. + * Close the Extendify AI assistant. * Used for YourHosting installations to avoid UI overlap with the tour. */ closeExtendifyAssistant() { try { - // Check if localStorage is available. - if ( typeof localStorage === 'undefined' ) { - return; - } - - // Find any localStorage key matching the Extendify agent pattern. - const keys = Object.keys( localStorage ); - for ( const key of keys ) { - if ( key.startsWith( 'extendify-agent-global-' ) ) { - try { - const data = JSON.parse( localStorage.getItem( key ) ); - if ( data && data.state ) { - data.state.open = false; - data.state.minimized = true; - localStorage.setItem( key, JSON.stringify( data ) ); + // First, try to update localStorage to prevent future opens. + if ( typeof localStorage !== 'undefined' ) { + const keys = Object.keys( localStorage ); + for ( const key of keys ) { + if ( key.startsWith( 'extendify-agent-global-' ) ) { + try { + const data = JSON.parse( localStorage.getItem( key ) ); + if ( data && data.state ) { + data.state.open = false; + data.state.minimized = true; + localStorage.setItem( key, JSON.stringify( data ) ); + } + } catch ( e ) { + // Ignore parse errors for individual keys. } - } catch ( e ) { - // Ignore parse errors for individual keys. } } } + + // Then, click the close button if the popup is already open. + this.clickExtendifyCloseButton(); } catch ( e ) { // Silently fail - don't break tour if Extendify handling fails. console.warn( 'PP Guided Tour: Could not close Extendify assistant', e ); } }, + /** + * Click the Extendify popup close button if it exists. + * Retries multiple times since Extendify may load after our script. + * + * @param {number} attempt Current attempt number. + */ + clickExtendifyCloseButton( attempt = 0 ) { + const maxAttempts = 10; + const delay = 500; // 500ms between attempts = 5 seconds total + + try { + // Find the Extendify close button by its text content. + const allButtons = document.querySelectorAll( 'button' ); + for ( const btn of allButtons ) { + const text = ( btn.textContent || '' ).trim().toLowerCase(); + + // Match the Extendify close button by text. + if ( text === 'venster sluiten' || text === 'close window' ) { + btn.click(); + console.log( 'PP Guided Tour: Clicked Extendify close button (attempt ' + attempt + ')' ); + return; + } + } + + // If not found and we haven't exceeded max attempts, retry. + if ( attempt < maxAttempts ) { + setTimeout( () => this.clickExtendifyCloseButton( attempt + 1 ), delay ); + } + } catch ( e ) { + console.warn( 'PP Guided Tour: Could not click Extendify close button', e ); + } + }, + /** * Initialize block editor context. */ From b9efb840eb43472412c5d72d197c581dc1607eab Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 14:25:41 +0100 Subject: [PATCH 8/9] re-try to close AI sidebar --- guided-tour/guided-tour.js | 60 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/guided-tour/guided-tour.js b/guided-tour/guided-tour.js index 5ba2d375c..e22ca7c5c 100644 --- a/guided-tour/guided-tour.js +++ b/guided-tour/guided-tour.js @@ -183,24 +183,30 @@ /** * Close the Extendify editor sidebar (AI Tools button). * Used for YourHosting installations to show PP sidebar instead. + * Retries multiple times since Extendify may open after wp.domReady. + * + * @param {number} attempt Current attempt number. */ - closeExtendifyEditorSidebar() { + closeExtendifyEditorSidebar( attempt = 0 ) { + const maxAttempts = 20; // 20 attempts * 500ms = 10 seconds total + const delay = 500; + try { - // Find the Extendify AI Tools button. + // Find the Extendify AI Tools button that's open. const extendifyButton = document.querySelector( - 'button[aria-controls^="extendify-draft"]' + 'button[aria-controls^="extendify-draft"][aria-expanded="true"]' ); - if ( ! extendifyButton ) { - // Button not found yet, retry after a short delay. - setTimeout( () => this.closeExtendifyEditorSidebar(), 500 ); + if ( extendifyButton ) { + // Sidebar is open, click to close it. + extendifyButton.click(); + console.log( 'PP Guided Tour: Closed Extendify editor sidebar (attempt ' + attempt + ')' ); return; } - // Check if sidebar is open. - if ( extendifyButton.getAttribute( 'aria-expanded' ) === 'true' ) { - // Click to close the sidebar. - extendifyButton.click(); + // Sidebar not open yet, retry if we haven't exceeded max attempts. + if ( attempt < maxAttempts ) { + setTimeout( () => this.closeExtendifyEditorSidebar( attempt + 1 ), delay ); } } catch ( e ) { // Silently fail - don't break tour if Extendify handling fails. @@ -1583,6 +1589,19 @@ console.log( 'PP Guided Tour: showYourHostingCompletionMessage called' ); try { + // Determine publish URL - try admin bar first, then construct directly. + let publishUrl = ''; + const adminBarPublish = document.querySelector( '#wp-admin-bar-iwp_migration_btn a' ); + + if ( adminBarPublish ) { + publishUrl = adminBarPublish.href; + } else if ( config.adminUrl ) { + // Construct publish URL directly (works in editor where admin bar isn't visible). + publishUrl = config.adminUrl + 'admin.php?page=iwp_migrate_content'; + } + + console.log( 'PP Guided Tour: Publish URL', publishUrl ); + const notification = document.createElement( 'div' ); notification.className = 'pp-guided-tour-complete-notice pp-guided-tour-yourhosting-complete'; notification.innerHTML = ` @@ -1590,32 +1609,13 @@

Great work! You're ready to go live!

You now know your way around your website. Time to share it with the world!

- - Publish Your Site - + ${ publishUrl ? `Publish Your Site` : '' } `; document.body.appendChild( notification ); console.log( 'PP Guided Tour: YourHosting completion message appended to body' ); - // Find and link to the publish button (with safeguard). - const publishBtn = notification.querySelector( '#pp-yourhosting-publish-btn' ); - const adminBarPublish = document.querySelector( '#wp-admin-bar-iwp_migration_btn a' ); - - console.log( 'PP Guided Tour: Publish button elements', { - publishBtn: !! publishBtn, - adminBarPublish: !! adminBarPublish, - adminBarPublishHref: adminBarPublish?.href, - } ); - - if ( publishBtn && adminBarPublish ) { - publishBtn.href = adminBarPublish.href; - } else if ( publishBtn ) { - // Hide button if publish link not found (local testing, etc.). - publishBtn.style.display = 'none'; - } - // Close button handler (with safeguard). const closeBtn = notification.querySelector( '.pp-guided-tour-yourhosting-complete-close' ); if ( closeBtn ) { From e56e04b152d056c3488a307de2478ecb341f5e4a Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 16 Jan 2026 16:41:02 +0100 Subject: [PATCH 9/9] typo --- guided-tour/class-guided-tour.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guided-tour/class-guided-tour.php b/guided-tour/class-guided-tour.php index 5b08fb196..c9fb5b919 100644 --- a/guided-tour/class-guided-tour.php +++ b/guided-tour/class-guided-tour.php @@ -173,7 +173,7 @@ private function register_tours(): void { 'title' => __( 'Yesssssss! Welcome to your website!', 'progress-planner' ), 'description' => __( 'And now we\'re going to improve and customize it together so it\'s ready for the world. Are you ready to get started?', 'progress-planner' ), 'cta' => __( 'Edit Page', 'progress-planner' ), - 'cta_hint' => __( 'Click here to star the guided tour.', 'progress-planner' ), + 'cta_hint' => __( 'Click here to start the guided tour.', 'progress-planner' ), ], // Step 1: Check the title in block editor. [