(() => { const form = document.querySelector('[data-ticket-wizard]'); if (!form) return; const steps = [...form.querySelectorAll('[data-wizard-step]')]; const progress = [...document.querySelectorAll('[data-wizard-progress-item]')]; const unit = form.querySelector('[data-ticket-unit]'); const category = form.querySelector('[data-ticket-category]'); const subject = form.querySelector('[data-ticket-subject]'); const province = form.querySelector('[data-province-input]'); const city = form.querySelector('[data-city-input]'); const body = form.querySelector('textarea[name="body"]'); const canonicalFiles = form.querySelector('input[name="files"]'); const voiceRoot = form.querySelector('[data-ticket-voice-recorder]'); let currentStep = 1; const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]); function updateProgress(number) { progress.forEach(item => { const itemNumber = Number(item.dataset.wizardProgressItem); item.classList.toggle('is-active', itemNumber === number); item.classList.toggle('is-complete', itemNumber < number); item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber); }); } function showStep(number, { scroll = true } = {}) { currentStep = number; steps.forEach(step => { const active = Number(step.dataset.wizardStep) === number; step.hidden = !active; step.classList.toggle('is-active', active); }); updateProgress(number); if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' }); } function clearValidation(number) { const step = form.querySelector(`[data-wizard-step="${number}"]`); const error = form.querySelector(`[data-wizard-error="${number}"]`); step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid')); if (error) { error.textContent = ''; error.classList.remove('is-visible'); error.removeAttribute('tabindex'); } } function showValidation(number, problem) { const step = form.querySelector(`[data-wizard-step="${number}"]`); const error = form.querySelector(`[data-wizard-error="${number}"]`); if (number === 1 && problem.fields.some(field => field === unit || field === category)) { const manualRouting = form.querySelector('.ticket-manual-routing'); if (manualRouting) manualRouting.open = true; } step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid')); problem.fields.forEach(field => field?.setAttribute('aria-invalid', 'true')); error.textContent = problem.message; error.tabIndex = -1; error.classList.add('is-visible'); requestAnimationFrame(() => { error.focus({ preventScroll: true }); error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); }); } function problemForStep(number) { if (number === 1) { const missing = []; const fields = []; if (!unit?.value) { missing.push('واحد سازمانی'); fields.push(unit); } if (!category?.value) { missing.push('دسته‌بندی'); fields.push(category); } if ((subject?.value.trim().length || 0) < 5) { missing.push('موضوع دقیق حداقل پنج‌حرفی'); fields.push(subject); } if (missing.length) return { message: `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, fields }; } if (number === 2) { if (city?.value && !province?.value) return { message: 'برای شهر انتخاب‌شده، استان را نیز مشخص کنید.', fields: [province, city] }; } if (number === 4 && !body?.value.trim() && !(canonicalFiles?.files.length)) { return { message: 'برای ادامه، شرح درخواست بنویسید یا یک فایل/پیام صوتی اضافه کنید.', fields: [body] }; } return null; } function validateStep(number) { const problem = problemForStep(number); if (problem) { showValidation(number, problem); return false; } clearValidation(number); return true; } function selectedText(select, fallback) { return select?.selectedOptions?.[0]?.textContent?.trim() || fallback; } function buildReview() { form.querySelector('[data-review-subject]').textContent = subject.value.trim(); form.querySelector('[data-review-unit]').textContent = selectedText(unit, '—'); form.querySelector('[data-review-category]').textContent = selectedText(category, '—'); form.querySelector('[data-review-location]').textContent = province?.value ? [province.value, city?.value].filter(Boolean).join('، ') : 'ثبت نشده'; const selectedExpert = form.querySelector('input[name="selected_expert"]:checked'); form.querySelector('[data-review-expert]').textContent = selectedExpert?.closest('label')?.querySelector('strong')?.textContent?.trim() || 'صف عمومی واحد'; form.querySelector('[data-review-body]').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.'; const fileList = form.querySelector('[data-review-files]'); fileList.replaceChildren(); const visibleFiles = [...(canonicalFiles?.files || [])].filter(file => !file.type.startsWith('audio/')); visibleFiles.forEach(file => { const chip = document.createElement('span'); chip.textContent = file.name; fileList.append(chip); }); const reviewAudio = form.querySelector('[data-review-audio]'); const voiceApi = voiceRoot?.ticketVoiceRecorder; reviewAudio.hidden = !voiceApi?.hasVoice(); if (voiceApi?.url()) reviewAudio.src = voiceApi.url(); if (!visibleFiles.length && !voiceApi?.hasVoice()) { const chip = document.createElement('span'); chip.textContent = 'بدون پیوست'; fileList.append(chip); } } async function leaveCurrentStep(targetStep) { const button = form.querySelector(`[data-wizard-step="${currentStep}"] [data-wizard-next]`); if (button) button.disabled = true; if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true }); if (button) button.disabled = false; if (!validateStep(currentStep)) return; if (currentStep === 4) buildReview(); showStep(targetStep); } form.querySelectorAll('[data-wizard-next]').forEach(button => button.addEventListener('click', () => leaveCurrentStep(Math.min(5, currentStep + 1)))); form.querySelectorAll('[data-wizard-prev]').forEach(button => button.addEventListener('click', async () => { if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true }); showStep(Math.max(1, currentStep - 1)); })); form.querySelectorAll('[data-wizard-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.wizardEdit)))); form.addEventListener('input', event => { event.target.removeAttribute?.('aria-invalid'); if (event.target === body) form.querySelector('[data-body-count]').textContent = toPersianDigits(body.value.length); }); form.addEventListener('change', event => event.target.removeAttribute?.('aria-invalid')); form.addEventListener('submit', event => { const invalidStep = [1, 2, 3, 4].find(number => problemForStep(number)); if (!invalidStep) return; event.preventDefault(); form.querySelectorAll('button[type="submit"]').forEach(button => { button.disabled = false; }); showStep(invalidStep); requestAnimationFrame(() => validateStep(invalidStep)); }); function setupVoiceRecorder(root) { if (!root) return; const ui = { idle: root.querySelector('[data-voice-idle]'), live: root.querySelector('[data-voice-live]'), preview: root.querySelector('[data-voice-preview]'), record: root.querySelector('[data-voice-record]'), pause: root.querySelector('[data-voice-pause]'), stop: root.querySelector('[data-voice-stop]'), cancel: root.querySelector('[data-voice-cancel]'), rerecord: root.querySelector('[data-voice-rerecord]'), remove: root.querySelector('[data-voice-delete]'), audio: root.querySelector('[data-voice-audio]'), timer: root.querySelector('[data-voice-timer]'), status: root.querySelector('[data-voice-status]'), meta: root.querySelector('[data-voice-meta]'), error: root.querySelector('[data-voice-error]'), }; let recorder = null; let stream = null; let chunks = []; let voiceFile = null; let voiceUrl = ''; let startedAt = 0; let pausedAt = 0; let pausedMs = 0; let timer = null; let stopResolver = null; let discardOnStop = false; let automaticStop = false; const elapsed = () => Math.max(0, Date.now() - startedAt - pausedMs - (recorder?.state === 'paused' ? Date.now() - pausedAt : 0)); const formatDuration = milliseconds => { const seconds = Math.floor(milliseconds / 1000); return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`); }; const stopTracks = () => { stream?.getTracks().forEach(track => track.stop()); stream = null; }; const clearTimer = () => { clearInterval(timer); timer = null; }; const showState = (state, status) => { ui.idle.hidden = state !== 'idle'; ui.live.hidden = state !== 'live' && state !== 'paused'; ui.preview.hidden = state !== 'preview'; root.dataset.state = state; if (status) ui.status.textContent = status; }; const removeVoice = ({ notify = true } = {}) => { if (notify && voiceFile) form.dispatchEvent(new CustomEvent('voice-removed', { detail: voiceFile })); voiceFile = null; if (voiceUrl) URL.revokeObjectURL(voiceUrl); voiceUrl = ''; ui.audio.removeAttribute('src'); ui.timer.textContent = '۰۰:۰۰'; ui.pause.textContent = 'مکث'; ui.error.hidden = true; showState('idle', 'برای توضیح سریع‌تر، صدای خود را ضبط کنید.'); }; const complete = (blob, duration) => { if (!blob.size) { removeVoice(); ui.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.'; ui.error.hidden = false; return; } if (voiceUrl) URL.revokeObjectURL(voiceUrl); voiceUrl = URL.createObjectURL(blob); ui.audio.src = voiceUrl; const type = blob.type || 'audio/webm'; const extension = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : type.includes('wav') ? 'wav' : 'webm'; voiceFile = new File([blob], `voice-${Date.now()}.${extension}`, { type }); form.dispatchEvent(new CustomEvent('voice-added', { detail: voiceFile })); ui.timer.textContent = formatDuration(duration); ui.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`; showState('preview', automaticStop ? 'ضبط هنگام ادامه‌دادن متوقف و ذخیره شد.' : 'ضبط ذخیره شد؛ پیش از ارسال آن را بررسی کنید.'); }; const start = async () => { ui.error.hidden = true; if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { ui.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.'; ui.error.hidden = false; return; } try { if (voiceFile) removeVoice(); stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm', 'audio/wav'].find(type => MediaRecorder.isTypeSupported?.(type)); recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); chunks = []; pausedMs = 0; discardOnStop = false; automaticStop = false; recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); }; recorder.onerror = () => { stopTracks(); clearTimer(); ui.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.'; ui.error.hidden = false; showState('idle'); }; recorder.onstop = () => { const duration = elapsed(); clearTimer(); stopTracks(); if (discardOnStop) removeVoice(); else complete(new Blob(chunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration); recorder = null; stopResolver?.(); stopResolver = null; }; recorder.start(500); startedAt = Date.now(); ui.timer.textContent = '۰۰:۰۰'; showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.'); timer = setInterval(() => { ui.timer.textContent = formatDuration(elapsed()); }, 250); } catch (error) { stopTracks(); ui.error.textContent = error.name === 'NotAllowedError' ? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.' : 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده می‌کند.'; ui.error.hidden = false; } }; const stopAndSave = ({ discard = false, automatic = false } = {}) => { if (!recorder || recorder.state === 'inactive') return Promise.resolve(); discardOnStop = discard; automaticStop = automatic; if (recorder.state === 'paused') { pausedMs += Date.now() - pausedAt; recorder.resume(); } return new Promise(resolve => { stopResolver = resolve; recorder.stop(); }); }; const togglePause = () => { if (recorder?.state === 'recording') { recorder.pause(); pausedAt = Date.now(); ui.pause.textContent = 'ادامه ضبط'; showState('paused', 'ضبط موقتاً متوقف شده است.'); } else if (recorder?.state === 'paused') { pausedMs += Date.now() - pausedAt; recorder.resume(); ui.pause.textContent = 'مکث'; showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.'); } }; ui.record.addEventListener('click', start); ui.pause.addEventListener('click', togglePause); ui.stop.addEventListener('click', () => stopAndSave()); ui.cancel.addEventListener('click', () => stopAndSave({ discard: true })); ui.remove.addEventListener('click', removeVoice); ui.rerecord.addEventListener('click', start); root.ticketVoiceRecorder = { stopAndSave, hasVoice: () => Boolean(voiceFile), url: () => voiceUrl }; window.addEventListener('pagehide', () => { stopTracks(); clearTimer(); }); } setupVoiceRecorder(voiceRoot); form.dataset.wizardEnhanced = ''; const serverErrorStep = steps.find(step => step.querySelector('.errorlist')); const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1; showStep(initialStep, { scroll: false }); if (serverErrorStep) { const messages = [...serverErrorStep.querySelectorAll('.errorlist li')].map(item => item.textContent.trim()).filter(Boolean); const number = Number(serverErrorStep.dataset.wizardStep); requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields: [] })); } })();