diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index fafc8f4..118c2df 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -52,6 +52,7 @@ jobs: core/ops-ux-v1762.js core/mobile-order.js core/banquet-menu.js + core/banquet-client-menu.js core/data-layer-v1773.js core/trial-demo.js core/training-catalog.js @@ -120,6 +121,9 @@ jobs: - name: Check the published optional training catalog timeout-minutes: 4 run: node tests/production-training-catalog.mjs + - name: Check the published single-page banquet menu + timeout-minutes: 4 + run: node tests/production-banquet-client-menu.mjs - name: Save production UI verification if: always() uses: actions/upload-artifact@v4 diff --git a/docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md b/docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md new file mode 100644 index 0000000..b054df5 --- /dev/null +++ b/docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md @@ -0,0 +1,23 @@ +# One-page banquet menu for the client + +Banquet menu → select individual dishes or choose a ready menu → Меню для клиента. +The preview and downloaded PDF use the same single A4 portrait canvas, with the +company document name/logo/contacts, event date, guests, and selected dishes grouped +by course. Costs can be shown or hidden. Sharing uses the system file share sheet +where supported; downloading a PDF is always available after rendering. + +The document reads all selected dishes, not just the current search/group/package +filter. Existing line price snapshots (including free lines) take precedence over +current catalog prices. Otherwise active catalog promotions apply with kopecks. +Pricing covers only selected menu dishes, not the order's delivery, services or +order-wide discount. The calculation is explicitly labelled in the document. + +Opening/exporting never applies the selection to an order, writes local storage or +saves a draft. A profile/company switch closes the preview and invalidates an +unfinished render. Existing catalog access and client-offer subscription limits +are respected. + +Layout measures Cyrillic text after bounded font loading, balances one/two columns +and adjusts type size down to a legible minimum. Names are never truncated and +no dish is silently omitted. A menu too large for a readable single page produces +a clear error rather than an extra page or tiny/clipped text. diff --git a/public/core/banquet-client-menu.js b/public/core/banquet-client-menu.js new file mode 100644 index 0000000..39d148f --- /dev/null +++ b/public/core/banquet-client-menu.js @@ -0,0 +1,178 @@ +/* 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 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,showPrices,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. + 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,showPrices?money(item.cents/100):''].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}={}){ + 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='#243b30',muted='#657269',gold='#9b793e',paper='#fbf8f1',rule='#dcd5c6'; + 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{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); + let headerBottom=Math.max(112,58+brandHeight); + if(logo){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)+14; + if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27)+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)+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?142:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight; + const layout=plan(c,s,showPrices,top,bottom); + for(let j=0;j{let size=30;c.font=`600 ${size}px ${SERIF}`;while(size>14&&c.measureText(v).width>400)c.font=`600 ${--size}px ${SERIF}`;text(v,x,fy,400,c.font,ink,size+4);}; + fitMoney(money(s.cents/100),M);fitMoney(money(s.totalCents/100),W/2+20);fy+=45; + text('Только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=24; + }else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20);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 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'); + dialog.innerHTML='

Меню для клиента

Оформляю меню на одной странице…

'; + 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]'); + 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});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; + 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}); +})(); diff --git a/public/core/banquet-menu.js b/public/core/banquet-menu.js index a654c57..cd2b57b 100644 --- a/public/core/banquet-menu.js +++ b/public/core/banquet-menu.js @@ -30,7 +30,7 @@ ${menu?`
${esc(menu.name)} · горячее — одно блюдо на выбор
`:''}
${available.map(([id,label])=>``).join('')}
${visible.length?[...sections].map(([section,rows])=>`

${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'Одно блюдо на выбор в каждом пакете':''}

${rows.map(i=>`
${i.demo&&i.ttk?``:''}
`).join('')}
`).join(''):`

${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}

`}
-
`; + `; } function bind(root,context){ const {catalog,draft,rerender}=context; @@ -39,6 +39,22 @@ root.querySelectorAll('[data-banquet-item]').forEach(b=>b.onchange=()=>{toggle(draft,catalog,b.dataset.banquetItem);rerender()}); root.querySelectorAll('[data-banquet-ttk]').forEach(b=>b.onclick=()=>window.CateriumTrialDemo?.showTTK(b.dataset.banquetTtk)); root.querySelectorAll('[data-banquet-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit)); + const clientMenu=root.querySelector('[data-banquet-client-menu]'); + clientMenu.onclick=async()=>{ + const who=JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]); + const input=JSON.parse(JSON.stringify({catalog,draft,event:document.getElementById('event')?.value??draft.event,date:document.getElementById('date')?.value??draft.date})); + clientMenu.disabled=true; + try{ + if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{ + const script=document.createElement('script');let timer; + const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));}; + script.src='core/banquet-client-menu.js?v=20260920-onepage';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script); + }); + if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return; + await window.CateriumBanquetClientMenu.open(input); + }catch(error){if(root.isConnected)window.alert(error.message);} + finally{if(clientMenu.isConnected)clientMenu.disabled=false;} + }; const preset=root.querySelector('[data-banquet-preset]');if(preset)preset.onclick=()=>{chooseMenu(draft,catalog,preset.dataset.banquetPreset);rerender()}; const clear=root.querySelector('[data-banquet-clear]');if(clear)clear.onclick=()=>{draft.banquetSelection=[];rerender()}; root.querySelector('[data-banquet-add]').onclick=()=>window.editBox?.(null); @@ -58,6 +74,7 @@ } function saveEditor(item,catalog){if(Number(item.category)!==6)return;const input=document.getElementById('ctBanquetPackage');if(!input)return;const name=input.value.trim(),known=packages(catalog).find(m=>m.name===name),same=item.banquet?.packageName===name;item.banquet={...(same?item.banquet:{}),packageId:name?(known?.id||'custom-'+name.toLowerCase().replace(/\s+/g,'-')):'',packageName:name,estimated:document.getElementById('ctBanquetEstimated')?.checked===true};if(known){item.banquet.packagePrice=known.price;item.banquet.packageGrams=known.grams}} const style=document.createElement('style');style.textContent=` + .ct-banquet-summary [data-banquet-client-menu]{width:100%;margin-top:10px;font-size:12px;min-height:42px} #tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-dish>[data-banquet-ttk]{font-size:10px;font-weight:700}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none} @container (min-width:760px){.ct-banquet-layout{grid-template-columns:minmax(0,1fr) 230px}.ct-banquet-summary{position:sticky;top:12px}} #new.sun-banquet-active .catalog{container-type:inline-size;width:100%;min-width:0} diff --git a/public/service-worker.js b/public/service-worker.js index a0bf174..ded00b9 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,7 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage'; const VERSION='20260918-ui-stability'; const CORE=[ + './core/banquet-client-menu.js?v=20260920-onepage', './core/training-catalog.js?v=20260920-training', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './vendor/supabase-2.112.4.min.js', @@ -13,6 +14,7 @@ const CORE=[ './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' ]; const CRITICAL_FRESH=new Set([ + '/core/banquet-client-menu.js', '/core/training-catalog.js', '/core/catalog-pricing.js','/core/client-menu.css', '/core/help-center.js','/core/help-center.css','/help/knowledge-v1.json', diff --git a/tests/banquet-client-menu.spec.mjs b/tests/banquet-client-menu.spec.mjs new file mode 100644 index 0000000..aaa0ebb --- /dev/null +++ b/tests/banquet-client-menu.spec.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import {test,expect} from '@playwright/test'; +import {trainingInit} from './training-catalog-fixture.mjs'; +const source=JSON.parse(fs.readFileSync('public/demo/banquet-v1.json','utf8')).banquet; +const menu=source.map((x,i)=>({...x,id:`menu-${i}`,demo:false,banquet:{packageId:'client-set',packageName:'Праздничное меню',...(i===9||i===10?{choiceGroup:'main',defaultChoice:i===9}:{})}})); +async function boot(page){ + page.on('dialog',d=>d.accept()); + await page.route('**/*',r=>{const u=new URL(r.request().url());return r.request().method()==='GET'&&u.origin==='http://127.0.0.1:4173'&&!u.pathname.startsWith('/api/')?r.continue():r.abort();}); + await page.addInitScript(trainingInit); + await page.addInitScript(items=>{localStorage.setItem('sunBoxes',JSON.stringify(items));localStorage.setItem('sunPdfBrandNameV1','Студия вкуса');localStorage.setItem('sunCompanyProfileV1',JSON.stringify({name:'Студия вкуса',phone:'+7 900 000-00-00',email:'hello@example.invalid'}));},menu); + await page.goto('/index.html',{waitUntil:'domcontentloaded'}); + await expect(page.locator('body > header')).toBeVisible(); + await page.waitForFunction(()=>window.CateriumBanquet&&window.SunOpsUXV1762&&window.CateriumDataV1773); + await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); + await page.getByRole('button',{name:'Банкетное меню',exact:true}).click(); +} +async function exportMenu(page){ + await page.locator('[data-banquet-client-menu]').click(); + await expect(page.locator('#ctBanquetClientDialog')).toBeVisible(); + await expect(page.locator('[data-bcm-download]')).toBeEnabled({timeout:20000}); +} +test.use({serviceWorkers:'block'}); + +test('ready-menu selection makes exactly one A4 PDF and never saves or applies the draft',async({page},info)=>{ + test.setTimeout(60000);await boot(page); + await expect(page.locator('[data-banquet-client-menu]')).toBeDisabled(); + await page.locator('[data-banquet-package="client-set"]').click();await page.locator('[data-banquet-preset]').click(); + await page.locator('[data-banquet-guests]').fill('20'); + const before=await page.evaluate(()=>({draft:JSON.stringify(draft),orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})); + await exportMenu(page); + await expect(page.locator('[data-bcm-status]')).toContainText('одна страница A4'); + const dl=page.waitForEvent('download');await page.locator('[data-bcm-download]').click();const download=await dl; + const path=info.outputPath('banquet-client-menu.pdf');await download.saveAs(path); + const bytes=fs.readFileSync(path);expect(bytes.subarray(0,5).toString()).toBe('%PDF-');expect(bytes.toString('latin1')).toContain('/Count 1');expect(bytes.toString('latin1')).toContain('/MediaBox [0 0 595.28 841.89]'); + await page.screenshot({path:info.outputPath('banquet-client-preview.png')}); + await page.locator('[data-bcm-prices]').uncheck();await expect(page.locator('[data-bcm-download]')).toBeEnabled();await expect(page.locator('[data-bcm-status]')).toContainText('Без указания стоимости'); + await page.locator('[data-bcm-close]').click(); + expect(await page.evaluate(()=>({draft:JSON.stringify(draft),orders:localStorage.sunOrders,boxes:localStorage.sunBoxes}))).toEqual(before); +}); + +test('custom selection includes all selected sections, not just the current package or group filter',async({page})=>{ + await boot(page); + for(const id of ['menu-0','menu-5','menu-9','menu-14'])await page.locator(`[data-banquet-item="${id}"]`).check(); + await page.locator('[data-banquet-group="salads"]').click(); + await exportMenu(page); + const data=await page.evaluate(()=>CateriumBanquetClientMenu.snapshot({catalog:boxes,draft})); + expect(data.items.map(i=>i.id)).toEqual(['menu-0','menu-5','menu-9','menu-14']); + const rendered=await page.evaluate(async()=>{const s=CateriumBanquetClientMenu.snapshot({catalog:boxes,draft});const r=await CateriumBanquetClientMenu.render(s,{showPrices:false});return {text:r.drawnText,heights:r.layout.columns.map(c=>c.height),room:r.layout.bottom-r.layout.top};}); + for(const item of data.items)expect(rendered.text.join(' ')).toContain(item.name); + expect(rendered.text.join(' ')).not.toContain('₽');expect(rendered.heights.every(h=>h<=rendered.room)).toBe(true); +}); + +test('saved line prices including zero and promotion kopecks are preserved; a cleared selection is not repopulated',async({page})=>{ + await boot(page);await page.addScriptTag({url:'/core/banquet-client-menu.js'}); + const result=await page.evaluate(()=>{ + const catalog=[{id:'free',name:'Подарок',category:6,price:100},{id:'discount',name:'Блюдо',category:6,price:999,promotion:{type:'percent',value:12.5}}]; + const d={guestsCount:3,banquetSelection:['free','discount'],lines:[{id:'free',price:0}]};const before=JSON.stringify(d); + const s=CateriumBanquetClientMenu.snapshot({catalog,draft:d});let empty=false;try{CateriumBanquetClientMenu.snapshot({catalog,draft:{...d,banquetSelection:[]}});}catch(_){empty=true;} + return {prices:s.items.map(i=>i.cents),total:s.totalCents,unchanged:JSON.stringify(d)===before,empty}; + }); + expect(result).toEqual({prices:[0,87413],total:262239,unchanged:true,empty:true}); +}); + +test('long names are fully wrapped on one page, oversized menus fail rather than silently truncating dishes',async({page},info)=>{ + test.setTimeout(60000);await boot(page);await page.addScriptTag({url:'/core/banquet-client-menu.js'}); + const result=await page.evaluate(async()=>{ + const catalog=Array.from({length:12},(_,i)=>({id:String(i),category:6,catalogSection:i<6?'Холодные закуски':'Горячие блюда',name:`Позиция ${i+1}: очень длинное название блюда с подробным описанием подачи и праздничного оформления`,price:400.25,weight:'150 г'})); + const d={guestsCount:20,banquetSelection:catalog.map(x=>x.id)}; + const s=CateriumBanquetClientMenu.snapshot({catalog,draft:d,brand:{name:'Тестовая компания'}}),r=await CateriumBanquetClientMenu.render(s); + let tooLarge='';try{const huge=catalog.map(x=>({...x,name:(x.name+' ').repeat(30)}));await CateriumBanquetClientMenu.render(CateriumBanquetClientMenu.snapshot({catalog:huge,draft:d}));}catch(e){tooLarge=e.message;} + return {names:s.items.map(x=>x.name),text:r.drawnText.join(' '),fits:r.layout.columns.every(c=>c.height<=r.layout.bottom-r.layout.top),tooLarge,png:r.canvas.toDataURL('image/png')}; + }); + for(const name of result.names)expect(result.text).toContain(name);expect(result.fits).toBe(true);expect(result.tooLarge).toContain('не помещается'); + fs.writeFileSync(info.outputPath('long-menu.png'),Buffer.from(result.png.split(',')[1],'base64')); +}); + +test('switching companies closes the preview and disabled client-offer access is respected',async({page})=>{ + await boot(page);await page.locator('[data-banquet-item="menu-0"]').check();await exportMenu(page); + await page.evaluate(()=>window.dispatchEvent(new Event('sun:cloud-tenant-changing')));await expect(page.locator('#ctBanquetClientDialog')).toHaveCount(0); + const denied=await page.evaluate(async()=>{window.SunSaaSV16.hasFeature=()=>false;try{await CateriumBanquetClientMenu.open({catalog:boxes,draft});return false;}catch(_){return true;}});expect(denied).toBe(true); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 9c41f50..4fbb891 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-banquet-client-menu.mjs b/tests/production-banquet-client-menu.mjs new file mode 100644 index 0000000..9312bd5 --- /dev/null +++ b/tests/production-banquet-client-menu.mjs @@ -0,0 +1,37 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {chromium,expect} from '@playwright/test'; +import {trainingInit} from './training-catalog-fixture.mjs'; +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +if(base.protocol!=='https:')throw new Error('Production verification requires HTTPS'); +const items=JSON.parse(await fs.readFile('public/demo/banquet-v1.json','utf8')).banquet.map((x,i)=>({...x,id:`onepage-${i}`,demo:false,banquet:{packageId:'test-set',packageName:'Праздничное меню'}})); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const browser=await chromium.launch(),results=[]; +try{ + for(const width of [390,1440]){ + const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'}); + try{ + await context.route('**/*',r=>{const u=new URL(r.request().url());return r.request().method()==='GET'&&u.origin===base.origin&&!u.pathname.startsWith('/api/')?r.continue():r.abort();}); + await context.addInitScript(trainingInit); + await context.addInitScript(menu=>{localStorage.setItem('sunBoxes',JSON.stringify(menu));localStorage.setItem('sunPdfBrandNameV1','Студия вкуса');},items); + const page=await context.newPage();page.on('dialog',d=>d.accept()); + await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000});await expect(page.locator('body > header')).toBeVisible({timeout:20000}); + await page.waitForFunction(()=>window.CateriumBanquet&&window.SunOpsUXV1762&&window.CateriumDataV1773); + await page.locator('header nav').getByRole('button',{name:'Новый заказ',exact:true}).click(); + await page.getByRole('button',{name:'Банкетное меню',exact:true}).click(); + await page.locator('[data-banquet-package="test-set"]').click();await page.locator('[data-banquet-preset]').click(); + await page.locator('[data-banquet-guests]').fill('20'); + const before=await page.evaluate(()=>({draft:JSON.stringify(draft),orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})); + await page.locator('[data-banquet-client-menu]').click(); + await expect(page.locator('[data-bcm-download]')).toBeEnabled({timeout:20000}); + await page.screenshot({path:`${output}/banquet-client-menu-${width}.png`}); + const promise=page.waitForEvent('download');await page.locator('[data-bcm-download]').click();const d=await promise; + const file=`${output}/banquet-client-menu-${width}.pdf`;await d.saveAs(file); + const bytes=await fs.readFile(file);assert.equal(bytes.subarray(0,5).toString(),'%PDF-');assert(bytes.toString('latin1').includes('/Count 1')); + await page.locator('[data-bcm-prices]').uncheck();await expect(page.locator('[data-bcm-status]')).toContainText('Без указания стоимости'); + await page.locator('[data-bcm-close]').click();assert.deepEqual(await page.evaluate(()=>({draft:JSON.stringify(draft),orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})),before); + results.push({width,selectedDishes:items.length,singlePage:true,hidePrices:true,noWrites:true});console.log(`PASS published single-page banquet menu ${width}px`); + }finally{await context.close();} + } + await fs.writeFile(`${output}/banquet-client-menu-result.json`,JSON.stringify({base:base.href,backend:'synthetic, network-blocked',results},null,2)); +}finally{await browser.close();}