Files
customerTicket/static/js/customer-ticket-wizard.js
2026-09-16 14:16:05 +03:30

380 lines
18 KiB
JavaScript

(() => {
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;
let guidedField = null;
let guideTimer = null;
const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
function focusWizardCard({ respectExistingFocus = false } = {}) {
requestAnimationFrame(() => {
if (
respectExistingFocus
&& document.activeElement !== document.body
&& document.activeElement !== document.documentElement
) return;
form.focus({ preventScroll: true });
form.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
inline: 'nearest',
});
});
}
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) focusWizardCard();
}
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 (field.getAttribute('aria-describedby') === error?.id) field.removeAttribute('aria-describedby');
});
if (error) {
error.textContent = '';
error.classList.remove('is-visible');
}
}
function clearGuidedField(field = guidedField) {
if (!field) return;
field.closest('.field, .ticket-intent-search, .ticket-location-fields')?.classList.remove('is-guided-field');
if (field === guidedField) guidedField = null;
if (guideTimer) {
window.clearTimeout(guideTimer);
guideTimer = null;
}
}
function guideToField(fields, error) {
const field = fields.find(item => item && !item.disabled && !item.hidden && item.getClientRects().length);
if (!field) {
error?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
return;
}
clearGuidedField();
guidedField = field;
const container = field.closest('.field, .ticket-intent-search, .ticket-location-fields');
container?.classList.add('is-guided-field');
requestAnimationFrame(() => {
field.focus({ preventScroll: true });
field.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'center',
inline: 'nearest',
});
});
guideTimer = window.setTimeout(() => clearGuidedField(field), 2800);
}
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');
if (field && error?.id) field.setAttribute('aria-describedby', error.id);
});
error.textContent = problem.message;
error.classList.add('is-visible');
guideToField(problem.fields, error);
}
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.getAttribute?.('aria-describedby')?.startsWith('wizard-error-')) event.target.removeAttribute('aria-describedby');
clearGuidedField(event.target);
if (event.target === body) form.querySelector('[data-body-count]').textContent = toPersianDigits(body.value.length);
});
form.addEventListener('change', event => {
event.target.removeAttribute?.('aria-invalid');
if (event.target.getAttribute?.('aria-describedby')?.startsWith('wizard-error-')) event.target.removeAttribute('aria-describedby');
clearGuidedField(event.target);
});
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 fields = [...serverErrorStep.querySelectorAll('.errorlist')]
.map(list => list.closest('.field, .ticket-server-errors')?.querySelector('input, select, textarea'))
.filter(Boolean);
const number = Number(serverErrorStep.dataset.wizardStep);
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields }));
} else focusWizardCard({ respectExistingFocus: true });
})();