fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

@@ -0,0 +1,148 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>نمونه رابط مرحله‌ای ثبت درخواست</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="prototype-note">
<span>نمونه نمایشی مستقل</span>
<p>این صفحه به Django و اطلاعات واقعی متصل نیست.</p>
</header>
<main class="shell">
<nav class="topbar" aria-label="مسیر صفحه">
<button class="back-button" type="button">→ بازگشت به درخواست‌ها</button>
<div class="brand"><span>لوله‌گستر</span><small>سامانه ارتباط با مشتریان</small></div>
</nav>
<section class="intro">
<div>
<span class="eyebrow">ثبت درخواست جدید</span>
<h1>چطور می‌توانیم کمک کنیم؟</h1>
<p>قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.</p>
</div>
<div class="save-state"><i></i> اطلاعات شما در این صفحه حفظ می‌شود</div>
</section>
<ol class="progress" aria-label="مراحل ثبت درخواست">
<li class="active" data-progress="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
<li data-progress="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
<li data-progress="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
<li data-progress="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
<li data-progress="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
</ol>
<form class="wizard" id="ticket-wizard" novalidate>
<section class="step active" data-step="1" aria-labelledby="step-1-title">
<header class="step-head"><span class="step-symbol">⌕</span><div><small>مرحله ۱ از ۵</small><h2 id="step-1-title">موضوع درخواستتان چیست؟</h2><p>با چند کلمه شروع کنید تا مسیر مناسب را پیشنهاد دهیم، یا خودتان انتخاب کنید.</p></div></header>
<label class="search-box"><span class="sr-only">جست‌وجوی موضوع درخواست</span><i>⌕</i><input id="intent" type="search" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جست‌وجو</kbd></label>
<div class="suggestions" id="suggestions" aria-live="polite" hidden>
<button type="button" data-suggest-unit="حسابداری" data-suggest-category="فیش واریزی"><b>ارسال فیش یا رسید پرداخت</b><small>حسابداری · فیش واریزی</small><span>انتخاب ←</span></button>
<button type="button" data-suggest-unit="بارگیری انبار" data-suggest-category="هماهنگی بار"><b>هماهنگی ارسال یا بارگیری</b><small>بارگیری انبار · هماهنگی بار</small><span>انتخاب ←</span></button>
<p class="suggestion-empty" id="suggestion-empty" hidden>پیشنهاد دقیقی پیدا نشد؛ از انتخاب دستی استفاده کنید.</p>
</div>
<button class="manual-toggle" type="button" id="manual-toggle" aria-expanded="false">انتخاب دستی واحد و دسته‌بندی <span>⌄</span></button>
<div class="manual-fields" id="manual-fields" hidden>
<label><span>واحد سازمانی <em>*</em></span><select id="unit" required><option value="">انتخاب واحد</option><option>فروش</option><option>حسابداری</option><option>بارگیری انبار</option></select></label>
<label><span>دسته‌بندی <em>*</em></span><select id="category" required disabled><option value="">ابتدا واحد را انتخاب کنید</option></select></label>
</div>
<label class="full-field"><span>موضوع دقیق درخواست <em>*</em></span><input id="subject" maxlength="200" required placeholder="یک عنوان کوتاه و روشن بنویسید"><small>مثال: پیگیری فیش واریزی سفارش ۲۴۸</small></label>
<p class="validation" data-error="1" role="alert"></p>
<footer class="step-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="2" aria-labelledby="step-2-title" hidden>
<header class="step-head"><span class="step-symbol">⌖</span><div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div></header>
<div class="two-columns">
<label><span>استان</span><select id="province"><option value="">انتخاب استان</option><option>تهران</option><option>اصفهان</option><option>مرکزی</option></select></label>
<label><span>شهر</span><select id="city" disabled><option value="">ابتدا استان را انتخاب کنید</option></select></label>
</div>
<div class="soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b>این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.</p></div>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="3" aria-labelledby="step-3-title" hidden>
<header class="step-head"><span class="step-symbol">◎</span><div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد می‌رسد.</p></div></header>
<div class="expert-list">
<label class="expert selected"><input type="radio" name="expert" value="صف عمومی" checked><span class="avatar queue">⌁</span><div><b>صف عمومی واحد</b><small>سریع‌ترین انتخاب · اولین کارشناس آزاد</small></div><i>✓</i></label>
<label class="expert"><input type="radio" name="expert" value="مریم احمدی"><span class="avatar">م</span><div><b>مریم احمدی</b><small>کارشناس فروش · پاسخ‌گویی فعال</small></div><i>✓</i></label>
<label class="expert"><input type="radio" name="expert" value="علی رضایی"><span class="avatar amber">ع</span><div><b>علی رضایی</b><small>کارشناس ارشد · پاسخ‌گویی فعال</small></div><i>✓</i></label>
</div>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="4" aria-labelledby="step-4-title" hidden>
<header class="step-head"><span class="step-symbol">≡</span><div><small>مرحله ۴ از ۵</small><h2 id="step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>هرچه واضح‌تر توضیح دهید، پاسخ دقیق‌تری دریافت می‌کنید. متن یا حداقل یک پیوست لازم است.</p></div></header>
<label class="full-field"><span>شرح درخواست</span><textarea id="body" rows="6" placeholder="شماره سفارش، محصول موردنظر و توضیح مسئله را بنویسید..."></textarea><small><span id="body-count">۰</span> از ۲۰۰۰ نویسه</small></label>
<div class="attachment-zone">
<div class="attachment-head"><span>+</span><div><b>عکس و فایل‌های مرتبط</b><small>حداکثر ۱۰ فایل؛ تصاویر بزرگ قبل از ارسال بهینه می‌شوند.</small></div></div>
<div class="attachment-actions"><label for="gallery">▣ انتخاب عکس از گالری</label><label for="documents">+ انتخاب فایل</label></div>
<input id="gallery" type="file" accept="image/*" multiple hidden><input id="documents" type="file" multiple hidden>
<div class="file-preview" id="file-preview" aria-live="polite"></div>
</div>
<section class="voice-recorder" id="voice-recorder" aria-labelledby="voice-title">
<header class="voice-head">
<span class="voice-icon" aria-hidden="true">◉</span>
<div><b id="voice-title">پیام صوتی</b><small id="voice-status" aria-live="polite">برای توضیح سریع‌تر، صدای خود را ضبط کنید.</small></div>
<span class="voice-timer" id="voice-timer">۰۰:۰۰</span>
</header>
<div class="voice-idle" id="voice-idle">
<button class="voice-start" type="button" id="record"><span>●</span> شروع ضبط صدا</button>
<small>پس از اجازه‌دادن به میکروفن، ضبط آغاز می‌شود.</small>
</div>
<div class="voice-live" id="voice-live" hidden>
<div class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="voice-controls">
<button type="button" id="pause">مکث</button>
<button class="voice-stop" type="button" id="stop">■ توقف و ذخیره</button>
<button class="voice-cancel" type="button" id="cancel-recording">لغو</button>
</div>
</div>
<div class="voice-preview" id="voice-preview" hidden>
<div class="voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small id="voice-meta">قابل پخش و بررسی پیش از ارسال</small></p></div>
<audio id="audio" controls preload="metadata"></audio>
<div class="voice-preview-actions"><button type="button" id="rerecord">ضبط مجدد</button><button type="button" id="delete-voice">حذف پیام صوتی</button></div>
</div>
<p class="voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال به‌صورت خودکار متوقف و ذخیره می‌شود.</p>
<p class="voice-error" id="voice-error" role="alert" hidden></p>
</section>
<p class="validation" data-error="4" role="alert"></p>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>مشاهده پیش‌نمایش <b>←</b></button></footer>
</section>
<section class="step review-step" data-step="5" aria-labelledby="step-5-title" hidden>
<header class="step-head"><span class="step-symbol success">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="step-5-title">همه‌چیز آماده ارسال است</h2><p>اطلاعات را یک بار مرور کنید؛ هر بخش را می‌توانید جداگانه ویرایش کنید.</p></div></header>
<div class="review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ می‌شود.</small></div></div>
<div class="review-list">
<section class="review-row">
<span class="review-icon">۱</span><div class="review-content"><small>موضوع و مسیر رسیدگی</small><h3 id="review-subject">—</h3><p><span id="review-unit">—</span><i>←</i><span id="review-category">—</span></p></div><button type="button" data-edit="1"><span>✎</span> ویرایش</button>
</section>
<section class="review-row">
<span class="review-icon">۲</span><div class="review-content"><small>موقعیت</small><h3 id="review-location">ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-edit="2"><span>✎</span> ویرایش</button>
</section>
<section class="review-row">
<span class="review-icon">۳</span><div class="review-content"><small>نحوه ارجاع</small><h3 id="review-expert">صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-edit="3"><span>✎</span> ویرایش</button>
</section>
<section class="review-row review-details">
<span class="review-icon">۴</span><div class="review-content"><small>شرح و پیوست‌ها</small><p class="review-body" id="review-body">—</p><div class="review-files" id="review-files"></div><audio id="review-audio" class="review-audio" controls hidden></audio></div><button type="button" data-edit="4"><span>✎</span> ویرایش</button>
</section>
</div>
<footer class="step-actions final-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary send" type="submit">ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
<section class="success-page" id="success-page" hidden>
<span>✓</span><h2>درخواست با موفقیت آماده شد</h2><p>این فقط نمونه نمایشی است و اطلاعاتی به سرور ارسال نشد.</p><button type="button" id="restart">مشاهده دوباره طرح</button>
</section>
</form>
</main>
<nav class="mobile-dock" aria-label="دسترسی سریع مشتری">
<button type="button"><span>◎</span>درخواست‌ها</button>
<button class="active" type="button"><span>+</span>درخواست جدید</button>
<button type="button"><span>○</span>پروفایل</button>
</nav>
<script src="prototype.js"></script>
</body>
</html>
+379
View File
@@ -0,0 +1,379 @@
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);
});
@@ -0,0 +1,50 @@
@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-latin-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}
:root{font-family:Vazirmatn,ui-sans-serif,system-ui,sans-serif;color:#08376b;background:#eee;--ink:#08376b;--ink-soft:#0b4c8c;--sea:#0693e3;--sea-dark:#1977ce;--coral:#fa9f00;--cream:#eee;--soft:#eaeaea;--paper:#fff;--mint:#e5f4ff;--line:rgba(8,55,107,.1);--muted:rgba(8,55,107,.58);--shadow-panel:0 18px 55px -35px rgba(8,55,107,.35);--shadow-lift:0 22px 55px -28px rgba(6,147,227,.42)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:var(--cream);color:var(--ink);-webkit-font-smoothing:antialiased}button,input,textarea,select{font:inherit}button,input,textarea,select,a{outline:0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{box-shadow:0 0 0 4px rgba(6,147,227,.2)}button{cursor:pointer}button:disabled{pointer-events:none;opacity:.5}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
.prototype-note{display:grid;gap:.05rem;padding:.55rem .8rem;background:#fff3d9;color:#805300;text-align:center;font-size:.68rem}.prototype-note span{font-weight:900}.prototype-note p{margin:0}.shell{width:100%;padding:0 .75rem 6.5rem}.topbar{display:flex;min-height:3.75rem;align-items:center;justify-content:space-between}.back-button,.manual-toggle{border:0;background:transparent;color:var(--sea-dark);font-size:.72rem;font-weight:850}.brand{display:none}.intro{margin:.8rem 0 1.1rem}.eyebrow{color:var(--sea);font-size:.68rem;font-weight:900;letter-spacing:.03em}.intro h1{margin:.3rem 0;color:var(--ink);font-size:1.45rem;line-height:1.45;letter-spacing:-.025em}.intro p{margin:0;color:var(--muted);font-size:.76rem;line-height:1.9}.save-state{display:none}
.progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .75rem;padding:0;list-style:none}.progress:before{position:absolute;right:10%;left:10%;top:1.05rem;height:2px;background:rgba(8,55,107,.1);content:""}.progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:var(--cream);font-size:.68rem;font-weight:900}.progress li>div{display:none}.progress li.active>span{border-color:var(--sea);background:var(--sea);color:#fff;box-shadow:0 8px 20px -12px var(--sea)}.progress li.completed>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
.wizard{border:1px solid var(--line);border-radius:1.1rem;background:var(--paper);box-shadow:var(--shadow-panel)}.step{padding:1rem}.step-head{display:flex;align-items:flex-start;gap:.65rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.step-symbol{display:grid;width:2.4rem;height:2.4rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.step-symbol.success{background:#e5f8ef;color:#16815e}.step-head small{color:rgba(8,55,107,.42);font-size:.6rem;font-weight:700}.step-head h2{margin:.1rem 0;color:var(--ink);font-size:1rem;line-height:1.55}.step-head p{margin:0;color:var(--muted);font-size:.7rem;line-height:1.85}
.search-box{display:flex;align-items:center;margin:1rem 0 .6rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .7rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.search-box:focus-within{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.search-box i{color:var(--sea);font-style:normal}.search-box input{width:100%;min-width:0;min-height:3rem;border:0;background:transparent;padding:.65rem;font-size:.76rem;color:var(--ink)}.search-box kbd{display:none}.suggestions{display:grid;gap:.4rem}.suggestions button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.12rem .5rem;width:100%;border:1px solid rgba(8,55,107,.09);border-radius:.8rem;background:#fff;padding:.7rem;text-align:right;color:var(--ink)}.suggestions button:active,.suggestions button.chosen{border-color:rgba(6,147,227,.45);background:var(--mint)}.suggestions b{font-size:.7rem}.suggestions small{color:var(--muted);font-size:.6rem}.suggestions span{grid-column:2;grid-row:1/3;align-self:center;color:var(--sea-dark);font-size:.6rem;font-weight:900}.manual-toggle{display:flex;width:100%;justify-content:space-between;margin-top:.8rem;border-top:1px dashed rgba(8,55,107,.12);padding:.85rem .1rem .3rem}.manual-fields,.two-columns{display:grid;gap:.7rem;margin-top:.55rem}.manual-fields label,.two-columns label,.full-field{display:grid;gap:.35rem;color:rgba(8,55,107,.78);font-size:.7rem;font-weight:850}.manual-fields em,.full-field em{color:var(--coral);font-style:normal}.manual-fields select,.two-columns select,.full-field input,.full-field textarea{width:100%;min-height:3rem;border:1px solid rgba(8,55,107,.12);border-radius:1rem;background:#fff;padding:.7rem .8rem;color:var(--ink);font-size:.76rem;box-shadow:0 1px 3px rgba(8,55,107,.05)}.manual-fields select:focus,.two-columns select:focus,.full-field input:focus,.full-field textarea:focus{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.full-field{margin-top:.85rem}.full-field textarea{min-height:9rem;resize:vertical;line-height:1.9}.full-field small{display:flex;justify-content:flex-end;color:rgba(8,55,107,.45);font-size:.6rem;font-weight:500}.validation{min-height:1rem;margin:.45rem 0 0;color:#a32929;font-size:.64rem;font-weight:700}
.step-actions{position:sticky;z-index:10;bottom:4.65rem;display:grid;grid-template-columns:1fr 1.35fr;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.96);padding:.7rem 1rem;backdrop-filter:blur(12px)}.step-actions>span{display:none}.primary,.secondary,.voice-box button,.success-page button{display:inline-flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border-radius:1rem;padding:.65rem .9rem;font-size:.72rem;font-weight:900}.primary{border:0;background:var(--sea);color:#fff;box-shadow:0 10px 30px -14px var(--sea)}.primary:active{background:var(--sea-dark)}.secondary{border:1px solid var(--line);background:#fff;color:rgba(8,55,107,.7);box-shadow:none}.step-actions .primary:only-of-type{grid-column:2}.soft-tip{display:flex;align-items:center;gap:.65rem;margin:1rem 0;border-radius:.85rem;background:var(--mint);padding:.75rem;color:rgba(8,55,107,.65)}.soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:var(--sea-dark);font-weight:900}.soft-tip p{margin:0;font-size:.65rem}.soft-tip b{display:block;color:var(--ink)}
.expert-list{display:grid;gap:.55rem;margin:1rem 0}.expert{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.72rem;text-align:right}.expert.selected{border:2px solid var(--sea);background:var(--mint)}.expert input{position:absolute;opacity:0}.expert>i{display:none;width:1.3rem;height:1.3rem;place-items:center;border-radius:50%;background:var(--sea);color:#fff;font-size:.58rem;font-style:normal}.expert.selected>i{display:grid}.avatar{display:grid;width:2.7rem;height:2.7rem;place-items:center;border-radius:.85rem;background:#e4eef8;color:#285b87;font-weight:950}.avatar.queue{background:#e4f6ef;color:#16815e}.avatar.amber{background:#fff1d7;color:#9a6200}.expert b,.expert small{display:block}.expert b{font-size:.72rem}.expert small{margin-top:.12rem;color:var(--muted);font-size:.59rem}
.attachment-zone{display:grid;gap:.7rem;margin-top:.85rem;border:2px dashed rgba(8,55,107,.14);border-radius:1rem;background:rgba(229,244,255,.45);padding:.85rem}.attachment-head,.voice-box,.voice-box>div{display:flex;align-items:center;gap:.65rem}.attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.attachment-head b,.attachment-head small,.voice-box b,.voice-box small{display:block}.attachment-head b,.voice-box b{font-size:.7rem}.attachment-head small,.voice-box small{margin-top:.1rem;color:var(--muted);font-size:.58rem}.attachment-actions{display:grid;gap:.5rem}.attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.7rem;font-weight:900;cursor:pointer}.attachment-actions label:first-child{border-color:var(--sea);background:var(--mint)}.file-preview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem}.file-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:.8rem;background:#fff;padding:.4rem}.file-card img,.file-card .file-icon{width:100%;height:76px;border-radius:.6rem}.file-card img{object-fit:cover}.file-card .file-icon{display:grid;place-items:center;background:var(--soft);color:rgba(8,55,107,.62);font-size:.66rem;font-weight:900}.file-card small{display:block;overflow:hidden;margin-top:.3rem;color:rgba(8,55,107,.7);font-size:.54rem;text-overflow:ellipsis;white-space:nowrap}.file-card button{position:absolute;top:.18rem;left:.18rem;display:grid;width:1.6rem;height:1.6rem;place-items:center;border:0;border-radius:.5rem;background:var(--coral);color:var(--ink);font-weight:900}.voice-box{justify-content:space-between;margin-top:.7rem;border:1px solid rgba(6,147,227,.14);border-radius:1rem;background:rgba(229,244,255,.55);padding:.75rem}.voice-box>div>span{color:var(--coral)}.voice-box button{min-height:2.5rem;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.65rem}
.review-grid{display:grid;gap:.55rem;margin:1rem 0}.review-grid article{border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.8rem;box-shadow:0 8px 24px -24px rgba(8,55,107,.7)}.review-grid header{display:flex;align-items:center;justify-content:space-between}.review-grid header>b{color:var(--muted);font-size:.63rem}.review-grid header button{border:0;background:transparent;color:var(--sea-dark);font-size:.62rem;font-weight:900}.review-grid h3{margin:.45rem 0 .15rem;color:var(--ink);font-size:.8rem}.review-grid p{display:flex;align-items:center;gap:.35rem;margin:0;color:var(--muted);font-size:.62rem;line-height:1.8}.review-grid p i{font-style:normal}.review-body{color:rgba(8,55,107,.75)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.55rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.58rem}.confirm{display:flex;align-items:flex-start;gap:.65rem;border:1px solid rgba(39,164,122,.25);border-radius:1rem;background:#f3fbf8;padding:.75rem}.confirm input{width:1.1rem;height:1.1rem;accent-color:#16815e}.confirm b,.confirm small{display:block}.confirm b{color:#245d4a;font-size:.68rem}.confirm small{margin-top:.12rem;color:#66877b;font-size:.58rem}.success-page{padding:3rem 1rem;text-align:center}.success-page>span{display:grid;width:3.7rem;height:3.7rem;margin:auto;place-items:center;border-radius:1.1rem;background:#e1f7ed;color:#16815e;font-size:1.7rem;font-weight:900}.success-page h2{margin:.9rem 0 .25rem;font-size:1.1rem}.success-page p{color:var(--muted);font-size:.7rem}.success-page button{margin-top:.8rem;border:0;background:var(--ink);color:#fff}.mobile-dock{position:fixed;z-index:50;right:.75rem;bottom:.75rem;left:.75rem;display:grid;grid-template-columns:repeat(3,1fr);border-radius:1.35rem;background:rgba(8,55,107,.97);padding:.35rem;box-shadow:0 20px 50px -18px rgba(8,55,107,.75);backdrop-filter:blur(14px)}.mobile-dock button{display:flex;min-height:3rem;flex-direction:column;align-items:center;justify-content:center;gap:.05rem;border:0;border-radius:1rem;background:transparent;color:rgba(255,255,255,.6);font-size:.58rem;font-weight:800}.mobile-dock button span{font-size:.9rem}.mobile-dock button.active{background:var(--sea);color:#fff}
@media(min-width:640px){.shell{width:min(100% - 2.5rem,72rem);padding-bottom:3rem}.topbar{min-height:4.5rem}.brand{display:grid;text-align:left}.brand span{font-size:.9rem;font-weight:900}.brand small{color:var(--muted);font-size:.58rem}.intro{display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;margin:1.1rem 0 1.35rem}.intro h1{font-size:1.9rem}.intro p{font-size:.8rem}.save-state{display:flex;align-items:center;gap:.45rem;color:var(--muted);font-size:.65rem}.save-state i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}.progress{margin-bottom:1rem}.progress:before{top:1.12rem}.progress li{display:flex;justify-content:center;gap:.5rem}.progress li>span{width:2.3rem;height:2.3rem}.progress li>div{display:block}.progress li b,.progress li small{display:block}.progress li b{font-size:.68rem}.progress li small{margin-top:.08rem;font-size:.55rem}.wizard{border-radius:1.6rem}.step{padding:1.35rem 1.5rem}.step-head{gap:.8rem}.step-symbol{width:2.7rem;height:2.7rem}.step-head h2{font-size:1.1rem}.step-head p{font-size:.74rem}.search-box{margin-top:1.15rem}.search-box kbd{display:inline;border:1px solid var(--line);border-radius:.4rem;background:var(--cream);padding:.2rem .4rem;color:var(--muted);font-size:.56rem}.manual-fields,.two-columns{grid-template-columns:1fr 1fr}.expert-list{grid-template-columns:repeat(3,1fr)}.expert{display:grid;grid-template-columns:1fr;justify-items:center;padding:.9rem;text-align:center}.expert>i{position:absolute;top:.5rem;left:.5rem}.avatar{width:3.15rem;height:3.15rem}.expert b{margin-top:.5rem}.attachment-actions{grid-template-columns:1fr 1fr}.file-preview{grid-template-columns:repeat(3,minmax(0,1fr))}.review-grid{grid-template-columns:1fr 1fr}.review-grid article.wide{grid-column:1/-1}.step-actions{position:static;display:flex;justify-content:flex-end;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.step-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.4);font-size:.6rem}.step-actions .primary:only-of-type{margin-right:auto}.primary,.secondary{min-width:8.5rem}.final-actions{justify-content:space-between}.mobile-dock{display:none}}
@media(min-width:1024px){.shell{width:min(100% - 4rem,65rem)}.intro h1{font-size:2.15rem}.wizard{margin-inline:auto}.step{padding:1.6rem 1.8rem}.step-actions{margin:1.3rem -1.8rem -1.6rem;padding:.85rem 1.8rem}.step-head h2{font-size:1.2rem}.suggestions{grid-template-columns:1fr 1fr}.review-grid{gap:.7rem}.file-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
/* Voice recorder: mobile-first states with playback and safe removal. */
.voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}
.voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}
.voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:var(--sea);font-size:1rem;box-shadow:0 8px 20px -16px rgba(8,55,107,.8)}
.voice-head b,.voice-head small,.voice-ready b,.voice-ready small{display:block}.voice-head b{font-size:.72rem}.voice-head small{margin-top:.12rem;color:var(--muted);font-size:.58rem;line-height:1.7}
.voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;color:var(--ink);font-size:.64rem;font-weight:900;font-variant-numeric:tabular-nums}
.voice-idle,.voice-live,.voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}
.voice-idle{display:grid;gap:.35rem}.voice-idle>small{color:var(--muted);font-size:.56rem;text-align:center}
.voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:var(--sea);color:#fff;font-size:.7rem;font-weight:900;box-shadow:0 12px 28px -18px var(--sea)}.voice-start span{color:#fff;font-size:.85rem}
.voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:var(--sea);animation:voice-wave .9s ease-in-out infinite}.voice-wave i:nth-child(2),.voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.voice-wave i:nth-child(3),.voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.voice-recorder[data-state="paused"] .voice-wave i{animation-play-state:paused;opacity:.4}
@keyframes voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}
.voice-controls{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.voice-controls button,.voice-preview-actions button{min-height:2.65rem;border:1px solid var(--line);border-radius:.8rem;background:#fff;color:var(--ink);font-size:.63rem;font-weight:850}.voice-controls .voice-stop{border-color:var(--sea);background:var(--sea);color:#fff}.voice-controls .voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}
.voice-preview{display:grid;gap:.65rem;background:#fff}.voice-ready{display:flex;align-items:center;gap:.55rem}.voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.voice-ready b{font-size:.68rem}.voice-ready small{color:var(--muted);font-size:.56rem}.voice-preview audio,.review-audio{width:100%;height:2.6rem;accent-color:var(--sea)}.voice-preview-actions{display:grid;grid-template-columns:1fr 1fr;gap:.45rem}.voice-preview-actions #delete-voice{border-color:rgba(169,39,60,.18);color:#a9273c}
.voice-note{display:flex;align-items:flex-start;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:var(--muted);font-size:.55rem;line-height:1.7}.voice-note span{color:var(--sea-dark)}.voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.6rem;font-weight:800}
/* Review: one calm scan path instead of a dashboard of competing cards. */
.review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.review-callout b,.review-callout small{display:block}.review-callout b{font-size:.68rem}.review-callout small{margin-top:.1rem;color:#66877b;font-size:.56rem;line-height:1.65}
.review-list{overflow:hidden;border:1px solid var(--line);border-radius:1rem;background:#fff}.review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.review-row+.review-row{border-top:1px solid rgba(8,55,107,.07)}.review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:var(--mint);color:var(--sea-dark);font-size:.58rem;font-weight:900}.review-content{min-width:0}.review-content>small{color:var(--muted);font-size:.55rem;font-weight:800}.review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:var(--ink);font-size:.73rem;line-height:1.6}.review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:var(--muted);font-size:.58rem;line-height:1.75}.review-content p i{font-style:normal}.review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:var(--mint);padding:.35rem .5rem;color:var(--sea-dark);font-size:.56rem;font-weight:900}.review-row>button span{font-size:.7rem}.review-details .review-content{grid-column:2/-1}.review-details>button{grid-column:3;grid-row:1}.review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.78)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.56rem}.review-audio{margin-top:.55rem}
@media(min-width:640px){.voice-recorder{margin-top:1rem}.voice-head{padding:.9rem 1rem}.voice-idle,.voice-live,.voice-preview{padding:.85rem 1rem}.voice-idle{grid-template-columns:auto 1fr;align-items:center}.voice-idle>small{text-align:right}.voice-start{min-width:11rem}.voice-live{grid-template-columns:1fr auto;align-items:center}.voice-controls{grid-template-columns:auto auto auto}.voice-controls .voice-cancel{grid-column:auto;min-height:2.65rem}.voice-controls button{min-width:6.5rem}.voice-note{padding:.65rem 1rem}.review-callout{padding:.8rem 1rem}.review-row{gap:.8rem;padding:.9rem 1rem}.review-row>button{padding:.4rem .65rem}.review-content h3{font-size:.78rem}.review-details .review-content{grid-column:2}.review-details>button{grid-column:3}.review-audio{max-width:28rem}}
@media(prefers-reduced-motion:reduce){.voice-wave i{animation:none}}
/* Readability pass: comfortable mobile text, 16px form controls, and clear hierarchy. */
body{font-size:16px;line-height:1.75}.prototype-note{font-size:.76rem;line-height:1.65}.back-button,.manual-toggle{font-size:.82rem}.eyebrow{font-size:.76rem}.intro p{font-size:.86rem;line-height:1.85}.step-head small{font-size:.7rem}.step-head h2{font-size:1.08rem;line-height:1.6}.step-head p{font-size:.82rem;line-height:1.85}.search-box input,.manual-fields select,.two-columns select,.full-field input,.full-field textarea{font-size:1rem}.suggestions b{font-size:.84rem}.suggestions small{font-size:.73rem}.suggestions span{font-size:.72rem}.suggestion-empty{grid-column:1/-1;margin:0;border:1px dashed rgba(8,55,107,.14);border-radius:.8rem;background:#fafcfd;padding:.75rem;color:var(--muted);font-size:.78rem;line-height:1.7;text-align:center}.manual-fields label,.two-columns label,.full-field{font-size:.82rem}.full-field small{font-size:.7rem}.validation{font-size:.74rem;line-height:1.7}.primary,.secondary,.success-page button{font-size:.82rem}.soft-tip p{font-size:.76rem;line-height:1.75}.expert b{font-size:.82rem}.expert small{font-size:.7rem;line-height:1.65}.attachment-head b{font-size:.82rem}.attachment-head small{font-size:.7rem;line-height:1.65}.attachment-actions label{font-size:.8rem}.file-card small{font-size:.68rem}.voice-head b{font-size:.84rem}.voice-head small{font-size:.72rem}.voice-timer{font-size:.75rem}.voice-idle>small{font-size:.69rem}.voice-start{font-size:.82rem}.voice-controls button,.voice-preview-actions button{font-size:.75rem}.voice-ready b{font-size:.8rem}.voice-ready small{font-size:.7rem}.voice-note{font-size:.68rem}.voice-error{font-size:.72rem}.review-callout b{font-size:.82rem}.review-callout small{font-size:.7rem}.review-content>small{font-size:.7rem}.review-content h3{font-size:.9rem;line-height:1.7}.review-content p{font-size:.72rem;line-height:1.8}.review-row>button{font-size:.68rem}.review-files span{font-size:.68rem}.confirm b{font-size:.8rem}.confirm small{font-size:.7rem;line-height:1.7}.success-page p{font-size:.8rem}.mobile-dock button{font-size:.68rem}
#review-subject{font-size:1rem;font-weight:900;line-height:1.8}.review-body{font-size:.88rem!important;font-weight:550;line-height:2!important}
@media(min-width:640px){.brand span{font-size:1rem}.brand small{font-size:.7rem}.intro p{font-size:.9rem}.save-state{font-size:.76rem}.progress li b{font-size:.78rem}.progress li small{font-size:.67rem}.step-head h2{font-size:1.2rem}.step-head p{font-size:.86rem}.review-content h3{font-size:.96rem}#review-subject{font-size:1.08rem}.review-body{font-size:.94rem!important}.review-row>button{font-size:.72rem}}
/* The decisive action stays visible throughout the review step. */
.review-step{padding-bottom:6.75rem}.final-actions{position:fixed;z-index:40;right:.75rem;bottom:4.85rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}.final-actions .primary,.final-actions .secondary{min-width:0}.final-actions .send{box-shadow:0 12px 28px -14px var(--sea)}
@media(min-width:640px){.review-step{padding-bottom:6rem}.final-actions{right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}.final-actions .primary,.final-actions .secondary{min-height:3.15rem}}
/* Validation feedback is a visible destination, not a quiet line below the form. */
.validation:empty{display:none}.validation.visible{position:relative;min-height:0;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8;box-shadow:0 10px 28px -24px rgba(146,38,58,.75)}.validation.visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.validation.visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}[aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
@@ -0,0 +1,163 @@
import base64
import pytest
from playwright.sync_api import expect
pytest_plugins = ["tests.e2e.conftest"]
pytestmark = pytest.mark.e2e
def test_validation_message_is_focused_and_scrolled_into_view(browser_page):
with browser_page() as page:
page.set_viewport_size({"width": 390, "height": 620})
page.goto(
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
wait_until="domcontentloaded",
)
page.locator('[data-step="1"] [data-next]').click()
error = page.locator('[data-error="1"]')
expect(error).to_be_visible()
expect(error).to_contain_text("واحد، دسته‌بندی، موضوع")
expect(error).to_be_in_viewport()
expect(page.locator("#unit")).to_have_attribute("aria-invalid", "true")
expect(page.locator("#category")).to_have_attribute("aria-invalid", "true")
expect(page.locator("#subject")).to_have_attribute("aria-invalid", "true")
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"1\"]')")
def test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal(browser_page):
tiny_png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
with browser_page() as page:
page.set_viewport_size({"width":390,"height":844})
page.goto("http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",wait_until="domcontentloaded")
expect(page.locator('[data-step="1"]')).to_be_visible()
expect(page.locator('[data-step="2"]')).to_be_hidden()
expect(page.locator(".mobile-dock")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
expect(page.locator("#suggestions")).to_be_hidden()
page.locator("#intent").fill("فیش")
expect(page.locator("#suggestions")).to_be_visible()
expect(page.get_by_role("button",name="ارسال فیش یا رسید پرداخت")).to_be_visible()
expect(page.get_by_role("button",name="هماهنگی ارسال یا بارگیری")).to_be_hidden()
assert page.locator("#intent").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
page.get_by_role("button",name="ارسال فیش یا رسید پرداخت").click()
expect(page.locator("#unit")).to_have_value("حسابداری")
expect(page.locator("#category")).to_have_value("فیش واریزی")
expect(page.locator("#subject")).to_have_value("")
page.locator("#subject").fill("پیگیری فیش واریزی سفارش")
page.locator("#unit").select_option(label="فروش")
expect(page.locator("#subject")).to_have_value("پیگیری فیش واریزی سفارش")
page.locator("#unit").select_option(label="حسابداری")
page.locator("#category").select_option(label="فیش واریزی")
page.locator('[data-step="1"] [data-next]').click()
expect(page.locator('[data-step="2"]')).to_be_visible()
page.locator('[data-step="2"] [data-next]').click()
expect(page.locator('[data-step="3"]')).to_be_visible()
page.locator('[data-step="3"] [data-next]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.locator("#body").fill("فیش واریزی سفارش را برای بررسی ارسال کرده‌ام.")
page.locator("#gallery").set_input_files({"name":"receipt.png","mimeType":"image/png","buffer":tiny_png})
page.locator("#documents").set_input_files({"name":"invoice.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
expect(page.locator("#file-preview .file-card")).to_have_count(2)
page.get_by_role("button",name="حذف receipt.png").click()
expect(page.locator("#file-preview .file-card")).to_have_count(1)
page.get_by_role("button",name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-step="5"]')).to_be_visible()
expect(page.locator("#review-subject")).to_have_text("پیگیری فیش واریزی سفارش")
assert page.locator("#review-subject").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
assert page.locator("#review-body").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 14
expect(page.locator("#review-files")).to_have_text("invoice.pdf")
page.locator('[data-edit="4"]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.get_by_role("button",name="مشاهده پیش‌نمایش").click()
send_button = page.get_by_role("button",name="ثبت و ارسال نهایی")
expect(send_button).to_be_visible()
expect(send_button).to_be_enabled()
send_box = send_button.bounding_box()
assert send_box and send_box["y"] + send_box["height"] <= page.viewport_size["height"]
send_button.click()
expect(page.locator("#success-page")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
page.set_viewport_size({"width":1280,"height":900})
expect(page.locator(".mobile-dock")).to_be_hidden()
expect(page.locator(".progress li > div").first).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
def test_voice_recording_auto_stops_on_next_and_can_be_previewed_and_removed(browser_page):
with browser_page() as page:
page.add_init_script(
"""
window.__microphoneTrackStops = 0;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: async () => ({
getTracks: () => [{ stop: () => { window.__microphoneTrackStops += 1; } }],
}),
},
});
class FakeMediaRecorder {
constructor(stream, options = {}) {
this.stream = stream;
this.mimeType = options.mimeType || 'audio/webm';
this.state = 'inactive';
}
start() { this.state = 'recording'; }
pause() { this.state = 'paused'; }
resume() { this.state = 'recording'; }
stop() {
this.state = 'inactive';
this.ondataavailable?.({ data: new Blob(['recorded voice'], { type: this.mimeType }) });
setTimeout(() => this.onstop?.(), 0);
}
static isTypeSupported() { return true; }
}
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: FakeMediaRecorder });
"""
)
page.set_viewport_size({"width": 390, "height": 844})
page.goto(
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
wait_until="domcontentloaded",
)
page.locator("#intent").fill("فیش")
page.get_by_role("button", name="ارسال فیش یا رسید پرداخت").click()
expect(page.locator("#subject")).to_have_value("")
page.locator("#subject").fill("موضوع دستی پیام صوتی")
page.locator('[data-step="1"] [data-next]').click()
page.locator('[data-step="2"] [data-next]').click()
page.locator('[data-step="3"] [data-next]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.get_by_role("button", name="شروع ضبط صدا").click()
expect(page.locator("#voice-live")).to_be_visible()
page.get_by_role("button", name="مکث").click()
expect(page.get_by_role("button", name="ادامه ضبط")).to_be_visible()
page.get_by_role("button", name="ادامه ضبط").click()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-step="5"]')).to_be_visible()
expect(page.locator("#review-audio")).to_be_visible()
assert page.evaluate("window.__microphoneTrackStops") == 1
page.locator('[data-edit="4"]').click()
expect(page.locator("#voice-preview")).to_be_visible()
assert page.locator("#audio").get_attribute("src").startswith("blob:")
page.get_by_role("button", name="حذف پیام صوتی").click()
expect(page.locator("#voice-idle")).to_be_visible()
expect(page.locator("#voice-preview")).to_be_hidden()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
error = page.locator('[data-error="4"]')
expect(error).to_contain_text("توضیح بنویسید")
expect(error).to_be_in_viewport()
expect(page.locator("#body")).to_have_attribute("aria-invalid", "true")
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"4\"]')")