153 lines
9.5 KiB
JavaScript
153 lines
9.5 KiB
JavaScript
/* Browser-only design prototype: submit is ALWAYS prevented, nothing uploaded.
|
||
Production must validate ownership, amount, date and real file content again.
|
||
Limits here follow bootstrap IMAGE defaults, not a read of live AttachmentPolicy. */
|
||
(() => {
|
||
'use strict';
|
||
const form=document.querySelector('#receipt-form');
|
||
if(!form)return;
|
||
const q=selector=>form.querySelector(selector);
|
||
const amount=q('#paid-amount'),file=q('#receipt-file'),button=q('#submit-receipt');
|
||
const summary=q('#receipt-errors'),status=q('#receipt-validation-status');
|
||
const groups={
|
||
notice:{controls:[q('#notice-select')],error:q('#notice-error')},
|
||
amount:{controls:[amount],error:q('#amount-error')},
|
||
destination:{controls:[q('#destination-account')],error:q('#destination-error')},
|
||
date:{controls:[q('#receipt-day'),q('#receipt-month'),q('#receipt-year')],error:q('#date-error')},
|
||
file:{controls:[file],error:q('#file-error')}
|
||
};
|
||
const messages=new Map();
|
||
const touched=new Set();
|
||
const imageChecks=new WeakMap();
|
||
let submitted=false,busy=false,revision=0,fileRevision=0;
|
||
function focusField(control){
|
||
const visible=control===file?q('.file-picker-label'):control;
|
||
visible.scrollIntoView({block:'center',behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});
|
||
control.focus({preventScroll:true});
|
||
}
|
||
function firstControl(key){
|
||
const controls=groups[key].controls;
|
||
return controls.find(control=>!control.value) || controls[0];
|
||
}
|
||
function renderSummary(){
|
||
const list=summary.querySelector('ul');list.replaceChildren();
|
||
messages.forEach((message,key)=>{
|
||
const item=document.createElement('li'),link=document.createElement('a');
|
||
link.href='#'+firstControl(key).id;link.textContent=message;
|
||
link.addEventListener('click',event=>{event.preventDefault();focusField(firstControl(key));});
|
||
item.append(link);list.append(item);
|
||
});
|
||
summary.hidden=!submitted || messages.size===0;
|
||
}
|
||
function showError(key,message){
|
||
const group=groups[key];group.error.textContent=message;group.error.hidden=!message;
|
||
group.controls.forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));
|
||
if(message)messages.set(key,message);else messages.delete(key);
|
||
renderSummary();
|
||
}
|
||
function normalizeDigits(value){
|
||
return value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632)).trim();
|
||
}
|
||
function amountError(){
|
||
q('#paid-amount-value').value='';
|
||
const value=normalizeDigits(amount.value);
|
||
if(!value)return 'مبلغ واریز را به ریال وارد کنید.';
|
||
if(value.startsWith('-'))return 'مبلغ باید بیشتر از صفر باشد.';
|
||
if(/[.٫]/.test(value))return 'مبلغ را به ریال و بدون اعشار وارد کنید.';
|
||
if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(value))return 'مبلغ را فقط با عدد بنویسید؛ مثلاً ۲۸۰۰۰۰۰۰۰.';
|
||
const digits=value.replace(/[,٬ \u00a0]/g,'').replace(/^0+(?=\d)/,'');
|
||
if(digits==='0')return 'مبلغ باید بیشتر از صفر باشد.';
|
||
// Avoid imprecise conversion: payload remains an integer string.
|
||
if(digits.length>16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER))return 'مبلغ بسیار بزرگ است؛ عدد روی فیش را دوباره بررسی کنید.';
|
||
q('#paid-amount-value').value=digits;return '';
|
||
}
|
||
function noticeError(){
|
||
if(q('input[name="receipt-mode"]:checked').value!=='linked')return '';
|
||
const selected=q('#notice-select').value;
|
||
if(!(window.chaharsuPaymentNotices || []).some(notice=>notice.id===selected))return 'واریز مرتبط را انتخاب کنید؛ اگر اعلامیه ندارید، واریز مستقل را بزنید.';
|
||
return '';
|
||
}
|
||
function destinationError(){
|
||
const selected=q('#destination-account').value;
|
||
if(!(window.chaharsuCompanyAccounts || []).some(account=>account.id===selected))return 'حساب مقصد را انتخاب کنید؛ مبلغ را به کدام حساب شرکت واریز کردهاید؟';
|
||
return '';
|
||
}
|
||
function dateError(){
|
||
form.dispatchEvent(new Event('receipt-date:validate'));
|
||
const controls=groups.date.controls;
|
||
if(controls.some(control=>!control.value))return 'تاریخ را کامل کنید؛ روز، ماه و سال لازماند.';
|
||
if(controls.some(control=>control.disabled || !control.checkValidity()))return 'تاریخ معتبر انتخاب کنید؛ روز باید در ماه و سال انتخابشده وجود داشته باشد.';
|
||
const value=q('#receipt-date-value').value;
|
||
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return 'تاریخ معتبر انتخاب کنید؛ روز، ماه و سال را دوباره بررسی کنید.';
|
||
const todayParts=new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Tehran',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date());
|
||
const today=Object.fromEntries(todayParts.map(part=>[part.type,part.value]));
|
||
if(value>[today.year,today.month,today.day].join('-'))return 'تاریخ واریز نمیتواند بعد از امروز باشد.';
|
||
return '';
|
||
}
|
||
function detectedType(bytes){
|
||
const text=(start,end)=>String.fromCharCode(...bytes.slice(start,end));
|
||
if(bytes[0]===255 && bytes[1]===216 && bytes[2]===255)return 'image/jpeg';
|
||
if([...bytes.slice(0,8)].join(',')==='137,80,78,71,13,10,26,10')return 'image/png';
|
||
if(['GIF87a','GIF89a'].includes(text(0,6)))return 'image/gif';
|
||
if(text(0,4)==='RIFF' && text(8,12)==='WEBP')return 'image/webp';
|
||
return '';
|
||
}
|
||
async function checkImage(selected){
|
||
const max=Number(file.dataset.maxBytes);
|
||
if(!selected.size)return 'تصویر انتخابشده خالی است؛ تصویر دیگری انتخاب کنید.';
|
||
if(selected.size>max)return 'حجم تصویر بیشتر از ۱۰ مگابایت است؛ تصویر کمحجمتری انتخاب کنید.';
|
||
const allowed={jpg:'image/jpeg',jpeg:'image/jpeg',png:'image/png',gif:'image/gif',webp:'image/webp'};
|
||
const extension=selected.name.split('.').pop().toLowerCase();
|
||
if(!allowed[extension])return 'فقط تصویر JPG، PNG، GIF یا WEBP انتخاب کنید.';
|
||
const type=detectedType(new Uint8Array(await selected.slice(0,16).arrayBuffer()));
|
||
if(type!==allowed[extension] || (selected.type && selected.type!==type))return 'محتوای فایل با نوع تصویر سازگار نیست؛ تصویر اصلی فیش را انتخاب کنید.';
|
||
const readable=await new Promise(resolve=>{
|
||
const image=new Image(),url=URL.createObjectURL(selected);
|
||
image.onload=()=>{URL.revokeObjectURL(url);resolve(image.naturalWidth>0 && image.naturalHeight>0);};
|
||
image.onerror=()=>{URL.revokeObjectURL(url);resolve(false);};image.src=url;
|
||
});
|
||
return readable?'':'تصویر باز نمیشود یا خراب است؛ تصویر دیگری انتخاب کنید.';
|
||
}
|
||
async function fileError(){
|
||
const selected=file.files[0];
|
||
if(!selected)return 'تصویر فیش واریز را انتخاب کنید.';
|
||
if(!imageChecks.has(selected))imageChecks.set(selected,checkImage(selected).catch(()=> 'تصویر قابل بررسی نیست؛ دوباره آن را انتخاب کنید.'));
|
||
return imageChecks.get(selected);
|
||
}
|
||
const validators={notice:noticeError,amount:amountError,destination:destinationError,date:dateError,file:fileError};
|
||
function refresh(key){
|
||
status.textContent='';
|
||
if(key==='file')return refreshFile();
|
||
if(submitted || touched.has(key))showError(key,validators[key]());
|
||
}
|
||
async function refreshFile(){
|
||
const token=++fileRevision;touched.add('file');
|
||
const message=await fileError();if(token===fileRevision)showError('file',message);
|
||
}
|
||
form.addEventListener('input',()=>{revision++;status.textContent='';});
|
||
form.addEventListener('change',()=>{revision++;status.textContent='';});
|
||
amount.addEventListener('blur',()=>{touched.add('amount');refresh('amount');});
|
||
amount.addEventListener('input',()=>{q('#paid-amount-value').value='';refresh('amount');});
|
||
groups.date.controls.forEach(control=>control.addEventListener('change',()=>refresh('date')));
|
||
q('#receipt-date-today').addEventListener('click',()=>{revision++;refresh('date');});
|
||
q('#notice-select').addEventListener('change',()=>refresh('notice'));
|
||
q('#destination-account').addEventListener('change',()=>refresh('destination'));
|
||
q('#destination-account').addEventListener('blur',()=>{touched.add('destination');refresh('destination');});
|
||
form.querySelectorAll('input[name="receipt-mode"]').forEach(control=>control.addEventListener('change',()=>refresh('notice')));
|
||
file.addEventListener('change',()=>refresh('file'));
|
||
q('#clear-file').addEventListener('click',()=>{revision++;refresh('file');});
|
||
form.addEventListener('submit',async event=>{
|
||
event.preventDefault();if(busy)return;
|
||
submitted=true;busy=true;button.disabled=true;button.textContent='در حال بررسی…';status.textContent='';
|
||
const current=revision;
|
||
try{
|
||
const results=[['notice',noticeError()],['amount',amountError()],['destination',destinationError()],['date',dateError()],['file',await fileError()]];
|
||
if(current!==revision){status.textContent='اطلاعات فرم تغییر کرده است؛ دوباره دکمهٔ بررسی را بزنید.';return;}
|
||
messages.clear();results.forEach(([key,message])=>showError(key,message));
|
||
if(messages.size){focusField(firstControl(messages.keys().next().value));return;}
|
||
status.textContent='اطلاعات فرم کامل و معتبر است. این پیشنمایش است؛ فیش ارسال یا ذخیره نشده است.';
|
||
}finally{busy=false;button.disabled=false;button.textContent='بررسی و ادامه';}
|
||
});
|
||
// Enable only when prevention and validation handlers are installed; no accidental native submit.
|
||
button.disabled=false;
|
||
})();
|