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.
This commit is contained in:
parent
eab678adbe
commit
bc3f186992
4
.github/workflows/deploy-timeweb.yml
vendored
4
.github/workflows/deploy-timeweb.yml
vendored
@ -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
|
||||
|
||||
23
docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md
Normal file
23
docs/releases/2026-09-20-BANQUET-CLIENT-MENU.md
Normal file
@ -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.
|
||||
178
public/core/banquet-client-menu.js
Normal file
178
public/core/banquet-client-menu.js
Normal file
@ -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<lines.length;i++){c.fillText(lines[i],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);
|
||||
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<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;
|
||||
if(showPrices){
|
||||
text('НА ОДНОГО ГОСТЯ',M,fy,380,`700 12px ${SANS}`,muted,17);
|
||||
text(`МЕНЮ НА ${s.guests.toLocaleString('ru-RU')} ГОСТЕЙ`,W/2+20,fy,416,`700 12px ${SANS}`,muted,17);fy+=24;
|
||||
const fitMoney=(v,x)=>{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='<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><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]');
|
||||
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});
|
||||
})();
|
||||
@ -30,7 +30,7 @@
|
||||
${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''}
|
||||
<div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div>
|
||||
<div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}${window.CateriumPricing?.badge(i)||''}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label>${i.demo&&i.ttk?`<button type="button" data-banquet-ttk="${esc(i.id)}" aria-label="ТТК: ${esc(i.name)}" title="Открыть технологическую карту">ТТК</button>`:''}<button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div>
|
||||
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
|
||||
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button><button class="outline" type="button" data-banquet-client-menu ${selected.length?'':'disabled'}>Меню для клиента</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
|
||||
}
|
||||
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}
|
||||
|
||||
@ -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',
|
||||
|
||||
81
tests/banquet-client-menu.spec.mjs
Normal file
81
tests/banquet-client-menu.spec.mjs
Normal file
@ -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);
|
||||
});
|
||||
@ -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}}
|
||||
]
|
||||
|
||||
37
tests/production-banquet-client-menu.mjs
Normal file
37
tests/production-banquet-client-menu.mjs
Normal file
@ -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();}
|
||||
Loading…
Reference in New Issue
Block a user