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..c1541b6bc
--- /dev/null
+++ b/guided-tour/README.md
@@ -0,0 +1,335 @@
+# 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
+
+## 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+
+- 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..c9fb5b919
--- /dev/null
+++ b/guided-tour/class-guided-tour.php
@@ -0,0 +1,847 @@
+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 start 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: 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',
+ ],
+ ],
+ ],
+ '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,
+ 'brandingId' => (int) \progress_planner()->get_ui__branding()->get_branding_id(),
+ '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 ) : ?>
+ -
+
+
+
+
+
+
+ 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();
+ } );
+ },
+
+ /**
+ * Close the Extendify AI assistant.
+ * Used for YourHosting installations to avoid UI overlap with the tour.
+ */
+ closeExtendifyAssistant() {
+ try {
+ // 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.
+ }
+ }
+ }
+ }
+
+ // 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.
+ */
+ initEditor() {
+ console.log( 'PP Guided Tour: Initializing editor context', config );
+
+ // Close Extendify AI assistant for YourHosting (branding ID 5159).
+ if ( config.brandingId && parseInt( config.brandingId, 10 ) === 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 && parseInt( config.brandingId, 10 ) === 5159 ) {
+ this.closeExtendifyEditorSidebar();
+ }
+
+ // 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' );
+
+ // Close Extendify editor sidebar for YourHosting.
+ if ( config.brandingId && parseInt( config.brandingId, 10 ) === 5159 ) {
+ this.closeExtendifyEditorSidebar();
+ }
+
+ this.openProgressPlannerSidebar();
+ this.waitForDriver( () => {
+ setTimeout( () => this.resumeEditorTour(), 2000 );
+ } );
+ }
+ },
+
+ /**
+ * 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( attempt = 0 ) {
+ const maxAttempts = 20; // 20 attempts * 500ms = 10 seconds total
+ const delay = 500;
+
+ try {
+ // Find the Extendify AI Tools button that's open.
+ const extendifyButton = document.querySelector(
+ 'button[aria-controls^="extendify-draft"][aria-expanded="true"]'
+ );
+
+ if ( extendifyButton ) {
+ // Sidebar is open, click to close it.
+ extendifyButton.click();
+ console.log( 'PP Guided Tour: Closed Extendify editor sidebar (attempt ' + attempt + ')' );
+ return;
+ }
+
+ // 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.
+ console.warn( 'PP Guided Tour: Could not close Extendify editor sidebar', e );
+ }
+ },
+
+ /**
+ * 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,
+ showButtons: [ 'next', 'close' ],
+ 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 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 );
+ }
+ },
+
+ /**
+ * 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 '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(
+ '.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 (clickable).
+ let hintHtml = '';
+ if ( step.hint ) {
+ hintHtml = ``;
+ }
+
+ // 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 );
+ } );
+
+ // 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 );
+ 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;
+ }
+
+ // 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 += ``;
+ }
+
+ // 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();
+ },
+
+ /**
+ * 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.
+ *
+ * @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() {
+ const brandingIdNum = config.brandingId ? parseInt( config.brandingId, 10 ) : 0;
+ console.log( 'PP Guided Tour: showCompletionMessage called', {
+ brandingId: config.brandingId,
+ brandingIdNum: brandingIdNum,
+ isYourHosting: brandingIdNum === 5159,
+ } );
+
+ // Check for YourHosting branding (with safeguard).
+ if ( brandingIdNum === 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';
+ 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 );
+ }
+ },
+
+ /**
+ * Show YourHosting-specific completion message with publish CTA.
+ */
+ showYourHostingCompletionMessage() {
+ 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 = `
+
+
+
Great work! You're ready to go live!
+
You now know your way around your website. Time to share it with the world!
+ ${ publishUrl ? `
Publish Your Site` : '' }
+
+ `;
+
+ document.body.appendChild( notification );
+ console.log( 'PP Guided Tour: YourHosting completion message appended to body' );
+
+ // 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.
+ *
+ * @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: