add payment_link and banks

This commit is contained in:
javad committed 2026-10-06 10:03:22 +03:30
1 parent 2ae79b1ca4
commit de3135b588
100 files changed
+6167 -47

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
/* Selection changes presentation only; permissions and updates remain server-side. */
(() => {
'use strict';
const form = document.querySelector('[data-bank-selection]');
if (!form) return;
const boxes = Array.from(form.querySelectorAll('input[name="accounts"]'));
const all = form.querySelector('[data-select-all]');
const count = form.querySelector('[data-selection-count]');
const submit = form.querySelector('[data-bulk-submit]');
if (!all || !count || !submit) return;
const update = () => {
const selected = boxes.filter(box => box.checked).length;
all.checked = selected > 0 && selected === boxes.length;
all.indeterminate = selected > 0 && selected < boxes.length;
count.textContent = selected ? `${selected.toLocaleString('fa-IR')} حساب انتخاب شده` : 'حساب‌های موردنظر را انتخاب کنید.';
submit.disabled = selected === 0;
};
all.addEventListener('change', () => {
boxes.forEach(box => { box.checked = all.checked; });
update();
});
boxes.forEach(box => box.addEventListener('change', update));
window.addEventListener('pageshow', update);
update();
})();
+94
View File
@@ -0,0 +1,94 @@
/* Local, server-filtered search. Explicit choice only; no customer data in storage. */
(() => {
'use strict';
const root = document.querySelector('[data-payment-customer-picker]');
if (!root) return;
const search = root.querySelector('input[type=search]');
const value = root.querySelector('#id_customer');
const results = root.querySelector('[data-customer-results]');
const selected = root.querySelector('[data-selected-customer]');
const status = root.querySelector('[role=status]');
let timer, controller, version = 0;
const node = (tag, text, className) => {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
};
const history = customer => {
const details = node('details', undefined, 'pay-customer-history');
details.append(node('summary', `سوابق قابل مشاهده · ${customer.ticket_count.toLocaleString('fa-IR')} تیکت`));
if (!customer.history.length) details.append(node('p', 'سابقه تیکت قابل مشاهده‌ای وجود ندارد.'));
customer.history.forEach(ticket => {
const link = node('a', `${ticket.number} · ${ticket.subject}`);
link.href = ticket.url; link.target = '_blank'; link.rel = 'noopener';
const row = node('div'); row.append(link, node('small', ticket.status)); details.append(row);
});
return details;
};
const identity = customer => {
const container = node('div', undefined, 'pay-customer-identity');
container.append(node('strong', customer.name), node('bdi', customer.mobile));
if (customer.organization) container.append(node('span', customer.organization));
container.append(node('small', customer.location || 'موقعیت ثبت نشده'));
return container;
};
const choose = (customer, focus = true) => {
clearTimeout(timer); version++; controller?.abort();
value.value = customer.value; value.dispatchEvent(new Event('change', {bubbles:true}));
search.value = ''; results.hidden = true; results.replaceChildren();
selected.replaceChildren(node('span', 'مشتری انتخاب‌شده', 'pay-selected-label'), identity(customer), history(customer));
const change = node('button', 'تغییر مشتری', 'pay-button secondary'); change.type = 'button';
change.addEventListener('click', () => {
value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; search.focus();
status.textContent = 'مشتری جدید را جست‌وجو و انتخاب کنید.';
});
selected.append(change); selected.hidden = false;
status.textContent = 'قبل از صدور، نام و شماره تماس مشتری انتخاب‌شده را بررسی کنید.';
if (focus) change.focus({preventScroll:true});
};
const lookup = async () => {
const query = search.value.trim(), current = ++version;
controller?.abort(); controller = new AbortController();
if (query.length < 2) { results.hidden = true; status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; }
status.textContent = 'در حال جست‌وجو…';
try {
const response = await fetch(`${root.dataset.searchUrl}?q=${encodeURIComponent(query)}`, {
credentials: 'same-origin', cache: 'no-store', signal: controller.signal
});
if (!response.ok) throw new Error('lookup');
const data = await response.json();
if (current !== version) return;
results.replaceChildren();
data.results.forEach(customer => {
const card = node('article', undefined, 'pay-customer-result');
const button = node('button', 'انتخاب این مشتری', 'pay-button secondary'); button.type = 'button';
button.addEventListener('click', () => choose(customer));
card.append(identity(customer), history(customer), button); results.append(card);
});
results.hidden = !data.results.length;
status.textContent = data.results.length ? `${data.results.length.toLocaleString('fa-IR')} نتیجه${data.has_more ? '؛ برای نتایج دقیق‌تر نام یا شماره کامل‌تری وارد کنید.' : '؛ مشتری موردنظر را انتخاب کنید.'}` : 'مشتری فعالی پیدا نشد؛ نام یا شماره تماس را بررسی کنید.';
} catch (error) {
if (error.name === 'AbortError' || current !== version) return;
results.hidden = true;
status.textContent = 'جست‌وجو انجام نشد؛ اتصال را بررسی کنید و دوباره جست‌وجو کنید.';
}
};
search.addEventListener('input', () => {
clearTimeout(timer); version++; controller?.abort();
value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; results.hidden = true;
if (search.value.trim().length < 2) { status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; }
timer = setTimeout(lookup, 350);
});
search.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); clearTimeout(timer); lookup(); }
});
root.closest('form').addEventListener('submit', event => {
if (!value.value) {
event.preventDefault(); status.textContent = 'مشتری را از نتایج جست‌وجو انتخاب کنید.';
search.focus(); search.scrollIntoView({block:'center'});
}
});
const initial = JSON.parse(document.querySelector('#payment-selected-customer').textContent);
if (initial && String(initial.value) === value.value) choose(initial, false);
})();
+44
View File
@@ -0,0 +1,44 @@
/* Customer-only presentation. Financial and image validation remains in receipt-form.js.
Object URLs preview local files without uploading or persisting them. */
(() => {
const form = document.querySelector('.customer-receipt-form');
if (!form) return;
const input = form.elements.namedItem('receipt_image');
const preview = document.querySelector('#receipt-file-preview');
const selection = document.querySelector('#receipt-file-selection');
const status = document.querySelector('#receipt-file-status');
let previewUrl = null;
function releasePreview() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
preview.removeAttribute('src');
preview.hidden = true;
}
function updateFile() {
releasePreview();
const file = input.files[0];
selection.hidden = !file;
status.textContent = file ? file.name + ' · ' + new Intl.NumberFormat('fa-IR', {maximumFractionDigits: 1}).format(file.size / 1024) + ' کیلوبایت' : '';
if (file && /^image\/(jpeg|png|gif|webp)$/.test(file.type)) {
previewUrl = URL.createObjectURL(file);
preview.src = previewUrl;
preview.hidden = false;
}
}
input.addEventListener('change', updateFile);
document.querySelector('#receipt-clear-file').addEventListener('click', () => {
input.value = '';
input.dispatchEvent(new Event('change', {bubbles: true}));
input.focus();
});
window.addEventListener('pagehide', releasePreview);
const origin = form.elements.namedItem('origin_bank');
const other = form.elements.namedItem('origin_other');
function updateOrigin() {
const visible = origin.selectedOptions[0]?.textContent.trim() === 'سایر';
other.closest('[data-field]').hidden = !visible;
other.disabled = !visible;
}
origin.addEventListener('change', updateOrigin);
updateOrigin();
})();
+65
View File
@@ -0,0 +1,65 @@
/* Server calculates the identifier from the selected customer's stored mobile.
Manual values remain editable. Changing customer clears an unchanged auto value. */
(() => {
const button = document.querySelector('#generate-deposit-identifier');
if (!button) return;
const form = button.closest('form');
const customer = form.elements.namedItem('customer');
const input = form.elements.namedItem('deposit_id');
const status = document.querySelector('#identifier-generation-status');
const replacement = document.querySelector('#identifier-replacement');
const candidate = document.querySelector('#identifier-candidate');
const originalLabel = button.innerHTML;
let activeCustomer = customer.value, generated = '', pending = '', controller = null;
function customerChanged() {
if (activeCustomer !== customer.value) {
controller?.abort(); controller = null;
if (generated && input.value === generated) input.value = '';
generated = ''; pending = ''; replacement.hidden = true;
activeCustomer = customer.value;
}
button.disabled = !customer.value;
button.innerHTML = originalLabel;
status.textContent = customer.value ? 'شناسه بر اساس شماره موبایل مشتری انتخاب‌شده تولید می‌شود.' : 'ابتدا مشتری را انتخاب کنید؛ شناسه بر اساس شماره موبایل او تولید می‌شود.';
}
function applyIdentifier(value) {
input.value = value; generated = value; pending = '';
replacement.hidden = true;
input.dispatchEvent(new Event('input', {bubbles:true}));
input.dispatchEvent(new Event('change', {bubbles:true}));
input.focus();
status.textContent = 'شناسه تولید و در فیلد وارد شد؛ قبل از صدور اعلامیه آن را بررسی کنید.';
}
customer.addEventListener('change', customerChanged);
button.addEventListener('click', async () => {
if (!customer.value) return;
const selectedCustomer = customer.value;
const request = new AbortController(); controller = request;
button.disabled = true; button.textContent = 'در حال تولید…';
replacement.hidden = true;
status.textContent = 'در حال دریافت شناسه از سیستم…';
let failureMessage = 'شناسه دریافت نشد؛ اتصال را بررسی کنید و دوباره تلاش کنید.';
try {
const response = await fetch(button.dataset.url, {
method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: request.signal,
headers: {'X-CSRFToken': form.elements.namedItem('csrfmiddlewaretoken').value},
body: new URLSearchParams({customer: selectedCustomer})
});
const data = await response.json();
if (request !== controller || customer.value !== selectedCustomer) return;
if (!response.ok) {failureMessage = data.error || 'شناسه تولید نشد؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (data.customer !== selectedCustomer || !/^[0-9]{13}$/.test(data.identifier)) {failureMessage = 'پاسخ سیستم معتبر نیست؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (input.value.trim() && input.value !== data.identifier) {
pending = data.identifier; candidate.textContent = pending; replacement.hidden = false;
status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
} else applyIdentifier(data.identifier);
} catch (error) {
if (error.name !== 'AbortError' && request === controller) status.textContent = failureMessage;
} finally {
if (request === controller) {controller = null; button.disabled = !customer.value; button.innerHTML = originalLabel;}
}
});
document.querySelector('#apply-deposit-identifier').addEventListener('click', () => {if (pending) applyIdentifier(pending);});
document.querySelector('#cancel-deposit-identifier').addEventListener('click', () => {pending = ''; replacement.hidden = true; status.textContent = 'شناسه فعلی حفظ شد.'; button.focus();});
customerChanged();
})();
+20
View File
@@ -0,0 +1,20 @@
/* All assets and UI behavior are local. No CDN or financial data in persistent browser storage. */
(() => {
'use strict';
let toast;
function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);}
async function copy(text){
try{await navigator.clipboard.writeText(text);status('کپی شد');}
catch{
const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات');
const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text;
const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();};
const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select();
}
}
document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy)));
const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id');
if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();}
const badge=document.querySelector('[data-payment-count]');
if(badge){const update=async()=>{if(document.hidden)return;try{const response=await fetch(badge.dataset.countUrl,{credentials:'same-origin',cache:'no-store'});if(!response.ok)return;const data=await response.json();document.querySelectorAll('[data-payment-count]').forEach(node=>{node.textContent=String(data.pending_receipts);});}catch{}};document.addEventListener('visibilitychange',update);setInterval(update,60000);}
})();
+78
View File
@@ -0,0 +1,78 @@
/* Local amount presentation: never convert rials to tomans for the server.
Exact BigInt arithmetic; preserve fractional toman (one rial = one tenth toman).
Shared with notice amounts; visible grouping does not replace server validation. */
(() => {
'use strict';
const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return;
input.inputMode='numeric';
const words=document.querySelector('#amount-words');
const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده'];
const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود'];
const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد'];
const scales=['','هزار','میلیون','میلیارد','تریلیون','کوادریلیون'];
const ascii=value=>value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632));
const digitCount=value=>(ascii(value).match(/\d/g) || []).length;
const separators=/[,٬ \u00a0]/g;
const grouped=value=>value.replace(/\B(?=(\d{3})+(?!\d))/g,'٬').replace(/\d/g,digit=>'۰۱۲۳۴۵۶۷۸۹'[digit]);
function threeDigits(value){
const parts=[];
if(value>=100)parts.push(hundreds[Math.floor(value/100)]);
const rest=value%100;
if(rest && rest<20)parts.push(small[rest]);
if(rest>=20){parts.push(tens[Math.floor(rest/10)]);if(rest%10)parts.push(small[rest%10]);}
return parts.join(' و ');
}
function numberWords(value){
if(value===0n)return 'صفر';
const parts=[];let index=0;
while(value>0n){
const group=Number(value%1000n);
if(group){const text=group===1 && index===1?'هزار':threeDigits(group)+(scales[index]?' '+scales[index]:'');parts.unshift(text);}
value/=1000n;index++;
}
return parts.join(' و ');
}
function tomanWords(rials){
const whole=rials/10n,fraction=rials%10n;
if(!fraction)return numberWords(whole)+' تومان';
return (whole?numberWords(whole)+' و ':'')+numberWords(fraction)+' دهم تومان';
}
function caretAfterDigits(value,count){
if(!count)return 0;
for(let index=0;index<value.length;index++)if(/\d/.test(ascii(value[index])) && --count===0)return index+1;
return value.length;
}
function formatAmount(event){
if(event?.isComposing)return;
const raw=input.value,start=input.selectionStart ?? raw.length,end=input.selectionEnd ?? start;
const normalized=ascii(raw).trim();
const valid=/^\d+$/.test(normalized) || /^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(normalized);
// Re-group after genuine digit edits/deletion, but never sanitize letters, signs or decimal input.
const editing=event?.inputType?.startsWith('delete') || (event?.inputType==='insertText' && /^\d+$/.test(ascii(event.data || '')));
if(!valid && !(editing && /^[\d,٬ \u00a0]+$/.test(normalized))){if(words)words.textContent='';return;}
const digits=normalized.replace(separators,'').replace(/^0+(?=\d)/,'');
if(!digits || digits.length>16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER)){if(words)words.textContent='';return;}
const leading=normalized.replace(separators,'').length-digits.length;
const left=Math.max(0,digitCount(raw.slice(0,start))-leading),right=Math.max(0,digitCount(raw.slice(0,end))-leading);
input.value=grouped(digits);
input.setSelectionRange(caretAfterDigits(input.value,left),caretAfterDigits(input.value,right));
if(words)words.textContent='معادل: '+tomanWords(BigInt(digits));
}
input.addEventListener('input',formatAmount);
input.addEventListener('compositionend',()=>formatAmount());
input.addEventListener('beforeinput',event=>{
const start=input.selectionStart,end=input.selectionEnd;
if(start!==end || event.isComposing)return;
const backward=event.inputType==='deleteContentBackward',forward=event.inputType==='deleteContentForward';
const adjacent=backward?start-1:start;
if((!backward && !forward) || !/[٬,]/.test(input.value[adjacent] || ''))return;
// Deleting a separator removes the adjacent digit, so grouping cannot trap the caret.
const target=backward?adjacent-1:adjacent+1;
const count=digitCount(input.value.slice(0,target));
const raw=input.value.slice(0,target)+input.value.slice(target+1);
event.preventDefault();input.value=ascii(raw).replace(separators,'');
input.setSelectionRange(count,count);input.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:event.inputType}));
});
input.addEventListener('blur',()=>formatAmount());
formatAmount();
})();
+70
View File
@@ -0,0 +1,70 @@
/* Server remains authoritative. Display Jalali, submit an ASCII Gregorian date-only.
No dates, bank details or receipts are placed in localStorage/service-worker caches. */
(() => {
'use strict';
const form=document.querySelector('#receipt-form');if(!form)return;
const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt');
const notices=JSON.parse(document.querySelector('#notice-data').textContent);
const day=q('paid_day'),month=q('paid_month'),year=q('paid_year');
const digits=value=>String(value).replace(/[۰-۹]/g,x=>String(x.charCodeAt(0)-1776)).replace(/[٠-٩]/g,x=>String(x.charCodeAt(0)-1632));
const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'});
const parts=date=>Object.fromEntries(formatter.formatToParts(date).map(p=>[p.type,p.value]));
function newYear(y){for(let d=18;d<=24;d++){const date=new Date(Date.UTC(y+621,2,d,12)),p=parts(date);if(Number(p.year)===y && Number(p.month)===1 && Number(p.day)===1)return date;}throw Error('تاریخ معتبر نیست.');}
function dateValue(){
const output=document.querySelector('#paid-date-iso');output.value='';
if(!day.value || !month.value || !year.value)return '';
try{const y=Number(year.value),m=Number(month.value),d=Number(day.value),offset=m<=6?(m-1)*31:186+(m-7)*30;const date=new Date(newYear(y).getTime()+(offset+d-1)*86400000),p=parts(date);if(Number(p.year)!==y || Number(p.month)!==m || Number(p.day)!==d)return '';output.value=date.toISOString().slice(0,10);return output.value;}catch{return '';}
}
function dateUpdate(){const iso=dateValue();document.querySelector('#date-preview').textContent=iso?'تاریخ انتخاب‌شده: '+day.options[day.selectedIndex].text+' '+month.options[month.selectedIndex].text+' '+year.value:'روز، ماه و سال پرداخت را کامل انتخاب کنید.';}
function monthUpdate(){
let length=31;
if(month.value && year.value){const m=Number(month.value),y=Number(year.value);length=m<=6?31:m<=11?30:(newYear(y+1)-newYear(y))/86400000===366?30:29;}
[...day.options].forEach(option=>{option.disabled=Number(option.value)>length;});if(Number(day.value)>length)day.value='';dateUpdate();
}
[month,year].forEach(control=>control.addEventListener('change',monthUpdate));day.addEventListener('change',dateUpdate);
document.querySelector('#date-today').addEventListener('click',()=>{const p=parts(new Date());year.value=p.year;month.value=p.month;monthUpdate();day.value=p.day;dateUpdate();day.focus();});monthUpdate();
function sync(){
const linked=q('receipt_mode').value==='linked';const select=q('notice_id');form.querySelector('[data-field=notice_id]').hidden=!linked;select.disabled=!linked;select.required=linked;
if(!linked)select.value='';
const notice=linked?notices.find(n=>n.id===select.value):null;const summary=document.querySelector('#selected-payment');summary.hidden=!notice;
const summaryText=notice?'مبلغ اعلام‌شده: '+new Intl.NumberFormat('fa-IR').format(BigInt(notice.amount))+' ریال · شناسه واریز: '+(notice.deposit || 'اعلام نشده')+(notice.invoice?' · فاکتور: '+notice.invoice:''):'';
const title=summary.querySelector('[data-selected-notice-title]');
if(title){title.textContent=notice?notice.subject:'';summary.querySelector('[data-selected-notice-summary]').textContent=summaryText;summary.querySelector('[data-selected-notice-link]').href=notice?notice.url:'#';}else summary.textContent=summaryText;
const finalSummary=document.querySelector('#receipt-summary');if(finalSummary)finalSummary.textContent=notice?'این فیش برای «'+notice.subject+'» ارسال می‌شود.':linked?'اعلامیه مرتبط را انتخاب کنید.':'این فیش به صورت واریز مستقل برای بررسی ارسال می‌شود.';
const depositWarning=summary.querySelector('[data-selected-deposit-warning]');
depositWarning.hidden=!notice?.deposit;
q('deposit_id').classList.toggle('pay-deposit-emphasis',Boolean(notice?.deposit));
q('deposit_id').required=false;
}
form.querySelectorAll('[name=receipt_mode]').forEach(control=>control.addEventListener('change',sync));q('notice_id').addEventListener('change',()=>{const notice=notices.find(n=>n.id===q('notice_id').value);if(notice){q('destination_account').value=String(notice.account);q('deposit_id').value=notice.deposit;}sync();});q('destination_account').addEventListener('change',sync);sync();
const fields=[...form.querySelectorAll('[data-field]')];
fields.forEach(field=>{const controls=field.querySelectorAll('input,select,textarea');controls.forEach(control=>{control.setAttribute('aria-describedby','error-'+field.dataset.field);if(field.querySelector('.field-error').textContent.trim())control.setAttribute('aria-invalid','true');});});
let busy=false;
function showErrors(errors){
fields.forEach(field=>{const name=field.dataset.field,message=errors.get(name)||'',error=field.querySelector('.field-error');error.textContent=message;error.hidden=!message;field.querySelectorAll('input,select,textarea').forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));});
const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),link=document.createElement('a');link.href='#id_'+name;link.textContent=message;link.onclick=event=>{event.preventDefault();q(name).focus();q(name).scrollIntoView({block:'center'});};item.append(link);list.append(item);});summary.hidden=!errors.size;
if(errors.size){const first=q(errors.keys().next().value);first.focus();first.scrollIntoView({block:'center'});}
}
async function validateImage(file){
if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
if(!file.size || file.size>Number(form.dataset.maxBytes))return 'حجم تصویر بیش از حد مجاز است یا تصویر خالی است.';
const extension=file.name.split('.').pop().toLowerCase();if(!['jpg','jpeg','png','gif','webp'].includes(extension))return 'فقط تصویر JPG، PNG، GIF یا WEBP انتخاب کنید.';
const readable=await new Promise(resolve=>{const picture=new Image(),url=URL.createObjectURL(file);picture.onload=()=>{URL.revokeObjectURL(url);resolve(picture.naturalWidth*picture.naturalHeight<=25000000);};picture.onerror=()=>{URL.revokeObjectURL(url);resolve(false);};picture.src=url;});return readable?'':'تصویر باز نمی‌شود یا ابعاد آن بیش از حد مجاز است.';
}
let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++);
form.addEventListener('submit',async event=>{
event.preventDefault();if(busy)return;busy=true;button.disabled=true;const version=revision,errors=new Map();
try{
const value=digits(q('paid_amount').value).trim();if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ ]\d{3})+$/.test(value))errors.set('paid_amount','مبلغ را به ریال و با عدد صحیح وارد کنید.');else{const amount=BigInt(value.replace(/[,٬ ]/g,''));if(amount<=0n || amount>9007199254740991n)errors.set('paid_amount','مبلغ باید مثبت و در محدوده مجاز باشد.');}
if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
if(!q('destination_account').value)errors.set('destination_account','حساب مقصد را انتخاب کنید.');
const iso=dateValue();if(!iso)errors.set('paid_day','تاریخ را کامل و معتبر انتخاب کنید؛ روز، ماه و سال لازم‌اند.');
else{const now=Object.fromEntries(new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Tehran',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()).map(p=>[p.type,p.value]));if(iso>[now.year,now.month,now.day].join('-'))errors.set('paid_day','تاریخ واریز نمی‌تواند بعد از امروز باشد.');}
const imageError=await validateImage(q('receipt_image').files[0]);if(imageError)errors.set('receipt_image',imageError);
if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;}
showErrors(errors);if(errors.size)return;
button.textContent='در حال ثبت…';HTMLFormElement.prototype.submit.call(form);return;
}finally{busy=false;if(button.textContent!=='در حال ثبت…')button.disabled=false;}
});
if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}}
})();