mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-07-31 07:17:10 -04:00
- 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.
661 lines
28 KiB
HTML
661 lines
28 KiB
HTML
{# 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>
|