380 lines
17 KiB
JavaScript
380 lines
17 KiB
JavaScript
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);
|
||
});
|