This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
document.querySelectorAll('[data-ticket-composer]').forEach(form=>{
const canonical=form.querySelector('input[name="files"]'),preview=form.querySelector('[data-file-preview]');
if(!canonical||!preview)return;
canonical.classList.add('canonical-file-input');
let files=[];
const status=form.querySelector('[data-compression-status]');
const compress=file=>new Promise(resolve=>{
if(!file.type.startsWith('image/')||file.type==='image/gif'||typeof Compressor==='undefined')return resolve(file);
new Compressor(file,{quality:.76,maxWidth:1920,maxHeight:1920,convertSize:1500000,success(blob){const ext=blob.type==='image/jpeg'?'.jpg':file.name.match(/\.[^.]+$/)?.[0]||'.jpg';resolve(new File([blob],file.name.replace(/\.[^.]+$/,ext),{type:blob.type,lastModified:Date.now()}))},error(){resolve(file)}});
});
const sync=()=>{const dt=new DataTransfer();files.forEach(file=>dt.items.add(file));canonical.files=dt.files;preview.replaceChildren();files.forEach((file,index)=>{const item=document.createElement('div');item.className='file-chip';if(file.type.startsWith('image/')){const img=document.createElement('img');const url=URL.createObjectURL(file);img.src=url;img.onload=()=>URL.revokeObjectURL(url);item.appendChild(img)}const name=document.createElement('small');name.textContent=`${file.name} · ${(file.size/1024/1024).toFixed(1)} MB`;item.appendChild(name);const remove=document.createElement('button');remove.type='button';remove.textContent='×';remove.setAttribute('aria-label','حذف فایل');remove.onclick=()=>{files.splice(index,1);sync()};item.appendChild(remove);preview.appendChild(item)})};
const add=async selected=>{if(status)status.textContent='در حال بهینه‌سازی تصاویر…';const prepared=await Promise.all(Array.from(selected).map(compress));files=[...files,...prepared];sync();if(status)status.textContent='تصاویر آماده ارسال هستند؛ نسخه اصلی روی گوشی شما تغییری نکرده است.'};
form.querySelectorAll('[data-gallery-input],[data-document-input]').forEach(input=>input.addEventListener('change',async()=>{await add(input.files);input.value=''}));
canonical.addEventListener('change',async()=>{if(canonical.dataset.syncing)return;const selected=Array.from(canonical.files);canonical.value='';await add(selected)});
form.addEventListener('voice-added',event=>{files=[...files,event.detail];sync()});
form.addEventListener('voice-removed',event=>{files=files.filter(file=>file!==event.detail);sync()});
form.addEventListener('submit',()=>form.querySelectorAll('button[type=submit],button:not([type])').forEach(button=>button.disabled=true));
});
+42
View File
@@ -0,0 +1,42 @@
document.querySelectorAll('form[data-chat-coach]').forEach(form=>{
const input=form.querySelector('textarea[name="body"]');
if(!input)return;
const rapidWindowMs=2*60*1000;
const autoDismissMs=3000;
let dismissed=false;
let dismissTimer=null;
const readLastTwoMessageTimes=()=>Array.from(document.querySelectorAll('[data-chat-coach-own][data-chat-coach-at]'))
.map(node=>Number(node.dataset.chatCoachAt)*1000)
.filter(Number.isFinite)
.sort((a,b)=>a-b)
.slice(-2);
const hint=document.createElement('aside');
hint.className='chat-coach chat-coach-floating';hint.hidden=true;hint.setAttribute('role','status');hint.setAttribute('aria-live','polite');
hint.innerHTML='<span class="chat-coach-icon" aria-hidden="true">✦</span><div><b>برای دریافت پاسخ دقیق‌تر</b><p>لطفاً همه توضیحات و سؤال‌های خود را در یک پیام کامل بنویسید و سپس ارسال کنید.</p></div><button type="button" data-chat-coach-dismiss aria-label="بستن راهنما" title="بستن">×</button>';
document.body.append(hint);
const dismiss=({focusInput=false}={})=>{
dismissed=true;
hint.hidden=true;
if(dismissTimer)clearTimeout(dismissTimer);
dismissTimer=null;
if(focusInput)input.focus();
};
const showTemporarily=()=>{
if(!hint.hidden)return;
hint.hidden=false;
dismissTimer=setTimeout(()=>dismiss(),autoDismissMs);
};
const detect=()=>{
const [previous,last]=readLastTwoMessageTimes();
const rapidPair=Number.isFinite(previous)&&Number.isFinite(last)&&last>=previous&&last-previous<=rapidWindowMs;
if(!dismissed&&rapidPair)showTemporarily();
else if(!rapidPair)hint.hidden=true;
};
hint.querySelector('[data-chat-coach-dismiss]').addEventListener('click',()=>dismiss({focusInput:true}));
const conversation=document.querySelector('[data-conversation]');
if(conversation)new MutationObserver(detect).observe(conversation,{childList:true,subtree:true});
detect();
});
+132
View File
@@ -0,0 +1,132 @@
document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
const scroller=workspace.querySelector('[data-conversation]');
const conversationEnd=workspace.querySelector('[data-conversation-end]');
const jump=workspace.querySelector('[data-jump-latest]');
const form=workspace.querySelector('[data-ticket-composer]');
const input=form?.querySelector('textarea');
const composer=workspace.querySelector('[data-composer-shell]');
const composerToggle=workspace.querySelector('[data-composer-toggle]');
const baseKey=workspace.dataset.draftKey||location.pathname;
let mode='reply';
const atBottom=()=>scroller.scrollHeight-scroller.scrollTop-scroller.clientHeight<80;
const scrollLatest=(smooth=false)=>{const top=scroller.scrollHeight;if(smooth&&typeof scroller.scrollTo==='function')scroller.scrollTo({top,behavior:'smooth'});else scroller.scrollTop=top};
let initialPin=true;
const scheduleLatest=()=>{[0,40,120,300,700,1200].forEach(delay=>setTimeout(()=>{if(initialPin)scrollLatest(false)},delay))};
scheduleLatest();
window.addEventListener('load',scheduleLatest,{once:true});
window.addEventListener('pageshow',scheduleLatest);
document.fonts?.ready?.then(scheduleLatest);
scroller.querySelectorAll('img,audio').forEach(media=>{const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(initialPin)scrollLatest(false)},{once:true})});
const sizeObserver='ResizeObserver' in window?new ResizeObserver(()=>{if(initialPin)scrollLatest(false)}):null;
sizeObserver?.observe(scroller);if(conversationEnd?.parentElement)sizeObserver?.observe(conversationEnd.parentElement);
setTimeout(()=>{initialPin=false;sizeObserver?.disconnect()},1800);
['wheel','touchstart','pointerdown','keydown'].forEach(eventName=>scroller.addEventListener(eventName,()=>{initialPin=false;sizeObserver?.disconnect()},{once:true,passive:true}));
scroller.addEventListener('scroll',()=>{jump.hidden=atBottom()});
jump?.addEventListener('click',()=>{scrollLatest(true);jump.hidden=true});
const groupDates=()=>{scroller.querySelectorAll('.date-divider').forEach(item=>item.remove());let previousDate='';scroller.querySelectorAll('[data-message]').forEach(message=>{if(message.dataset.date!==previousDate){const divider=document.createElement('div');divider.className='date-divider';divider.innerHTML=`<span>${message.dataset.dayLabel}</span>`;message.before(divider);previousDate=message.dataset.date}})};
groupDates();
document.body.addEventListener('htmx:beforeSwap',event=>{if(event.detail.target?.matches('[data-load-older]')){scroller.dataset.previousHeight=scroller.scrollHeight}});
document.body.addEventListener('htmx:afterSwap',event=>{if(event.detail.target?.matches('[data-load-older]')){const previous=Number(scroller.dataset.previousHeight||0);groupDates();scroller.scrollTop+=scroller.scrollHeight-previous}});
const resize=()=>{if(!input)return;input.style.height='auto';input.style.height=`${Math.min(Math.max(input.scrollHeight,112),240)}px`};
const draftKey=()=>`${baseKey}-${mode}`;
const status=workspace.querySelector('[data-draft-status]');
const replyResult=workspace.dataset.replyResult||new URLSearchParams(location.search).get('reply_result')||'';
const submissionSucceeded=replyResult==='reply'||replyResult==='note';
if(submissionSucceeded){sessionStorage.removeItem(`${baseKey}-reply`);sessionStorage.removeItem(`${baseKey}-note`)}
const setComposerOpen=(open,focus=true)=>{composer?.classList.toggle('is-collapsed',!open);composerToggle?.setAttribute('aria-expanded',String(open));if(open&&focus)setTimeout(()=>input?.focus(),80)};
const saveDraft=()=>{if(!input)return;sessionStorage.setItem(draftKey(),input.value);if(status){status.textContent=input.value?'پیش‌نویس ذخیره شد':'';clearTimeout(saveDraft.timer);saveDraft.timer=setTimeout(()=>status.textContent='',1800)}};
const loadDraft=()=>{if(!input)return;input.value=sessionStorage.getItem(draftKey())||'';resize();input.dispatchEvent(new Event('input',{bubbles:true}))};
if(input){input.setAttribute('rows','5');input.setAttribute('placeholder','شرح کامل پاسخ، اطلاعات تکمیلی و نتیجه مورد انتظار خود را بنویسید…');input.addEventListener('input',()=>{resize();saveDraft()});loadDraft()}
const hasDraft=Boolean(sessionStorage.getItem(`${baseKey}-reply`)||sessionStorage.getItem(`${baseKey}-note`));
if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}else setComposerOpen(hasDraft||replyResult==='error',false);
composerToggle?.addEventListener('click',()=>setComposerOpen(true));
workspace.querySelectorAll('[data-composer-close]').forEach(button=>button.addEventListener('click',()=>setComposerOpen(false,false)));
composer?.addEventListener('keydown',event=>{if(event.key==='Escape'){setComposerOpen(false,false);composerToggle?.focus()}});
const primarySubmit=form?.querySelector('[data-primary-submit]');
const guideToPrimarySubmit=event=>{event.preventDefault();event.stopPropagation();if(!primarySubmit)return;primarySubmit.scrollIntoView({behavior:'smooth',block:'center'});primarySubmit.classList.remove('submit-attention');void primarySubmit.offsetWidth;primarySubmit.classList.add('submit-attention');setTimeout(()=>primarySubmit.focus({preventScroll:true}),450)};
workspace.querySelectorAll('[data-scroll-to-submit]').forEach(control=>{control.addEventListener('click',guideToPrimarySubmit);control.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){guideToPrimarySubmit(event)}})});
workspace.querySelectorAll('[data-composer-mode]').forEach(button=>button.addEventListener('click',()=>{saveDraft();mode=button.dataset.composerMode;loadDraft();setTimeout(()=>input?.focus(),0)}));
workspace.querySelector('[data-toggle-recorder]')?.addEventListener('click',()=>{const recorder=workspace.querySelector('[data-voice-recorder]');recorder.hidden=!recorder.hidden;if(!recorder.hidden)recorder.querySelector('[data-record]')?.focus()});
workspace.querySelector('[data-toggle-extra]')?.addEventListener('click',()=>{const extra=workspace.querySelector('[data-composer-extra]');extra.hidden=!extra.hidden});
form?.addEventListener('submit',()=>{form.dataset.submitting='true'});
window.addEventListener('pageshow',()=>{if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}});
const pollUrl=workspace.dataset.pollUrl;
const readUrl=workspace.dataset.readUrl;
const pollInterval=3000;
let pollController=null,pollRunning=false,pollGeneration=0,pollErrors=0,nextPollAt=0;
let readRunning=false;
const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||'';
const acknowledgeVisibleMessages=async()=>{
if(!readUrl||readRunning||document.visibilityState!=='visible')return;
const messageIds=[...scroller.querySelectorAll('[data-message-id]')].map(item=>item.dataset.messageId).filter(Boolean);
if(!messageIds.length)return;
readRunning=true;
try{await fetch(readUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:messageIds})})}catch(error){console.error('Message read acknowledgement failed',error)}finally{readRunning=false}
};
acknowledgeVisibleMessages();
// Keep the staff/customer conversation synchronized even while another tab is
// active. Browsers may throttle background timers, so visibility is used only
// to trigger an immediate catch-up, never as a hard polling gate.
// navigator.onLine only reports the browser's network-interface heuristic.
// It can be false while this Django origin is still reachable (notably on
// local networks, installed PWAs and some mobile browsers). Let fetch be the
// source of truth and use the existing error backoff when the origin really
// is unavailable.
const canPoll=()=>Boolean(pollUrl);
const schedulePoll=()=>{const delay=pollErrors?Math.min(30000,pollInterval*(2**Math.min(pollErrors,3))):pollInterval;nextPollAt=Date.now()+delay};
const applyPollUpdate=data=>{
if(!data.changed)return;
const wasAtBottom=atBottom();
const fragment=document.createRange().createContextualFragment(data.html||'');
if(data.replace)scroller.querySelectorAll('[data-message],.date-divider,.conversation-empty').forEach(item=>item.remove());
else scroller.querySelectorAll('.conversation-empty').forEach(item=>item.remove());
scroller.insertBefore(fragment,conversationEnd);
workspace.dataset.pollVersion=data.version||'';workspace.dataset.pollCursor=data.cursor||'';
const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??'');
workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent);
groupDates();
acknowledgeVisibleMessages();
scroller.querySelectorAll('img:not([data-poll-bound]),audio:not([data-poll-bound])').forEach(media=>{media.dataset.pollBound='true';const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(wasAtBottom)scrollLatest(false)},{once:true})});
if(wasAtBottom){scrollLatest(true);jump.hidden=true}else jump.hidden=false;
};
const poll=async()=>{
if(!canPoll()||pollRunning)return;
const generation=++pollGeneration;pollRunning=true;const controller=new AbortController();pollController=controller;
try{
const url=new URL(pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||'');
const response=await fetch(url,{method:'GET',signal:controller.signal,cache:'no-store',headers:{Accept:'application/json'}});
if(response.redirected){location.assign(response.url);return}
if(!response.ok)throw new Error(`HTTP ${response.status}`);
applyPollUpdate(await response.json());pollErrors=0;
}catch(error){if(error.name!=='AbortError'){pollErrors+=1;console.error('Ticket polling failed',error)}}finally{if(generation===pollGeneration){pollRunning=false;pollController=null;schedulePoll()}}
};
const startPolling=()=>{if(!canPoll()||pollRunning||Date.now()<nextPollAt)return;poll()};
const stopPolling=()=>{pollGeneration+=1;if(pollController){pollController.abort();pollController=null}pollRunning=false;nextPollAt=0};
const restartPolling=()=>{stopPolling();startPolling()};
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible'){restartPolling();acknowledgeVisibleMessages()}});
window.addEventListener('focus',startPolling);
window.addEventListener('pageshow',restartPolling);
window.addEventListener('online',restartPolling);
// One independent heartbeat cannot be lost in the load/pageshow race that can
// cancel a recursively scheduled timeout. pollRunning prevents overlap and
// nextPollAt preserves the normal interval and network-error backoff.
const pollHeartbeat=setInterval(startPolling,1000);
window.addEventListener('beforeunload',()=>{clearInterval(pollHeartbeat);stopPolling()},{once:true});
startPolling();
const resetMessageMenu=menu=>{if(!menu)return;const editor=menu.querySelector('[data-message-editor]');const choices=menu.querySelector('[data-message-menu-choices]');const input=menu.querySelector('[data-message-edit-input]');if(editor)editor.hidden=true;if(choices)choices.hidden=false;if(input){input.value=input.dataset.originalValue||'';input.dispatchEvent(new Event('input',{bubbles:true}))}};
const closeMessageMenu=menu=>{if(!menu)return;menu.open=false;resetMessageMenu(menu)};
workspace.addEventListener('toggle',event=>{const menu=event.target.closest?.('[data-message-menu]');if(menu?.open)workspace.querySelectorAll('[data-message-menu][open]').forEach(other=>{if(other!==menu)closeMessageMenu(other)})},true);
workspace.addEventListener('click',event=>{const close=event.target.closest('[data-message-menu-close]');if(close){event.preventDefault();closeMessageMenu(close.closest('[data-message-menu]'));return}const edit=event.target.closest('[data-message-edit-open]');if(edit){const menu=edit.closest('[data-message-menu]');menu.querySelector('[data-message-menu-choices]').hidden=true;menu.querySelector('[data-message-editor]').hidden=false;const input=menu.querySelector('[data-message-edit-input]');input.focus();input.setSelectionRange(input.value.length,input.value.length);return}const back=event.target.closest('[data-message-edit-back]');if(back){event.preventDefault();resetMessageMenu(back.closest('[data-message-menu]'))}});
workspace.addEventListener('input',event=>{if(!event.target.matches('[data-message-edit-input]'))return;const count=event.target.closest('[data-message-editor]').querySelector('[data-message-edit-count]');if(count)count.textContent=`${event.target.value.length.toLocaleString('fa-IR')} / ۴۰۰۰`});
document.addEventListener('click',event=>{workspace.querySelectorAll('[data-message-menu][open]').forEach(menu=>{if(!menu.contains(event.target))closeMessageMenu(menu)})});
workspace.addEventListener('keydown',event=>{if(event.key==='Escape'){const menu=event.target.closest('[data-message-menu]')||workspace.querySelector('[data-message-menu][open]');if(menu){event.preventDefault();closeMessageMenu(menu);menu.querySelector(':scope > summary')?.focus()}}});
workspace.querySelectorAll('[data-toggle-panel]').forEach(button=>button.addEventListener('click',()=>{const panel=workspace.querySelector(`#${button.dataset.togglePanel}`);workspace.querySelectorAll('[data-workspace-panel]').forEach(item=>item.classList.toggle('panel-open',item===panel&&!item.classList.contains('panel-open')))}));
workspace.querySelectorAll('[data-close-panel]').forEach(button=>button.addEventListener('click',()=>button.closest('[data-workspace-panel]').classList.remove('panel-open')));
});
+1
View File
@@ -0,0 +1 @@
(()=>{const dialog=document.querySelector('[data-image-viewer]');if(!dialog)return;const image=dialog.querySelector('[data-image-preview]'),title=dialog.querySelector('[data-image-title]'),download=dialog.querySelector('[data-image-download]');document.addEventListener('click',event=>{const trigger=event.target.closest('[data-image-open]');if(!trigger)return;image.src=trigger.dataset.imageSrc;image.alt=trigger.dataset.imageName||'تصویر پیوست';title.textContent=trigger.dataset.imageName||'تصویر پیوست';download.href=trigger.dataset.imageSrc.replace('/view/','/download/');dialog.showModal()});dialog.querySelector('[data-image-close]').addEventListener('click',()=>dialog.close());dialog.addEventListener('click',event=>{if(event.target===dialog)dialog.close()});dialog.addEventListener('close',()=>image.removeAttribute('src'))})();
+38
View File
@@ -0,0 +1,38 @@
document.querySelectorAll('[data-location-picker]').forEach(async picker=>{
const province=picker.querySelector('[data-province-input]');
const city=picker.querySelector('[data-city-input]');
const provinceList=picker.querySelector('[data-province-options]')||(province?.matches('select')?province:null);
const cityList=picker.querySelector('[data-city-options]')||(city?.matches('select')?city:null);
const hint=picker.querySelector('[data-location-hint]');
if(!province||!city||!provinceList||!cityList)return;
const normalize=value=>(value||'').trim().replace(/ي/g,'ی').replace(/ك/g,'ک');
const setHint=(text,error=false)=>{if(!hint)return;hint.textContent=text;hint.classList.toggle('is-error',error)};
const fill=(list,values,placeholder='')=>{
const selected=normalize(list.value);
const options=values.map(value=>{const option=document.createElement('option');option.value=value;option.textContent=value;return option});
if(list.matches('select')){const empty=document.createElement('option');empty.value='';empty.textContent=placeholder;options.unshift(empty)}
list.replaceChildren(...options);
if(values.includes(selected))list.value=selected;
};
try{
const response=await fetch(picker.dataset.locationsUrl,{cache:'force-cache'});
if(!response.ok)throw new Error('location data unavailable');
const rows=await response.json();
const locations=new Map(rows.map(row=>[normalize(row.province),row.cities]));
fill(provinceList,rows.map(row=>row.province),'انتخاب استان');
const syncCities=(clearInvalid=false)=>{
const cities=locations.get(normalize(province.value))||[];
fill(cityList,cities,'انتخاب شهر');
city.disabled=!cities.length;
if(clearInvalid&&city.value&&!cities.includes(normalize(city.value)))city.value='';
setHint(cities.length?`${cities.length.toLocaleString('fa-IR')} شهر برای این استان در دسترس است.`:'برای نمایش شهرها، یک استان معتبر انتخاب کنید.',Boolean(province.value&&!cities.length));
};
province.addEventListener('input',()=>syncCities(true));
province.addEventListener('change',()=>syncCities(true));
city.addEventListener('change',()=>{const cities=locations.get(normalize(province.value))||[];setHint(!city.value||cities.includes(normalize(city.value))?'موقعیت انتخابی برای همین تیکت استفاده می‌شود.':'شهر را از فهرست همین استان انتخاب کنید.',Boolean(city.value&&!cities.includes(normalize(city.value))))});
syncCities(false);
}catch(error){
province.disabled=true;city.disabled=true;setHint('فهرست شهرها بارگذاری نشد؛ صفحه را دوباره باز کنید.',true);
}
});
+15
View File
@@ -0,0 +1,15 @@
let deferredInstallPrompt=null;
const banner=document.querySelector('[data-pwa-install]'),accept=banner?.querySelector('[data-pwa-accept]'),help=banner?.querySelector('[data-pwa-help]');
const standalone=matchMedia('(display-mode: standalone)').matches||navigator.standalone===true;
const mobileDevice=navigator.userAgentData?.mobile===true||/android|iphone|ipad|ipod|mobile/i.test(navigator.userAgent)||(matchMedia('(max-width: 820px)').matches&&matchMedia('(pointer: coarse)').matches);
const isiOS=/iphone|ipad|ipod/i.test(navigator.userAgent);
const dismissed=()=>Date.now()-Number(localStorage.getItem('pwa-install-dismissed')||0)<24*60*60*1000;
const show=()=>{if(banner&&mobileDevice&&!standalone&&!dismissed())banner.hidden=false};
const setGuide=(text,label='راهنمای نصب')=>{if(help)help.textContent=text;if(accept)accept.textContent=label;banner?.classList.add('pwa-install-guidance')};
if('serviceWorker' in navigator&&window.isSecureContext){let workerRegistration=null,workerReloading=false;const assetVersion=document.documentElement.dataset.staticVersion||new URL(document.currentScript?.src||location.href).searchParams.get('v')||'';const serviceWorkerUrl=`/service-worker.js?v=${encodeURIComponent(assetVersion)}`;const updateWorker=()=>{if(workerRegistration&&document.visibilityState==='visible')workerRegistration.update().catch(()=>{})};navigator.serviceWorker.addEventListener('controllerchange',()=>{if(workerReloading)return;workerReloading=true;location.reload()});window.addEventListener('load',async()=>{try{workerRegistration=await navigator.serviceWorker.register(serviceWorkerUrl,{updateViaCache:'none'});await workerRegistration.update()}catch(error){setGuide('فعال‌سازی نصب برنامه با خطا روبه‌رو شد. صفحه را تازه‌سازی کنید.');show()}});document.addEventListener('visibilitychange',updateWorker);setInterval(updateWorker,60000)}
window.addEventListener('beforeinstallprompt',event=>{event.preventDefault();deferredInstallPrompt=event;if(accept){accept.textContent='نصب برنامه';accept.disabled=false}banner?.classList.remove('pwa-install-guidance');if(help)help.textContent='برنامه آماده نصب است؛ برای ادامه روی «نصب برنامه» بزنید.';show()});
if(mobileDevice&&isiOS&&!standalone){setGuide('در Safari روی دکمه اشتراک‌گذاری (□↑) بزنید و «Add to Home Screen» را انتخاب کنید.','نمایش راهنما');setTimeout(show,700)}
if(mobileDevice&&!isiOS&&!standalone)setTimeout(()=>{if(!deferredInstallPrompt){setGuide(window.isSecureContext?'از منوی مرورگر گزینه «Install app» یا «افزودن به صفحه اصلی» را انتخاب کنید.':'نصب مستقیم روی آدرس HTTP شبکه ممکن نیست. سامانه را با HTTPS باز کنید؛ سپس دکمه نصب فعال می‌شود.','راهنمای نصب');show()}},1800);
banner?.querySelector('[data-pwa-dismiss]')?.addEventListener('click',()=>{banner.hidden=true;localStorage.setItem('pwa-install-dismissed',Date.now())});
accept?.addEventListener('click',async()=>{if(deferredInstallPrompt){accept.disabled=true;deferredInstallPrompt.prompt();const {outcome}=await deferredInstallPrompt.userChoice;deferredInstallPrompt=null;if(outcome==='accepted')banner.hidden=true;else{accept.disabled=false;setGuide('نصب لغو شد. برای تلاش دوباره از منوی مرورگر «Install app» را انتخاب کنید.')}}else if(isiOS){setGuide('۱) Safari را باز کنید. ۲) دکمه اشتراک‌گذاری □↑ را بزنید. ۳) Add to Home Screen و سپس Add را انتخاب کنید.','متوجه شدم')}else if(!window.isSecureContext){setGuide('مرورگر برای نصب و Service Worker به HTTPS نیاز دارد. از نسخه HTTPS سامانه استفاده کنید.','نیازمند HTTPS')}else{setGuide('منوی سه‌نقطه مرورگر را باز کنید و «Install app» یا «افزودن به صفحه اصلی» را بزنید.','نصب از منوی مرورگر')}});
window.addEventListener('appinstalled',()=>{deferredInstallPrompt=null;localStorage.removeItem('pwa-install-dismissed');if(banner)banner.hidden=true});
+21
View File
@@ -0,0 +1,21 @@
document.addEventListener('DOMContentLoaded',()=>{
const root=document.querySelector('[data-customer-picker]');if(!root)return;
const search=root.querySelector('[data-customer-search]'),results=root.querySelector('[data-customer-results]'),status=root.querySelector('[data-customer-status]');
const idInput=root.querySelector('[data-customer-id]'),mobile=root.querySelector('#id_customer_mobile'),first=root.querySelector('#id_first_name'),last=root.querySelector('#id_last_name'),province=root.querySelector('#id_province'),city=root.querySelector('#id_city');
const selected=root.querySelector('[data-selected-customer]');let timer,requestToken=0;
const identityRows=[first?.closest('[data-new-customer-field]'),last?.closest('[data-new-customer-field]')].filter(Boolean);
const summaryCustomer=root.querySelector('[data-summary-customer]'),summarySubject=root.querySelector('[data-summary-subject]'),summaryCategory=root.querySelector('[data-summary-category]'),subject=root.querySelector('#id_subject'),category=root.querySelector('#id_category');
const escapeHtml=value=>String(value??'').replace(/[&<>'"]/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
function setNewCustomer(isNew){identityRows.forEach(row=>row.hidden=!isNew);status.className=`customer-lookup-status ${isNew?'is-new':'is-found'}`;status.textContent=isNew?'این شماره در سامانه ثبت نشده است؛ نام و نام خانوادگی مشتری جدید را تکمیل کنید.':'مشتری موجود انتخاب شد؛ مشخصات ثبت‌شده در بالا نمایش داده شده است.'}
function selectCustomer(customer){idInput.value=customer.id;mobile.value=customer.mobile;first.value=customer.first_name;last.value=customer.last_name;province.value=customer.province;city.value=customer.city;selected.hidden=false;selected.querySelector('[data-selected-name]').textContent=customer.name;selected.querySelector('[data-selected-mobile]').textContent=customer.mobile;selected.querySelector('[data-selected-location]').textContent=[customer.city,customer.province].filter(Boolean).join('، ')||'موقعیت ثبت نشده';selected.querySelector('[data-selected-stats]').textContent=`${customer.ticket_count} تیکت، ${customer.active_count} فعال`;summaryCustomer.textContent=customer.name;results.hidden=true;search.value='';setNewCustomer(false);province.dispatchEvent(new Event('input',{bubbles:true}))}
function clearCustomer(clearMobile=true){idInput.value='';selected.hidden=true;identityRows.forEach(row=>row.hidden=false);if(clearMobile)mobile.value='';first.value='';last.value='';province.value='';city.value='';summaryCustomer.textContent='هنوز انتخاب نشده';status.className='customer-lookup-status';status.textContent='شماره موبایل را وارد کنید تا عضویت مشتری بررسی شود.'}
async function lookup(params,showList=false){const token=++requestToken;try{const response=await fetch(`${root.dataset.searchUrl}?${params}`,{headers:{'Accept':'application/json'}});if(!response.ok)throw new Error();const data=await response.json();if(token!==requestToken)return;if(showList){results.innerHTML=data.results.length?data.results.map(item=>`<button type="button" data-customer-result='${escapeHtml(JSON.stringify(item))}'><b>${escapeHtml(item.name)}</b><span dir="ltr">${escapeHtml(item.mobile)}</span><small>${escapeHtml([item.city,item.province].filter(Boolean).join('، ')||'موقعیت ثبت نشده')} · ${item.ticket_count} تیکت</small></button>`).join(''):'<p>مشتری‌ای با این عبارت پیدا نشد.</p>';results.hidden=false}else if(data.results.length&&data.results[0].mobile.replace(/\D/g,'')===mobile.value.replace(/\D/g,'').replace(/^98(?=9)/,'0'))selectCustomer(data.results[0]);else{if(idInput.value)clearCustomer(false);setNewCustomer(true)}}catch{status.className='customer-lookup-status is-error';status.textContent='بررسی مشتری ممکن نشد؛ اتصال را بررسی و دوباره تلاش کنید.'}}
search.addEventListener('input',()=>{clearTimeout(timer);const value=search.value.trim();if(value.length<2){results.hidden=true;return}timer=setTimeout(()=>lookup(`q=${encodeURIComponent(value)}`,true),300)});
results.addEventListener('click',event=>{const button=event.target.closest('[data-customer-result]');if(button)selectCustomer(JSON.parse(button.dataset.customerResult))});
root.querySelector('[data-clear-customer]').addEventListener('click',()=>{clearCustomer();search.focus()});
mobile.addEventListener('input',()=>{if(idInput.value&&mobile.value!==selected.querySelector('[data-selected-mobile]').textContent)clearCustomer(false);clearTimeout(timer);const digits=mobile.value.replace(/\D/g,'');if(digits.length<10){status.className='customer-lookup-status';status.textContent='شماره موبایل را کامل وارد کنید.';return}status.textContent='در حال بررسی شماره…';timer=setTimeout(()=>lookup(`q=${encodeURIComponent(mobile.value)}`),350)});
if(idInput.value)lookup(`id=${encodeURIComponent(idInput.value)}`);else if(mobile.value)mobile.dispatchEvent(new Event('input'));
subject?.addEventListener('input',()=>summarySubject.textContent=subject.value.trim()||'هنوز وارد نشده');
category?.addEventListener('change',()=>summaryCategory.textContent=category.selectedOptions[0]?.text||'انتخاب نشده');
if(subject?.value)summarySubject.textContent=subject.value;if(category?.value)summaryCategory.textContent=category.selectedOptions[0]?.text;
});
+20
View File
@@ -0,0 +1,20 @@
(() => {
const form=document.querySelector('[data-ticket-search-url]'); if(!form)return;
const input=form.querySelector('[data-ticket-search]'); const results=form.querySelector('[data-ticket-search-results]');
const unit=form.querySelector('[data-ticket-unit]'); const category=form.querySelector('[data-ticket-category]'); let timer;
const message=text=>{results.replaceChildren(); const p=document.createElement('p'); p.className='intent-message'; p.textContent=text; results.append(p)};
const select=async item=>{
unit.value=String(item.unit_id);
const loaded=new Promise(resolve=>category.addEventListener('ticket-options-loaded',resolve,{once:true}));
unit.dispatchEvent(new Event('change',{bubbles:true})); await loaded;
category.value=String(item.category_id); category.dispatchEvent(new Event('change',{bubbles:true}));
document.querySelector('.manual-routing')?.setAttribute('open',''); message(`مسیر انتخاب شد: ${item.unit} ← ${item.category}`); category.focus();
};
const render=items=>{
results.replaceChildren(); if(!items.length){message('نتیجه دقیقی پیدا نشد؛ از انتخاب دستی استفاده کنید.');return}
items.forEach(item=>{const button=document.createElement('button');button.type='button';button.className='intent-result';
const title=document.createElement('strong');title.textContent=item.category;const meta=document.createElement('span');meta.textContent=item.unit;
const detail=document.createElement('small');detail.textContent=item.description||'انتخاب این مسیر برای درخواست';button.append(title,meta,detail);button.addEventListener('click',()=>select(item));results.append(button)});
};
input.addEventListener('input',()=>{clearTimeout(timer);const q=input.value.trim();if(q.length<2){results.replaceChildren();return}message('در حال یافتن بهترین مسیر…');timer=setTimeout(async()=>{try{const response=await fetch(`${form.dataset.ticketSearchUrl}?q=${encodeURIComponent(q)}`,{headers:{Accept:'application/json'}});render((await response.json()).results)}catch{message('جست‌وجو در دسترس نیست؛ از انتخاب دستی استفاده کنید.')}},260)});
})();
+62
View File
@@ -0,0 +1,62 @@
(() => {
const form = document.querySelector("[data-unit-options-url]");
if (!form) return;
const unit = form.querySelector("[data-ticket-unit]");
const category = form.querySelector("[data-ticket-category]");
const picker = form.querySelector("[data-expert-picker]");
if (!unit || !category || !picker) return;
let requestController;
const addGeneralQueue = (queue) => {
const label = document.createElement("label"); label.className = "expert-card";
label.dataset.generalQueue = ""; label.dataset.unitId = String(queue.unit_id);
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "selected_expert"; radio.value = ""; radio.checked = true;
const avatar = document.createElement("span"); avatar.className = "expert-avatar"; avatar.textContent = "⌁";
const title = document.createElement("strong"); title.textContent = queue.label;
const detail = document.createElement("small"); detail.textContent = "اولین کارشناس آزاد همین واحد";
label.append(radio, avatar, title, detail); picker.append(label);
};
const render = (payload) => {
category.replaceChildren(new Option("انتخاب دسته‌بندی", ""));
payload.categories.forEach((item) => category.add(new Option(item.label, item.id)));
const selectedUnitLabel=unit.options[unit.selectedIndex]?.textContent?.trim() || "واحد";
const queue=payload.queue || {unit_id:unit.value,label:`صف عمومی ${selectedUnitLabel}`};
picker.replaceChildren(); addGeneralQueue(queue);
payload.experts.forEach((item) => {
const label=document.createElement("label"); label.className="expert-card";
const radio=document.createElement("input"); radio.type="radio"; radio.name="selected_expert"; radio.value=item.id;
const image=document.createElement("img"); image.src=item.avatar_url; image.alt=`تصویر ${item.name}`; image.width=56; image.height=56; image.loading="lazy";
const title=document.createElement("strong"); title.textContent=item.name;
const detail=document.createElement("small"); detail.textContent=item.job_title;
label.append(radio,image,title,detail); picker.append(label);
});
if (!payload.experts.length) {
const empty=document.createElement("p"); empty.className="muted expert-empty";
empty.textContent="برای این واحد کارشناس قابل انتخابی ثبت نشده است؛ درخواست به صف عمومی می‌رود.";
picker.append(empty);
}
};
unit.addEventListener("change", async () => {
requestController?.abort(); requestController=new AbortController();
const requestedUnit=unit.value;
category.disabled=true;
try {
const optionsUrl=new URL(form.dataset.unitOptionsUrl,window.location.origin);
optionsUrl.searchParams.set("unit",requestedUnit);
optionsUrl.searchParams.set("v",document.documentElement.dataset.staticVersion || "1");
const response=await fetch(optionsUrl, {headers:{Accept:"application/json"},credentials:"same-origin",cache:"no-store",signal:requestController.signal});
if (!response.ok) throw new Error("options request failed");
const payload=await response.json();
if (unit.value!==requestedUnit) return;
render(payload);
category.dispatchEvent(new CustomEvent("ticket-options-loaded",{detail:{ok:true,unitId:requestedUnit}}));
} catch (error) {
if (error.name==="AbortError") return;
console.error("Failed to update ticket unit options", error);
picker.replaceChildren();
const message=document.createElement("p"); message.className="muted expert-empty"; message.setAttribute("role","alert");
message.textContent="دریافت کارشناسان این واحد ناموفق بود؛ لطفاً دوباره تلاش کنید."; picker.append(message);
category.dispatchEvent(new CustomEvent("ticket-options-loaded",{detail:{ok:false,unitId:requestedUnit}}));
} finally { if (unit.value===requestedUnit) category.disabled=false; }
});
})();
+8
View File
@@ -0,0 +1,8 @@
document.addEventListener('submit',event=>{
const form=event.target;if(!form.matches('form'))return;
requestAnimationFrame(()=>{if(!form.checkValidity())return;form.querySelectorAll('button[type="submit"],button:not([type])').forEach(button=>{button.disabled=true;button.setAttribute('aria-busy','true');if(!button.dataset.label)button.dataset.label=button.textContent;button.textContent='در حال ثبت…'})});
});
document.querySelectorAll('.flash').forEach(flash=>{
const close=document.createElement('button');close.type='button';close.className='flash-close';close.setAttribute('aria-label','بستن پیام');close.textContent='×';close.addEventListener('click',()=>flash.remove());flash.appendChild(close);
if(flash.classList.contains('success'))setTimeout(()=>{flash.classList.add('flash-leaving');setTimeout(()=>flash.remove(),250)},7000);
});
+12
View File
@@ -0,0 +1,12 @@
document.querySelectorAll('[data-voice-recorder]').forEach(root=>{
let recorder,stream,chunks=[],started,timer,pausedAt=0,pausedMs=0,voiceFile;
const form=root.closest('form'),record=root.querySelector('[data-record]'),pause=root.querySelector('[data-pause]'),stop=root.querySelector('[data-stop]'),remove=root.querySelector('[data-delete]'),audio=root.querySelector('audio'),clock=root.querySelector('[data-timer]');
const supported=window.isSecureContext&&navigator.mediaDevices?.getUserMedia&&window.MediaRecorder;
if(!supported){record.disabled=true;record.title='ضبط صدا فقط در HTTPS یا localhost و مرورگر سازگار فعال است';root.insertAdjacentHTML('beforeend','<small class="recorder-error">مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.</small>');return}
const stopTracks=()=>{stream?.getTracks().forEach(track=>track.stop());stream=null};
const reset=()=>{if(voiceFile)form.dispatchEvent(new CustomEvent('voice-removed',{detail:voiceFile}));voiceFile=null;stopTracks();if(audio.src)URL.revokeObjectURL(audio.src);audio.removeAttribute('src');audio.hidden=true;remove.hidden=true;clock.textContent='00:00';record.disabled=false;pause.disabled=true;pause.textContent='مکث';stop.disabled=true};
record.addEventListener('click',async()=>{try{stream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});const mime=['audio/webm;codecs=opus','audio/mp4','audio/ogg;codecs=opus','audio/webm'].find(type=>MediaRecorder.isTypeSupported(type));recorder=new MediaRecorder(stream,mime?{mimeType:mime}:undefined);chunks=[];pausedMs=0;recorder.ondataavailable=event=>{if(event.data.size)chunks.push(event.data)};recorder.onerror=()=>{stopTracks();alert('ضبط صدا متوقف شد. دوباره تلاش کنید.')};recorder.onstop=()=>{clearInterval(timer);stopTracks();const type=recorder.mimeType.split(';')[0]||'audio/webm',blob=new Blob(chunks,{type});if(!blob.size)return reset();audio.src=URL.createObjectURL(blob);audio.hidden=false;remove.hidden=false;const ext=type.includes('ogg')?'ogg':type.includes('mp4')?'m4a':'webm';voiceFile=new File([blob],`voice-${Date.now()}.${ext}`,{type});form.dispatchEvent(new CustomEvent('voice-added',{detail:voiceFile}))};recorder.start(1000);started=Date.now();record.disabled=true;pause.disabled=false;stop.disabled=false;timer=setInterval(()=>{const elapsed=Date.now()-started-pausedMs-(recorder.state==='paused'?Date.now()-pausedAt:0),seconds=Math.max(0,Math.floor(elapsed/1000));clock.textContent=`${String(Math.floor(seconds/60)).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}`},500)}catch(error){stopTracks();const denied=error.name==='NotAllowedError';alert(denied?'دسترسی میکروفن رد شده است. از تنظیمات مرورگر، دسترسی Microphone را برای این سایت فعال کنید.':'میکروفن در دسترس نیست یا توسط برنامه دیگری استفاده می‌شود.')}});
pause.addEventListener('click',()=>{if(recorder?.state==='recording'){recorder.pause();pausedAt=Date.now();pause.textContent='ادامه ضبط'}else if(recorder?.state==='paused'){pausedMs+=Date.now()-pausedAt;recorder.resume();pause.textContent='مکث'}});
stop.addEventListener('click',()=>{if(recorder&&recorder.state!=='inactive')recorder.stop();pause.disabled=true;stop.disabled=true});
remove.addEventListener('click',reset);window.addEventListener('pagehide',stopTracks);
});
+16
View File
@@ -0,0 +1,16 @@
document.addEventListener('DOMContentLoaded', async () => {
if (!('OTPCredential' in window) || !window.isSecureContext) return;
const form = document.querySelector('[data-webotp-form]');
const input = form?.querySelector('[data-webotp]');
if (!form || !input) return;
const controller = new AbortController();
form.addEventListener('submit', () => controller.abort(), { once: true });
try {
const credential = await navigator.credentials.get({ otp: { transport: ['sms'] }, signal: controller.signal });
if (!credential?.code) return;
input.value = credential.code;
form.requestSubmit();
} catch (error) {
if (error.name !== 'AbortError') console.debug('WebOTP unavailable', error);
}
});