Files
customerTicket/design-prototypes/ticket-new-wizard/prototype.js
T
2026-09-14 15:48:13 +03:30

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