The one-page banquet menu PDF for clients no longer prints a price next to each dish, and the footer no longer shows a grand total — only the price per guest is shown, so the document can't be read as a per-dish price list. Added a theme selector (Золото/Ночь/ Минимал) with three visually distinct color/typography treatments for the same one-page layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
19 KiB
JavaScript
189 lines
19 KiB
JavaScript
/* One-page banquet menus. This module only reads an explicit selection snapshot;
|
||
opening, changing display options and exporting never save or apply an order. */
|
||
(()=>{
|
||
'use strict';
|
||
if(window.CateriumBanquetClientMenu)return;
|
||
const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif';
|
||
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
|
||
const THEMES={
|
||
gold:{label:'Золото',paper:'#fbf8f1',ink:'#243b30',muted:'#657269',accent:'#9b793e',rule:'#dcd5c6',titleAlign:'left'},
|
||
noir:{label:'Ночь',paper:'#1c1c20',ink:'#f4f1e9',muted:'#a79f8f',accent:'#c9a24a',rule:'#3c3b3d',titleAlign:'left'},
|
||
mono:{label:'Минимал',paper:'#ffffff',ink:'#161616',muted:'#6c6c6c',accent:'#161616',rule:'#e6e6e6',titleAlign:'center'}
|
||
};
|
||
const themeIds=Object.keys(THEMES);
|
||
const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim();
|
||
const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
|
||
const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;};
|
||
const copy=v=>JSON.parse(JSON.stringify(v));
|
||
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']);
|
||
let fontsPromise,enginePromise,panel=null;
|
||
function allowed(){
|
||
const c=window.SunCloudV2,s=window.SunSaaSV16;
|
||
if(c?.isSupportMode?.()||c?.getSupportMode?.())return false;
|
||
if(s?.hasFeature?.('client_offers')===false||s?.isWritable?.()===false)return false;
|
||
return !c?.getSession?.()?.user||c.hasPermission?.('catalog.view')!==false;
|
||
}
|
||
function section(item){
|
||
if(window.CateriumBanquet?.group)return window.CateriumBanquet.group(item);
|
||
const s=clean(item.catalogSection).toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло/.test(s)?'bread':/закуск|рыб|мясн|сыр|овощ/.test(s)?'cold':'other';
|
||
}
|
||
function snapshot(input){
|
||
const catalog=Array.isArray(input?.catalog)?input.catalog:[],draft=input?.draft||{};
|
||
const ids=new Set((Array.isArray(draft.banquetSelection)?draft.banquetSelection:(draft.lines||[]).filter(l=>catalog.some(i=>String(i.id)===String(l.id)&&Number(i.category)===6)).map(l=>l.id)).map(String));
|
||
const rows=catalog.filter(i=>Number(i.category)===6&&i.hidden!==true&&ids.has(String(i.id)));
|
||
if(!rows.length)throw new Error('Сначала выберите блюда или готовое меню.');
|
||
if(rows.length>100)throw new Error('Для одной страницы выбрано слишком много блюд. Сократите меню.');
|
||
const items=rows.map(item=>{
|
||
const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));
|
||
const raw=line?.price,hasPrice=raw!==undefined&&raw!==null&&String(raw).trim()!==''&&Number.isFinite(Number(raw))&&Number(raw)>=0;
|
||
const price=hasPrice?Number(raw):(window.CateriumPricing?.price(item)??number(item.price));
|
||
const grams=window.CateriumBanquet?.grams?.(item)??Math.round((parseFloat(clean(item.weight).replace(',','.'))||0)*(/кг/i.test(clean(item.weight))?1000:1));
|
||
return {id:String(item.id),name:clean(item.name)||'Без названия',group:section(item),weight:clean(item.weight),grams:Math.max(0,number(grams)),cents:Math.round(Math.max(0,number(price))*100),estimated:item.banquet?.estimated===true};
|
||
});
|
||
const guests=Math.min(10000,Math.max(1,Math.round(number(draft.guestsCount)||1))),cents=items.reduce((n,i)=>n+i.cents,0);
|
||
if(!Number.isSafeInteger(cents*guests))throw new Error('Стоимость меню слишком велика для точного расчёта. Проверьте цены.');
|
||
const sourceBrand=input?.brand||window.CateriumBranding?.identity?.()||{};
|
||
return {items,guests,cents,totalCents:cents*guests,grams:items.reduce((n,i)=>n+i.grams,0),event:clean(input?.event??draft.event),date:clean(input?.date??draft.date),brand:{name:clean(sourceBrand.name)||'Моя компания',logo:clean(sourceBrand.logo),contacts:clean(sourceBrand.contacts)},estimated:items.some(i=>i.estimated)};
|
||
}
|
||
async function fonts(){
|
||
if(!fontsPromise)fontsPromise=Promise.all([['Caterium Menu Sans','Manrope.ttf','200 800'],['Caterium Menu Serif','PlayfairDisplay.ttf','400 900']].map(async([name,file,weight])=>{
|
||
if(!window.FontFace||!document.fonts)return;
|
||
let timer;try{const f=new FontFace(name,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});await Promise.race([f.load(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('font timeout')),6000);})]);document.fonts.add(f);}catch(_){/* Measured system-font fallback; no late font swap. */}finally{clearTimeout(timer);}
|
||
}));return fontsPromise;
|
||
}
|
||
function engine(){
|
||
if(window.SunPdfEngine)return Promise.resolve(window.SunPdfEngine);
|
||
if(enginePromise)return enginePromise;
|
||
enginePromise=new Promise((resolve,reject)=>{
|
||
const script=document.createElement('script');let timer;
|
||
const fail=()=>{clearTimeout(timer);script.remove();enginePromise=null;reject(new Error('Не удалось загрузить экспорт PDF. Проверьте интернет и повторите.'));};
|
||
script.src=new URL('core/pdf-engine.js?v=20260920-onepage',document.baseURI).href;
|
||
script.onload=()=>{clearTimeout(timer);window.SunPdfEngine?resolve(window.SunPdfEngine):fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
|
||
});return enginePromise;
|
||
}
|
||
async function logoImage(src){
|
||
if(!/^data:image\/(png|jpe?g|webp);base64,/i.test(src)||src.length>2000000)return null;
|
||
return new Promise(resolve=>{const i=new Image();let done=false;const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v);};const timer=setTimeout(()=>finish(null),3500);i.onload=()=>{try{resolvePrepared();}catch(_){finish(null);}};function resolvePrepared(){finish(window.CateriumBranding?.prepareLogoImage?.(i)||i);}i.onerror=()=>finish(null);i.src=src;});
|
||
}
|
||
function wrap(ctx,value,width){
|
||
const lines=[];let line='';
|
||
for(const para of clean(value).split(/\r?\n/)){
|
||
for(const word of para.split(/\s+/).filter(Boolean)){
|
||
if(ctx.measureText((line?line+' ':'')+word).width<=width){line+=(line?' ':'')+word;continue;}
|
||
if(line){lines.push(line);line='';}
|
||
for(const ch of word){if(line&&ctx.measureText(line+ch).width>width){lines.push(line);line='';}line+=ch;}
|
||
}
|
||
if(line){lines.push(line);line='';}
|
||
}
|
||
return lines;
|
||
}
|
||
function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});}
|
||
function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;}
|
||
function plan(ctx,s,top,bottom){
|
||
const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id));
|
||
// Keep every dish and every character. Never crop, use ellipses or make a
|
||
// second page. A genuinely oversized menu gets a clear, recoverable error.
|
||
// Per-item prices are never shown to the client — only the per-guest total in the footer.
|
||
for(let font=24;font>=17;font--){
|
||
const choices=ordered.length<=8?[1,2]:[2,1];
|
||
for(const columns of choices){
|
||
const width=(W-M*2-(columns-1)*42)/columns;
|
||
ctx.font=`${font}px ${SANS}`;
|
||
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].filter(Boolean).join(' · ')}));
|
||
for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;}
|
||
const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};};
|
||
let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]);
|
||
candidates=candidates.filter(cols=>cols.every(c=>c.height<=bottom-top));
|
||
if(candidates.length){candidates.sort((a,b)=>Math.max(...a.map(c=>c.height))-Math.max(...b.map(c=>c.height)));return {font,width,columns:candidates[0],top,bottom};}
|
||
}
|
||
}
|
||
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
|
||
}
|
||
async function render(s,{showPrices=true,themeId='gold'}={}){
|
||
const theme=THEMES[themeId]||THEMES.gold;
|
||
await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]);
|
||
const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2;
|
||
const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top';
|
||
const drawnText=[],{ink,muted,accent:gold,paper,rule}=theme,centered=theme.titleAlign==='center';
|
||
const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],align==='center'?x+width/2:x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
|
||
const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();};
|
||
c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48);
|
||
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left');
|
||
let headerBottom=Math.max(112,58+brandHeight);
|
||
if(logo&&!centered){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);}
|
||
line(M,headerBottom+14,W-M,gold);
|
||
let y=headerBottom+42;
|
||
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14;
|
||
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10;
|
||
const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · ');
|
||
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23,centered?'center':'left')+24;
|
||
line(M,y,W-M);const top=y+20;
|
||
c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19;
|
||
const footerHeight=(showPrices?108:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
|
||
const layout=plan(c,s,top,bottom);
|
||
for(let j=0;j<layout.columns.length;j++){
|
||
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
|
||
for(const row of col.entries){
|
||
if(row.heading){text(row.heading.toLocaleUpperCase('ru-RU'),x,yy+5,layout.width,`700 14px ${SANS}`,gold,18);yy+=40;}
|
||
yy+=text(row.item.name,x,yy,layout.width,`${layout.font}px ${SANS}`,ink,layout.font*1.3);
|
||
if(row.meta)yy+=text(row.meta,x,yy+4,layout.width,`${Math.max(13,layout.font-5)}px ${SANS}`,muted,layout.font-2);
|
||
yy+=10;
|
||
}
|
||
}
|
||
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
|
||
// Only the per-guest price is ever shown to the client — never a per-item price
|
||
// and never the grand total, so the menu can't be read as a full price list.
|
||
if(showPrices){
|
||
text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24;
|
||
let size=34;c.font=`600 ${size}px ${SERIF}`;const v=money(s.cents/100);while(size>16&&c.measureText(v).width>W-M*2)c.font=`600 ${--size}px ${SERIF}`;
|
||
text(v,M,fy,W-M*2,c.font,ink,size+4,centered?'center':'left');fy+=size+16;
|
||
text('За гостя, только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18,centered?'center':'left');fy+=24;
|
||
}else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20,centered?'center':'left');fy+=26;}
|
||
if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;}
|
||
if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19);
|
||
const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96));
|
||
const blob=pdf.fromJpegs([{width:canvas.width,height:canvas.height,bytes:new Uint8Array(await jpeg.arrayBuffer())}]);
|
||
return {canvas,jpeg,blob,layout,drawnText};
|
||
}
|
||
function close(){if(!panel)return;const old=panel;panel=null;old.generation++;if(old.url)URL.revokeObjectURL(old.url);old.dialog.remove();if(old.restore?.isConnected)old.restore.focus({preventScroll:true});}
|
||
function style(){
|
||
if(document.getElementById('ctBanquetClientStyle'))return;
|
||
const el=document.createElement('style');el.id='ctBanquetClientStyle';el.textContent=`
|
||
#ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box}
|
||
#ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4}
|
||
#ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0}
|
||
#ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog select{min-height:34px;padding:5px 8px;border-radius:8px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30}
|
||
#ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002}
|
||
#ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}}
|
||
`;document.head.append(el);
|
||
}
|
||
async function open(input){
|
||
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
|
||
const s=snapshot(input),started=scope();close();style();
|
||
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
|
||
const themeOptions=themeIds.map(id=>`<option value="${id}">${THEMES[id].label}</option>`).join('');
|
||
dialog.innerHTML=`<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label>Оформление<select data-bcm-theme>${themeOptions}</select></label><label><input type="checkbox" data-bcm-prices checked>Показывать цену за гостя</label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>`;
|
||
const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state;
|
||
const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),check=get('[data-bcm-prices]'),themeSelect=get('[data-bcm-theme]');
|
||
document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal();
|
||
const valid=()=>panel===state&&scope()===started&&allowed();
|
||
const build=async()=>{
|
||
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
|
||
try{const result=await render(s,{showPrices:check.checked,themeId:themeSelect.value});if(!valid()||ticket!==state.generation)return;
|
||
if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result;
|
||
const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img);
|
||
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. ${check.checked?'Показана только цена за гостя.':'Без указания цены.'}`;download.disabled=false;
|
||
const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false;
|
||
}catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}}
|
||
};
|
||
check.onchange=build;themeSelect.onchange=build;
|
||
download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);};
|
||
share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}};
|
||
await build();return state.result;
|
||
}
|
||
window.addEventListener('sun:cloud-tenant-changing',close);
|
||
window.addEventListener('sun:cloud-permissions-changed',()=>{if(panel&&!allowed())close();});
|
||
window.addEventListener('sun:subscription-changed',()=>{if(panel&&!allowed())close();});
|
||
window.CateriumBanquetClientMenu=Object.freeze({snapshot,render,open,close});
|
||
})();
|