Add compact accessible client summaries and menu discounts in percent or rubles with explicit durations. Preserve ordinary catalog prices and order line snapshots; derive current prices and expire promotions automatically without background writes. Keep fractional prices, legacy sale compatibility, and regression coverage. Full pull-request QA passed. Publication remains gated by full main QA and byte-for-byte production asset and UI verification.
113 lines
7.8 KiB
JavaScript
113 lines
7.8 KiB
JavaScript
/* Catalog prices are derived, never overwritten by a timer. Stored order lines
|
||
keep their own price snapshots. Promotion dates are absolute UTC instants. */
|
||
(()=>{
|
||
'use strict';
|
||
const DAY=86400000,MAX_TIMER=2147480000;
|
||
const number=v=>typeof v==='number'?v:Number(String(v??'').trim().replace(',','.'));
|
||
const cents=v=>Math.round((v+Number.EPSILON)*100)/100;
|
||
const validPrice=v=>Number.isFinite(number(v))&&number(v)>=0;
|
||
const money=v=>`${Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`;
|
||
const stamp=v=>typeof v==='string'&&v.trim()?Date.parse(v):NaN;
|
||
const $=id=>document.getElementById(id);
|
||
function basePrice(item){
|
||
const current=validPrice(item?.price)?cents(number(item.price)):0;
|
||
// Compatibility only: old records stored the discounted price in price.
|
||
return !item?.promotion&&validPrice(item?.oldPrice)&&number(item.oldPrice)>current?cents(number(item.oldPrice)):current;
|
||
}
|
||
function promotion(item){
|
||
if(item?.promotion&&typeof item.promotion==='object')return item.promotion;
|
||
const base=basePrice(item),current=validPrice(item?.price)?cents(number(item.price)):0;
|
||
return base>current?{type:'amount',value:cents(base-current),startsAt:null,endsAt:null}:null;
|
||
}
|
||
function quote(item,now=Date.now()){
|
||
const base=basePrice(item),p=promotion(item),value=number(p?.value);
|
||
const start=p?.startsAt==null?-Infinity:stamp(p.startsAt),end=p?.endsAt==null?Infinity:stamp(p.endsAt);
|
||
const valid=Boolean(p&&['percent','amount'].includes(p.type)&&Number.isFinite(value)&&value>0&&base>0&&value<=(p.type==='percent'?100:base)&&!Number.isNaN(start)&&!Number.isNaN(end)&&end>start);
|
||
const active=valid&&now>=start&&now<end;
|
||
return {base,price:active?cents(Math.max(0,base-(p.type==='percent'?base*value/100:value))):base,active,scheduled:valid&&now<start,expired:valid&&now>=end};
|
||
}
|
||
const price=(item,now)=>quote(item,now).price;
|
||
function linePrice(line,item,now){return line?.price!==undefined&&line.price!==null&&String(line.price).trim()!==''&&validPrice(line.price)?cents(number(line.price)):price(item,now)}
|
||
const badge=(item)=>quote(item).active?'<span class="ct-promotion-badge">Акция</span>':'';
|
||
const localTime=iso=>{const d=new Date(iso);if(!Number.isFinite(d.getTime()))return '';return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`};
|
||
let editorOriginal=null,getter=null,timer=0,lastSignature='';
|
||
function loadEditor(item){
|
||
const p=promotion(item),q=quote(item);
|
||
editorOriginal=p?{...p}:null;
|
||
if(!$('ctPromotionEnabled'))return;
|
||
$('boxPrice').value=basePrice(item);
|
||
$('ctPromotionEnabled').checked=Boolean(p&&!q.expired);
|
||
$('ctDiscountType').value=p?.type==='amount'?'amount':'percent';
|
||
$('ctDiscountValue').value=p?.value??'';
|
||
const keep=$('ctPromotionDuration').querySelector('[value="keep"]');
|
||
keep.hidden=!(p?.endsAt&&!q.expired);
|
||
$('ctPromotionDuration').value=p&&!q.expired?(p.endsAt?'keep':'none'):'7';
|
||
$('ctPromotionEnd').value=p?.endsAt?localTime(p.endsAt):'';
|
||
$('ctPromotionError').textContent='';
|
||
updateEditor();
|
||
}
|
||
function readEditor(now=Date.now()){
|
||
const base=number($('boxPrice')?.value);
|
||
if(!Number.isFinite(base)||base<0)return {error:'Укажите обычную цену: число не меньше нуля.',field:'boxPrice'};
|
||
if(!$('ctPromotionEnabled')?.checked)return {base:cents(base),promotion:null};
|
||
const type=$('ctDiscountType').value,raw=$('ctDiscountValue').value,value=number(raw);
|
||
if(!raw.trim()||!Number.isFinite(value)||cents(value)<=0)return {error:'Укажите скидку больше нуля.',field:'ctDiscountValue'};
|
||
if(base<=0||value>(type==='percent'?100:base))return {error:type==='percent'?'Скидка должна быть не больше 100%, а обычная цена — больше нуля.':'Скидка не может превышать обычную цену.',field:'ctDiscountValue'};
|
||
const duration=$('ctPromotionDuration').value;
|
||
let startsAt=editorOriginal?.startsAt||new Date(now).toISOString(),endsAt=null;
|
||
if(duration==='keep')endsAt=editorOriginal?.endsAt;
|
||
else if(duration==='custom'){
|
||
const rawEnd=$('ctPromotionEnd').value,t=new Date(rawEnd).getTime();
|
||
if(!rawEnd||!Number.isFinite(t))return {error:'Выберите дату и время окончания акции.',field:'ctPromotionEnd'};
|
||
endsAt=new Date(t).toISOString();startsAt=new Date(now).toISOString();
|
||
}else if(duration!=='none'){
|
||
if(!['1','7','14','30'].includes(duration))return {error:'Выберите срок акции.',field:'ctPromotionDuration'};
|
||
startsAt=new Date(now).toISOString();endsAt=new Date(now+Number(duration)*DAY).toISOString();
|
||
}
|
||
if(endsAt&&stamp(endsAt)<=now)return {error:'Акция уже закончилась. Выберите новый срок или выключите её.',field:'ctPromotionDuration'};
|
||
return {base:cents(base),promotion:{type,value:cents(value),startsAt,endsAt}};
|
||
}
|
||
function updateEditor(){
|
||
const enabled=$('ctPromotionEnabled')?.checked,fields=$('ctPromotionFields');if(!fields)return;
|
||
fields.hidden=!enabled;
|
||
$('ctPromotionEndLabel').hidden=$('ctPromotionDuration').value!=='custom';
|
||
const type=$('ctDiscountType').value;
|
||
$('ctDiscountCaption').textContent=type==='percent'?'Скидка, %':'Скидка, ₽';
|
||
$('ctDiscountValue').max=type==='percent'?'100':String(number($('boxPrice').value)||0);
|
||
const data=readEditor(),preview=$('ctPromotionPreview');
|
||
if(data.error){preview.textContent=enabled?data.error:'';return;}
|
||
const q=quote({price:data.base,promotion:data.promotion});
|
||
const ending=data.promotion?.endsAt?` До ${new Date(data.promotion.endsAt).toLocaleString('ru-RU',{day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'})}. Затем снова ${money(data.base)}.`:' Без ограничения срока.';
|
||
preview.textContent=enabled?`Цена по акции: ${money(q.price)}.${ending}`:`Обычная цена: ${money(data.base)}.`;
|
||
}
|
||
function saveEditor(item){
|
||
const data=readEditor();
|
||
if(data.error){$('ctPromotionError').textContent=data.error;$(data.field)?.focus();return false;}
|
||
item.price=data.base;
|
||
if(data.promotion)item.promotion=data.promotion;else delete item.promotion;
|
||
delete item.oldPrice;delete item.sale;
|
||
$('ctPromotionError').textContent='';return true;
|
||
}
|
||
function refresh(){
|
||
clearTimeout(timer);timer=0;if(!getter)return;
|
||
const now=Date.now(),items=getter()||[];
|
||
const signature=JSON.stringify(items.map(i=>[String(i.id),price(i,now),quote(i,now).active]));
|
||
if(signature!==lastSignature){lastSignature=signature;window.dispatchEvent(new CustomEvent('caterium:catalog-prices-changed'));}
|
||
let next=Infinity;
|
||
for(const item of items){const p=promotion(item);for(const t of [stamp(p?.startsAt),stamp(p?.endsAt)])if(t>now)next=Math.min(next,t);}
|
||
if(Number.isFinite(next))timer=setTimeout(refresh,Math.min(MAX_TIMER,Math.max(1,next-Date.now()+10)));
|
||
}
|
||
function watch(catalogGetter){getter=catalogGetter;refresh();}
|
||
function boot(){
|
||
$('ctPromotionEditor')?.addEventListener('input',()=>{if($('ctPromotionError'))$('ctPromotionError').textContent='';updateEditor();});
|
||
$('ctPromotionEditor')?.addEventListener('change',updateEditor);
|
||
$('boxPrice')?.addEventListener('input',updateEditor);
|
||
window.addEventListener('sun:cloud-state-applied',refresh);
|
||
window.addEventListener('focus',refresh);
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
|
||
window.addEventListener('sun:cloud-tenant-changing',()=>{clearTimeout(timer);timer=0;lastSignature='';editorOriginal=null;});
|
||
}
|
||
window.CateriumPricing=Object.freeze({basePrice,promotion,quote,price,linePrice,badge,loadEditor,saveEditor,refresh,watch});
|
||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
|
||
})();
|