const form = document.querySelector('#ticket-wizard'); const steps = [...document.querySelectorAll('[data-step]')]; const progress = [...document.querySelectorAll('[data-progress]')]; const unit = document.querySelector('#unit'); const category = document.querySelector('#category'); const subject = document.querySelector('#subject'); const body = document.querySelector('#body'); const province = document.querySelector('#province'); const city = document.querySelector('#city'); const files = []; let current = 1; const categories = { 'فروش': ['مشاوره خرید', 'موجودی و قیمت', 'پیگیری سفارش'], 'حسابداری': ['فیش واریزی', 'صورتحساب', 'پیگیری پرداخت'], 'بارگیری انبار': ['هماهنگی بار', 'زمان ارسال', 'پیگیری تحویل'], }; const cities = { 'تهران': ['تهران', 'شهریار', 'اسلامشهر'], 'اصفهان': ['اصفهان', 'کاشان', 'گلپایگان'], 'مرکزی': ['اراک', 'ساوه', 'محلات'], }; const recorderUi = { root: document.querySelector('#voice-recorder'), idle: document.querySelector('#voice-idle'), live: document.querySelector('#voice-live'), preview: document.querySelector('#voice-preview'), record: document.querySelector('#record'), pause: document.querySelector('#pause'), stop: document.querySelector('#stop'), cancel: document.querySelector('#cancel-recording'), rerecord: document.querySelector('#rerecord'), remove: document.querySelector('#delete-voice'), audio: document.querySelector('#audio'), reviewAudio: document.querySelector('#review-audio'), timer: document.querySelector('#voice-timer'), status: document.querySelector('#voice-status'), meta: document.querySelector('#voice-meta'), error: document.querySelector('#voice-error'), }; let recorder = null; let microphoneStream = null; let audioChunks = []; let voiceBlob = null; let voiceUrl = ''; let recordingStartedAt = 0; let pausedStartedAt = 0; let totalPausedMs = 0; let timerId = null; let stopResolver = null; let discardOnStop = false; function toPersianDigits(value) { return String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]); } function formatDuration(milliseconds) { const seconds = Math.max(0, Math.floor(milliseconds / 1000)); return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`); } function elapsedRecordingTime() { if (!recordingStartedAt) return 0; const activePause = recorder?.state === 'paused' ? Date.now() - pausedStartedAt : 0; return Date.now() - recordingStartedAt - totalPausedMs - activePause; } function setSelect(select, items, placeholder) { select.replaceChildren(new Option(placeholder, ''), ...items.map(item => new Option(item, item))); select.disabled = !items.length; } function showStep(number) { current = number; steps.forEach(step => { const active = Number(step.dataset.step) === number; step.hidden = !active; step.classList.toggle('active', active); }); progress.forEach(item => { const itemNumber = Number(item.dataset.progress); item.classList.toggle('active', itemNumber === number); item.classList.toggle('completed', itemNumber < number); item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber); }); window.scrollTo({ top: 0, behavior: 'smooth' }); } function showValidationError(number, message, invalidFields = []) { const step = document.querySelector(`[data-step="${number}"]`); const error = document.querySelector(`[data-error="${number}"]`); step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid')); invalidFields.forEach(field => field?.setAttribute('aria-invalid', 'true')); error.textContent = message; error.tabIndex = -1; error.classList.add('visible'); requestAnimationFrame(() => { error.focus({ preventScroll: true }); error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); }); } function clearValidationError(number) { const step = document.querySelector(`[data-step="${number}"]`); const error = document.querySelector(`[data-error="${number}"]`); step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid')); if (error) { error.textContent = ''; error.classList.remove('visible'); error.removeAttribute('tabindex'); } } function validateStep(number) { const error = document.querySelector(`[data-error="${number}"]`); if (number === 1) { const missing = []; const invalidFields = []; if (!unit.value) { missing.push('واحد'); invalidFields.push(unit); } if (!category.value) { missing.push('دسته‌بندی'); invalidFields.push(category); } if (subject.value.trim().length < 5) { missing.push('موضوع حداقل پنج‌حرفی'); invalidFields.push(subject); } if (missing.length) { showValidationError(number, `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, invalidFields); return false; } } if (number === 4 && !body.value.trim() && !files.length && !voiceBlob) { showValidationError(number, 'برای ادامه، توضیح بنویسید یا یک فایل/پیام صوتی اضافه کنید.', [body]); return false; } if (error) clearValidationError(number); return true; } function stopMicrophoneTracks() { microphoneStream?.getTracks().forEach(track => track.stop()); microphoneStream = null; } function clearTimer() { clearInterval(timerId); timerId = null; } function updateRecorderView(mode, statusText) { recorderUi.idle.hidden = mode !== 'idle'; recorderUi.live.hidden = mode !== 'live'; recorderUi.preview.hidden = mode !== 'preview'; recorderUi.root.dataset.state = mode; if (statusText) recorderUi.status.textContent = statusText; } function clearVoice() { clearTimer(); stopMicrophoneTracks(); if (voiceUrl) URL.revokeObjectURL(voiceUrl); voiceBlob = null; voiceUrl = ''; recorderUi.audio.removeAttribute('src'); recorderUi.reviewAudio.removeAttribute('src'); recorderUi.reviewAudio.hidden = true; recorderUi.timer.textContent = '۰۰:۰۰'; recorderUi.pause.textContent = 'مکث'; recorderUi.error.hidden = true; updateRecorderView('idle', 'برای توضیح سریع‌تر، صدای خود را ضبط کنید.'); } function completeRecording(blob, duration, automatic) { if (!blob.size) { clearVoice(); recorderUi.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.'; recorderUi.error.hidden = false; return; } if (voiceUrl) URL.revokeObjectURL(voiceUrl); voiceBlob = blob; voiceUrl = URL.createObjectURL(blob); recorderUi.audio.src = voiceUrl; recorderUi.reviewAudio.src = voiceUrl; recorderUi.timer.textContent = formatDuration(duration); recorderUi.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`; updateRecorderView('preview', automatic ? 'ضبط هنگام ادامه‌دادن متوقف و ذخیره شد.' : 'ضبط با موفقیت ذخیره شد؛ آن را بررسی کنید.'); } async function startRecording() { recorderUi.error.hidden = true; if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { recorderUi.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.'; recorderUi.error.hidden = false; return; } try { if (voiceBlob) clearVoice(); microphoneStream = 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'] .find(type => MediaRecorder.isTypeSupported?.(type)); recorder = new MediaRecorder(microphoneStream, mimeType ? { mimeType } : undefined); audioChunks = []; totalPausedMs = 0; discardOnStop = false; recorder.ondataavailable = event => { if (event.data.size) audioChunks.push(event.data); }; recorder.onerror = () => { recorderUi.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.'; recorderUi.error.hidden = false; clearVoice(); }; recorder.onstop = () => { const duration = elapsedRecordingTime(); clearTimer(); stopMicrophoneTracks(); if (discardOnStop) clearVoice(); else completeRecording(new Blob(audioChunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration, recorderUi.root.dataset.autoStop === 'true'); recorderUi.root.dataset.autoStop = 'false'; recorder = null; stopResolver?.(); stopResolver = null; }; recorder.start(500); recordingStartedAt = Date.now(); recorderUi.timer.textContent = '۰۰:۰۰'; updateRecorderView('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.'); timerId = setInterval(() => { recorderUi.timer.textContent = formatDuration(elapsedRecordingTime()); }, 250); } catch (error) { stopMicrophoneTracks(); recorderUi.error.textContent = error.name === 'NotAllowedError' ? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.' : 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده می‌کند.'; recorderUi.error.hidden = false; } } function stopRecording({ discard = false, automatic = false } = {}) { if (!recorder || recorder.state === 'inactive') return Promise.resolve(); discardOnStop = discard; recorderUi.root.dataset.autoStop = String(automatic); if (recorder.state === 'paused') { totalPausedMs += Date.now() - pausedStartedAt; recorder.resume(); } return new Promise(resolve => { stopResolver = resolve; recorder.stop(); }); } function togglePause() { if (!recorder) return; if (recorder.state === 'recording') { recorder.pause(); pausedStartedAt = Date.now(); recorderUi.pause.textContent = 'ادامه ضبط'; recorderUi.status.textContent = 'ضبط موقتاً متوقف شده است.'; recorderUi.root.dataset.state = 'paused'; } else if (recorder.state === 'paused') { totalPausedMs += Date.now() - pausedStartedAt; recorder.resume(); recorderUi.pause.textContent = 'مکث'; recorderUi.status.textContent = 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.'; recorderUi.root.dataset.state = 'live'; } } function buildReview() { document.querySelector('#review-subject').textContent = subject.value; document.querySelector('#review-unit').textContent = unit.value; document.querySelector('#review-category').textContent = category.value; document.querySelector('#review-location').textContent = province.value ? [province.value, city.value].filter(Boolean).join('، ') : 'ثبت نشده'; document.querySelector('#review-expert').textContent = document.querySelector('input[name="expert"]:checked').value; document.querySelector('#review-body').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.'; const list = document.querySelector('#review-files'); list.replaceChildren(); files.forEach(file => { const chip = document.createElement('span'); chip.textContent = file.name; list.appendChild(chip); }); if (!files.length && !voiceBlob) { const chip = document.createElement('span'); chip.textContent = 'بدون پیوست'; list.appendChild(chip); } recorderUi.reviewAudio.hidden = !voiceBlob; } document.querySelectorAll('[data-next]').forEach(button => button.addEventListener('click', async () => { button.disabled = true; if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true }); button.disabled = false; if (!validateStep(current)) return; if (current === 4) buildReview(); showStep(Math.min(5, current + 1)); })); document.querySelectorAll('[data-prev]').forEach(button => button.addEventListener('click', async () => { if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true }); showStep(Math.max(1, current - 1)); })); document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.edit)))); document.querySelector('#manual-toggle').addEventListener('click', event => { const target = document.querySelector('#manual-fields'); target.hidden = !target.hidden; event.currentTarget.setAttribute('aria-expanded', String(!target.hidden)); event.currentTarget.querySelector('span').textContent = target.hidden ? '⌄' : '⌃'; }); unit.addEventListener('change', () => setSelect(category, categories[unit.value] || [], 'انتخاب دسته‌بندی')); province.addEventListener('change', () => setSelect(city, cities[province.value] || [], 'انتخاب شهر')); document.querySelectorAll('[data-suggest-unit]').forEach(button => button.addEventListener('click', () => { unit.value = button.dataset.suggestUnit; setSelect(category, categories[unit.value] || [], 'انتخاب دسته‌بندی'); category.value = button.dataset.suggestCategory; document.querySelector('#manual-fields').hidden = false; document.querySelector('#manual-toggle').setAttribute('aria-expanded', 'true'); document.querySelectorAll('[data-suggest-unit]').forEach(item => item.classList.toggle('chosen', item === button)); })); document.querySelector('#intent').addEventListener('input', event => { const query = event.target.value.trim(); const suggestions = document.querySelector('#suggestions'); const suggestionButtons = [...suggestions.querySelectorAll('[data-suggest-unit]')]; suggestions.hidden = !query; if (!query) { suggestionButtons.forEach(button => { button.hidden = false; }); document.querySelector('#suggestion-empty').hidden = true; return; } const normalizedQuery = query.toLocaleLowerCase('fa'); let visibleCount = 0; suggestionButtons.forEach(button => { const matches = button.textContent.toLocaleLowerCase('fa').includes(normalizedQuery); button.hidden = !matches; if (matches) visibleCount += 1; }); document.querySelector('#suggestion-empty').hidden = visibleCount > 0; }); document.querySelectorAll('.expert input').forEach(input => input.addEventListener('change', () => { document.querySelectorAll('.expert').forEach(card => card.classList.toggle('selected', card.querySelector('input').checked)); })); body.addEventListener('input', () => { document.querySelector('#body-count').textContent = toPersianDigits(body.value.length); }); function renderFiles() { const preview = document.querySelector('#file-preview'); preview.replaceChildren(); files.forEach((file, index) => { const card = document.createElement('div'); card.className = 'file-card'; if (file.type.startsWith('image/')) { const image = document.createElement('img'); image.src = URL.createObjectURL(file); image.onload = () => URL.revokeObjectURL(image.src); image.alt = `پیش‌نمایش ${file.name}`; card.appendChild(image); } else { const icon = document.createElement('div'); icon.className = 'file-icon'; icon.textContent = (file.name.split('.').pop() || 'FILE').toUpperCase(); card.appendChild(icon); } const label = document.createElement('small'); label.textContent = file.name; card.appendChild(label); const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `حذف ${file.name}`); remove.addEventListener('click', () => { files.splice(index, 1); renderFiles(); }); card.appendChild(remove); preview.appendChild(card); }); } document.querySelectorAll('#gallery,#documents').forEach(input => input.addEventListener('change', () => { files.push(...input.files); input.value = ''; renderFiles(); })); recorderUi.record.addEventListener('click', startRecording); recorderUi.pause.addEventListener('click', togglePause); recorderUi.stop.addEventListener('click', () => stopRecording()); recorderUi.cancel.addEventListener('click', () => stopRecording({ discard: true })); recorderUi.remove.addEventListener('click', clearVoice); recorderUi.rerecord.addEventListener('click', startRecording); window.addEventListener('pagehide', () => { stopMicrophoneTracks(); clearTimer(); }); form.addEventListener('submit', event => { event.preventDefault(); steps.forEach(step => { step.hidden = true; }); document.querySelector('#success-page').hidden = false; progress.forEach(item => item.classList.add('completed')); window.scrollTo({ top: 0, behavior: 'smooth' }); }); document.querySelector('#restart').addEventListener('click', () => { document.querySelector('#success-page').hidden = true; showStep(1); });