Files
wizarr/app/templates/wizard/steps.html
Matthieu B 89762d96dd feat: add bundle preview functionality and improve token generation tests
- Added a preview link for bundles in the wizard settings page. closes #1048
- Enhanced the wizard steps template to handle bundle preview mode.
- Updated JavaScript to generate URLs for bundle preview and runtime modes.
- Improved test coverage for password reset token generation by asserting token creation.
2025-12-09 14:37:39 +01:00

661 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# templates/wizard/steps.html #}
<div id="wizard-wrapper"
x-data="wizardSwipe()"
@touchstart="onTouchStart($event)"
@touchmove="onTouchMove($event)"
@touchend="onTouchEnd($event)"
class="wizard-container md:justify-center md:items-center md:px-4 md:py-8"
data-current-idx="{{ idx }}"
data-max-idx="{{ max_idx }}"
data-server-type="{{ server_type }}"
data-phase="{{ phase|default('') }}"
data-step-phase="{{ (step_phase or '') }}"
{% if is_bundle_preview %}data-bundle-id="{{ bundle_id }}"{% endif %}
data-completion-url="{{ completion_url or '' }}"
data-completion-label="{{ completion_label or '' }}"
data-next-label="{{ _('Next') }}"
data-phase-label-pre="{{ _('Before Invitation') }}"
data-phase-label-post="{{ _('After Invitation') }}">
{% set current_step_phase = step_phase or '' %}
{% set completion_href = completion_url or '' %}
{% set completion_label_text = completion_label or _('Continue to Invite') %}
{% set default_next_label = _('Next') %}
{% set is_pre_phase = phase == 'pre' %}
{% set is_last_step = idx == max_idx %}
{% set is_pre_final = is_pre_phase and is_last_step and completion_href %}
<!-- FLOATING PROGRESS BAR (mobile - floating at top) -->
<div id="wizard-progress-mobile" class="wizard-progress-mobile md:hidden">
<div class="wizard-glass-container">
<!-- Phase Indicator (only shown in preview mode) -->
{% if phase == 'preview' %}
<div id="wizard-phase-container-mobile"
class="flex justify-center mb-2"
{% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-mobile"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if current_step_phase == 'pre' %}
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif current_step_phase == 'post' %}
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% endif %}"
data-phase-label="mobile">
{% if current_step_phase == 'pre' %}
{{ _("Before Invitation") }}
{% elif current_step_phase == 'post' %}
{{ _("After Invitation") }}
{% endif %}
</span>
</div>
{% endif %}
<!-- Progress bar (only shown when there are multiple steps) -->
{% if max_idx > 0 %}
<div class="flex justify-between items-center mb-2 text-xs text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span class="font-semibold">{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200/50 dark:bg-gray-700/50 rounded-full h-1.5 overflow-hidden">
<div id="progress-bar-mobile"
class="h-1.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, {{ gradient_start }} 0%, {{ gradient_end }} 100%);
box-shadow: 0 0 8px {{ shadow_color }}"></div>
</div>
{% endif %}
</div>
</div>
<!-- DESKTOP PROGRESS BAR (centered) -->
<div id="wizard-progress-desktop"
class="hidden md:block w-full max-w-md mb-6">
<!-- Phase Indicator (only shown in preview mode) -->
{% if phase == 'preview' %}
<div id="wizard-phase-container-desktop"
class="flex justify-center mb-3"
{% if not current_step_phase %}style="display:none"{% endif %}>
<span id="wizard-phase-label-desktop"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if current_step_phase == 'pre' %}
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif current_step_phase == 'post' %}
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% endif %}"
data-phase-label="desktop">
{% if current_step_phase == 'pre' %}
{{ _("Before Invitation") }}
{% elif current_step_phase == 'post' %}
{{ _("After Invitation") }}
{% endif %}
</span>
</div>
{% endif %}
<!-- Progress bar (only shown when there are multiple steps) -->
{% if max_idx > 0 %}
<div class="flex justify-between items-center mb-2 text-sm text-gray-600 dark:text-gray-400">
<span>{{ _("Step") }} {{ idx + 1 }} {{ _("of") }} {{ max_idx + 1 }}</span>
<span>{{ ((idx + 1) / (max_idx + 1) * 100) | round | int }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 overflow-hidden">
<div id="progress-bar-desktop"
class="h-2.5 rounded-full transition-all duration-500 ease-out"
style="width: {{ ((idx + 1) / (max_idx + 1) * 100) }}%;
background: linear-gradient(90deg, {{ gradient_start }} 0%, {{ gradient_end }} 100%);
box-shadow: 0 0 10px {{ shadow_color }}"></div>
</div>
{% endif %}
</div>
<!-- CARD (full height on mobile, centered on desktop) -->
<div class="htmx-indicator absolute inset-0 bg-white/80 dark:bg-gray-900/80 flex items-center justify-center z-50">
<div class="flex flex-col items-center gap-3">
<svg class="animate-spin h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading...</span>
</div>
</div>
{% include "wizard/_content.html" %}
<!-- FLOATING NAV BUTTONS (mobile - fixed at bottom) -->
<div class="wizard-nav-mobile md:hidden">
<a id="wizard-prev-btn"
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx-1) }}"
{% endif %}
{% elif is_bundle_preview and bundle_id %}
hx-get="{{ url_for('wizard.bundle_preview', bundle_id=bundle_id, idx=idx-1) }}"
{% elif server_type == 'bundle' %}
hx-get="{{ url_for('wizard.bundle_view', idx=idx-1) }}"
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="wizard-btn-mobile wizard-btn-prev"
{% if idx == 0 %}style="display:none"{% endif %}
aria-label="{{ _('Previous') }}">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 19l-7-7 7-7" />
</svg>
</a>
<a id="wizard-next-btn"
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx+1) }}"
{% endif %}
{% elif is_bundle_preview and bundle_id %}
hx-get="{{ url_for('wizard.bundle_preview', bundle_id=bundle_id, idx=idx+1) }}"
{% elif server_type == 'bundle' %}
hx-get="{{ url_for('wizard.bundle_view', idx=idx+1) }}"
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="wizard-btn-mobile wizard-btn-next"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<!-- DESKTOP NAV BUTTONS (centered below card) -->
<div class="hidden md:flex justify-center space-x-4 mt-6">
<a id="wizard-prev-btn-desktop"
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx-1) }}"
{% endif %}
{% elif is_bundle_preview and bundle_id %}
hx-get="{{ url_for('wizard.bundle_preview', bundle_id=bundle_id, idx=idx-1) }}"
{% elif server_type == 'bundle' %}
hx-get="{{ url_for('wizard.bundle_view', idx=idx-1) }}"
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx-1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx-1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx-1) }}"
{% endif %}
hx-vals='{"dir":"prev"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
class="btn-nav"
{% if idx == 0 %}style="display:none"{% endif %}> {{ _("Previous") }}</a>
<a id="wizard-next-btn-desktop"
{% if is_pre_final %}
href="{{ completion_href }}" hx-get="{{ completion_href }}" hx-target="#wizard-content" hx-swap="outerHTML swap:0s" hx-indicator=".htmx-indicator" data-final-step="1"
{% else %}
{% if server_type == 'combo' %}
{% if phase == 'pre' %}
hx-get="{{ url_for('wizard.combo', category='pre_invite', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.combo', category='post_invite', idx=idx+1) }}"
{% endif %}
{% elif is_bundle_preview and bundle_id %}
hx-get="{{ url_for('wizard.bundle_preview', bundle_id=bundle_id, idx=idx+1) }}"
{% elif server_type == 'bundle' %}
hx-get="{{ url_for('wizard.bundle_view', idx=idx+1) }}"
{% elif phase == 'pre' %}
hx-get="{{ url_for('wizard.pre_wizard', idx=idx+1) }}"
{% elif phase == 'post' %}
hx-get="{{ url_for('wizard.post_wizard', idx=idx+1) }}"
{% else %}
hx-get="{{ url_for('wizard.step', server=server_type, idx=idx+1) }}"
{% endif %}
hx-vals='{"dir":"next"}'
hx-target="#wizard-content"
hx-swap="outerHTML swap:0s"
hx-indicator=".htmx-indicator"
{% endif %}
class="btn-nav"
{% if not is_pre_final and idx == max_idx %}style="display:none"{% endif %}
data-default-html="{{ _('Next') }} "
data-final-html="{{ completion_label_text }}"
data-default-aria-label="{{ default_next_label }}"
aria-label="{{ completion_label_text if is_pre_final else default_next_label }}"
{% if require_interaction %}
aria-disabled="true" data-disabled="1" title="{{ _('Click the link in this step to be able to continue') }}"
{% endif %}>
{% if is_pre_final %}
{{ completion_label_text }}
{% else %}
{{ _("Next") }}
{% endif %}
</a>
</div>
</div>
<script>
/**
* WIZARR WIZARD ARCHITECTURE
* ==========================
*
* A modular, app-like wizard system with fixed UI chrome and smooth content transitions.
*
* STRUCTURE:
* ----------
* 1. Templates:
* - wizard/frame.html → Full page wrapper (initial load only)
* - wizard/steps.html → Wrapper with UI chrome (progress, buttons)
* - wizard/_content.html → Content-only partial (HTMX swaps)
*
* 2. Request Flow:
* - Initial load: Returns frame.html → includes steps.html → includes _content.html
* - HTMX request: Returns _content.html ONLY (no UI chrome duplication)
*
* 3. State Management:
* - Server sends headers: X-Wizard-Idx, X-Require-Interaction, X-Wizard-Step-Phase
* - Client updates: Progress bars, button URLs, button visibility, phase badges
* - Wrapper stores:
* - data-current-idx: Current wizard step index (int, 0-based)
* - data-max-idx: Maximum wizard step index (int)
* - data-server-type: Type of server being configured (string, e.g., "plex", "jellyfin")
* - data-phase: Current wizard phase (string, "pre" or "post")
* - data-step-phase: Current step phase (string, "pre" or "post" for pre-invite/post-invite)
* These attributes are updated by the server and client-side JS to synchronize UI state,
* drive progress bar, navigation button logic, and ensure correct HTMX swaps.
*
* 4. Mobile Features:
* - Fixed progress bar at top (no scroll)
* - Fixed navigation buttons at bottom (circular, gradient)
* - Swipe gestures (left=next, right=prev)
* - Content-only scrolling with smooth animations
*
* IMPORTANT:
* ----------
* - NEVER swap the wrapper (causes UI duplication)
* - ONLY swap #wizard-content
* - Progress/buttons update via JavaScript, not DOM replacement
*/
// Modern wizard controller
const WizardController = {
wrapper: null,
isInitialized: false,
init() {
// Prevent double initialization
if (this.isInitialized) return;
this.wrapper = document.getElementById('wizard-wrapper');
if (!this.wrapper) {
console.error('Wizard wrapper not found');
return;
}
// Listen for content swaps
document.body.addEventListener('htmx:afterSwap', (evt) => {
if (evt.detail.target.id === 'wizard-content') {
this.updateUI(evt.detail.xhr);
}
});
this.isInitialized = true;
console.log('WizardController initialized');
this.updatePhaseBadges(this.getStepPhase());
},
getCurrentIdx() {
return parseInt(this.wrapper.dataset.currentIdx);
},
getMaxIdx() {
return parseInt(this.wrapper.dataset.maxIdx);
},
getServerType() {
return this.wrapper.dataset.serverType;
},
getPhase() {
return this.wrapper.dataset.phase || '';
},
getStepPhase() {
return this.wrapper.dataset.stepPhase || '';
},
getCompletionUrl() {
return this.wrapper.dataset.completionUrl || '';
},
getCompletionLabel() {
return this.wrapper.dataset.completionLabel || '';
},
getDefaultNextLabel() {
return this.wrapper.dataset.nextLabel || 'Next';
},
updateUI(xhr) {
if (!xhr) {
console.error('No XHR object provided');
return;
}
const hxRedirect = xhr.getResponseHeader('HX-Redirect');
if (hxRedirect) {
// HTMX will handle navigation; skip local state updates to avoid errors.
return;
}
const newIdx = parseInt(xhr.getResponseHeader('X-Wizard-Idx'));
const requireInteraction = xhr.getResponseHeader('X-Require-Interaction') === 'true';
const maxIdx = this.getMaxIdx();
const serverType = this.getServerType();
const newStepPhase = xhr.getResponseHeader('X-Wizard-Step-Phase');
// Validation
if (isNaN(newIdx) || isNaN(maxIdx)) {
console.error('Invalid index values:', {
newIdx,
maxIdx
});
return;
}
if (newIdx < 0 || newIdx > maxIdx) {
console.error('Index out of bounds:', {
newIdx,
maxIdx
});
return;
}
console.log('✅ Wizard update:', {
newIdx,
maxIdx,
serverType,
requireInteraction
});
// Update wrapper state
this.wrapper.dataset.currentIdx = newIdx;
if (typeof newStepPhase === 'string') {
this.wrapper.dataset.stepPhase = newStepPhase;
}
const effectiveStepPhase = typeof newStepPhase === 'string' ? newStepPhase : this.getStepPhase();
this.updatePhaseBadges(effectiveStepPhase);
// Update progress
this.updateProgress(newIdx, maxIdx);
// Update navigation buttons
this.updateButtons(newIdx, maxIdx, serverType, requireInteraction);
},
updatePhaseBadges(phase) {
const mobileContainer = document.getElementById('wizard-phase-container-mobile');
const desktopContainer = document.getElementById('wizard-phase-container-desktop');
const mobileLabel = document.getElementById('wizard-phase-label-mobile');
const desktopLabel = document.getElementById('wizard-phase-label-desktop');
const preLabel = this.wrapper.dataset.phaseLabelPre || 'Before Invitation';
const postLabel = this.wrapper.dataset.phaseLabelPost || 'After Invitation';
const phaseText = phase === 'pre' ?
preLabel :
phase === 'post' ?
postLabel :
'';
const PRE_CLASSES = ['bg-blue-100', 'text-blue-800', 'dark:bg-blue-900', 'dark:text-blue-200'];
const POST_CLASSES = ['bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200'];
const applyClasses = (el) => {
if (!el) return;
el.classList.remove(...PRE_CLASSES, ...POST_CLASSES);
if (phase === 'pre') {
el.classList.add(...PRE_CLASSES);
} else if (phase === 'post') {
el.classList.add(...POST_CLASSES);
}
};
const updateLabel = (container, label) => {
if (!container || !label) return;
if (!phaseText) {
container.style.display = 'none';
return;
}
container.style.display = '';
label.textContent = phaseText;
applyClasses(label);
};
updateLabel(mobileContainer, mobileLabel);
updateLabel(desktopContainer, desktopLabel);
},
updateProgress(idx, maxIdx) {
const percentage = ((idx + 1) / (maxIdx + 1)) * 100;
// Update progress bars
['progress-bar-mobile', 'progress-bar-desktop'].forEach(id => {
const bar = document.getElementById(id);
if (bar) bar.style.width = `${percentage}%`;
});
// Update text using direct child access
['wizard-progress-mobile', 'wizard-progress-desktop'].forEach(id => {
const container = document.getElementById(id);
if (container) {
// Find the flex div that contains step counter (has justify-between class)
// This avoids selecting the phase indicator flex div (which has justify-center)
const flexDiv = container.querySelector('.flex.justify-between');
if (flexDiv) {
const stepSpan = flexDiv.children[0];
const percentageSpan = flexDiv.children[1];
if (stepSpan) {
// Preserve the localized text pattern, just update the numbers
const currentText = stepSpan.textContent || '';
// Replace numbers: first occurrence is current step, second is total steps
let numberCount = 0;
const updatedText = currentText.replace(/\d+/g, () => {
numberCount++;
return numberCount === 1 ? `${idx + 1}` : `${maxIdx + 1}`;
});
stepSpan.textContent = updatedText;
}
if (percentageSpan) {
percentageSpan.textContent = `${Math.round(percentage)}%`;
}
}
}
});
},
updateButtons(idx, maxIdx, serverType, requireInteraction) {
const phase = this.getPhase();
const completionUrl = this.getCompletionUrl();
const completionLabel = this.getCompletionLabel();
const defaultNextLabel = this.getDefaultNextLabel();
const isFinalPreStep = phase === 'pre' && idx === maxIdx && completionUrl;
const buttonConfigs = [{
id: 'wizard-prev-btn',
isPrev: true
}, {
id: 'wizard-prev-btn-desktop',
isPrev: true
}, {
id: 'wizard-next-btn',
isPrev: false
}, {
id: 'wizard-next-btn-desktop',
isPrev: false
}];
buttonConfigs.forEach(({
id,
isPrev
}) => {
const btn = document.getElementById(id);
if (!btn) return;
const targetIdx = isPrev ? idx - 1 : idx + 1;
// Generate URL based on phase and server type
let newUrl;
const bundleId = this.wrapper.dataset.bundleId;
if (serverType === 'combo') {
// Combo wizard uses path-based category routing
const category = phase === 'pre' ? 'pre_invite' : 'post_invite';
newUrl = `/wizard/combo/${category}/${targetIdx}`;
} else if (serverType === 'bundle' && bundleId) {
// Bundle preview mode (admin)
newUrl = `/wizard/bundle-preview/${bundleId}/${targetIdx}`;
} else if (serverType === 'bundle') {
// Runtime bundle mode (user flow)
newUrl = `/wizard/bundle/${targetIdx}`;
} else if (phase === 'pre') {
newUrl = `/wizard/pre-wizard/${targetIdx}`;
} else if (phase === 'post') {
newUrl = `/wizard/post-wizard/${targetIdx}`;
} else {
newUrl = `/wizard/${serverType}/${targetIdx}`;
}
// Update visibility and state
if (isPrev) {
btn.style.display = idx === 0 ? 'none' : '';
btn.setAttribute('hx-get', newUrl);
} else {
const defaultAriaLabel = btn.dataset.defaultAriaLabel || defaultNextLabel;
if (isFinalPreStep) {
btn.style.display = '';
btn.setAttribute('href', completionUrl);
btn.dataset.finalStep = '1';
btn.setAttribute('hx-get', completionUrl);
btn.removeAttribute('hx-vals');
btn.setAttribute('hx-target', '#wizard-content');
btn.setAttribute('hx-swap', 'outerHTML swap:0s');
btn.setAttribute('hx-indicator', '.htmx-indicator');
const aria = completionLabel || defaultAriaLabel;
btn.setAttribute('aria-label', aria);
if (btn.dataset.finalHtml) {
btn.innerHTML = btn.dataset.finalHtml;
}
} else {
btn.style.display = idx === maxIdx ? 'none' : '';
btn.removeAttribute('href');
delete btn.dataset.finalStep;
btn.setAttribute('hx-get', newUrl);
btn.setAttribute('hx-vals', '{"dir":"next"}');
btn.setAttribute('hx-target', '#wizard-content');
btn.setAttribute('hx-swap', 'outerHTML swap:0s');
btn.setAttribute('hx-indicator', '.htmx-indicator');
btn.setAttribute('aria-label', defaultAriaLabel);
if (btn.dataset.defaultHtml) {
btn.innerHTML = btn.dataset.defaultHtml;
}
}
if (requireInteraction) {
btn.setAttribute('aria-disabled', 'true');
btn.dataset.disabled = '1';
} else {
btn.removeAttribute('aria-disabled');
delete btn.dataset.disabled;
}
}
const logTarget = !isPrev && isFinalPreStep ? completionUrl : newUrl;
console.log(`Button ${id}: target=${logTarget}, visible=${btn.style.display !== 'none'}`);
});
// Reinitialize HTMX
htmx.process(this.wrapper);
// Reinitialize interaction gating for the new step
if (typeof attachInteractionGating === 'function') {
attachInteractionGating(this.wrapper);
}
}
};
// Swipe gesture handler
function wizardSwipe() {
return {
startX: 0,
currentX: 0,
threshold: 75,
onTouchStart(e) {
if (!e.touches || !e.touches.length) return;
const x = e.touches[0].clientX;
this.startX = x;
this.currentX = x; // tap without move should not trigger swipe
},
onTouchMove(e) {
if (!e.touches || !e.touches.length) return;
this.currentX = e.touches[0].clientX;
},
onTouchEnd(e) {
// Safari may skip touchmove on quick taps; use last changed touch as fallback
if (e.changedTouches && e.changedTouches.length) {
this.currentX = e.changedTouches[0].clientX;
}
const diff = this.startX - this.currentX;
if (Math.abs(diff) > this.threshold) {
const btnId = diff > 0 ? 'wizard-next-btn' : 'wizard-prev-btn';
const btn = document.getElementById(btnId);
if (btn && btn.style.display !== 'none' && !btn.hasAttribute('aria-disabled')) {
btn.click();
}
}
this.startX = 0;
this.currentX = 0;
}
};
}
// Initialize controller when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => WizardController.init());
} else {
WizardController.init();
}
</script>