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