From bc3f1869922ed98a8496190947997263f35200a6 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Sun, 20 Sep 2026 12:54:42 +0300 Subject: [PATCH] One-page banquet menu for the client (#36) Add a single-page A4 preview and PDF for all selected banquet dishes or a ready-menu selection, with company branding and optional costs. Preserve draft/order state and saved line prices, enforce access and tenant scope, and fail clearly rather than clipping oversized menus. Nineteen targeted browser scenarios passed twice and the generated iPhone PDF was visually checked. Full main QA must pass before the existing automatic production promotion; production verification includes the new renderer and real-asset client menu export. --- .github/workflows/deploy-timeweb.yml | 4 + .../2026-09-20-BANQUET-CLIENT-MENU.md | 23 +++ public/core/banquet-client-menu.js | 178 ++++++++++++++++++ public/core/banquet-menu.js | 19 +- public/service-worker.js | 4 +- tests/banquet-client-menu.spec.mjs | 81 ++++++++ tests/playwright.config.mjs | 4 +- tests/production-banquet-client-menu.mjs | 37 ++++ 8 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md create mode 100644 public/core/banquet-client-menu.js create mode 100644 tests/banquet-client-menu.spec.mjs create mode 100644 tests/production-banquet-client-menu.mjs 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();}