feat: give cream-elegance and neon-menu their own matching inner pages

The cover for these 2 templates was fully custom, but the item-list
and pricing pages behind it still came from the old generic base
renderer (Arial, different card style, different page chrome) — so a
downloaded offer read as a nice cover stapled to a plain, differently
styled document ("два предложения").

Both templates now render their own item-list pages (photo, name,
category/weight, qty and line total, paginated ~10 rows/page) and a
matching pricing-breakdown page (base cost, discount, promo, delivery,
total, per-guest), using the same fonts, palette and card language as
the cover, with a shared footer/page-number treatment across every
page. The other 4 signature templates and the 13 classic ones are
untouched and keep using the original base renderer.

Verified locally: 8 items -> 3 pages (cover + 1 menu + pricing),
15 items -> 4 pages (cover + 2 menu + pricing), 0 items -> 3 pages,
no errors, base renderer confirmed never invoked for these 2 ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pavlov346346-source 2026-09-16 09:58:07 +03:00
parent c0bb18d5f7
commit 51e2fa4c33

View File

@ -151,14 +151,82 @@
}
function correctPageNumbers(pages,start,total,innerId){const foot=(PALETTES[innerId]||{}).foot||'#fff';pages.forEach((canvas,i)=>{try{const scale=canvas.width/W,ctx=canvas.getContext('2d');ctx.save();ctx.setTransform(scale,0,0,scale,0,0);ctx.fillStyle=foot;ctx.fillRect(W-190,H-50,150,40);text(ctx,String(start+i)+' / '+String(total),W-M,H-34,100,16,{font:'10px Arial',color:'#888',align:'right',maxLines:1});ctx.restore()}catch(_){}})}
// ---- Fully-styled inner pages for the 2 polished templates, so the whole document reads as one design instead of a nice cover glued to the generic base pages. ----
function newPage(p,dark){const canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);const ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=p.bg;ctx.fillRect(0,0,W,H);if(dark){ctx.save();roundRect(ctx,20,20,W-40,H-40,0,null,p.accent,1.4);ctx.restore()}return{canvas,ctx}}
function contentHeader(ctx,p,f,s,label,dark){text(ctx,String(s.brandName||'Солнце Кейтеринг').toUpperCase(),M,50,320,16,{font:`700 11px ${f.label}`,color:p.accent,maxLines:1});text(ctx,eventTitle(s),M,70,520,26,{font:`600 20px ${f.display}`,color:dark?'#fff':p.ink,maxLines:1});text(ctx,label,W-M,58,260,16,{font:`600 11px ${f.label}`,color:p.muted,align:'right',maxLines:1});line(ctx,M,108,W-M,108,p.line,1)}
async function itemRowsPage(ctx,p,f,items,y0,dark){
const rowH=dark?96:104,gap=dark?12:14;
const imgs=await Promise.all(items.map(it=>loadImage(it.photoData||'')));
items.forEach((item,i)=>{
const yy=y0+i*(rowH+gap),photoSize=rowH-20,px=M+10,py=yy+10,textX=px+photoSize+18,sum=Number(item.sum??(Number(item.unitPrice||0)*Number(item.qty||0)));
roundRect(ctx,M,yy,W-2*M,rowH,16,dark?'rgba(255,255,255,.04)':p.paper,dark?'rgba(255,255,255,.12)':p.line);
roundRect(ctx,px,py,photoSize,photoSize,12,p.deep,null);
if(imgs[i])coverImage(ctx,imgs[i],px,py,photoSize,photoSize,12);
text(ctx,item.name||'Позиция меню',textX,yy+14,W-2*M-(textX-M)-135,22,{font:`600 16px ${f.display}`,color:dark?'#fff':p.ink,maxLines:1});
const meta=[item.weight?String(item.weight):'',item.categoryName?String(item.categoryName):''].filter(Boolean).join(' · ');
if(meta)text(ctx,meta,textX,yy+42,W-2*M-(textX-M)-135,16,{font:`500 12px ${f.label}`,color:p.muted,maxLines:1});
text(ctx,'× '+Math.round(Number(item.qty||1)),W-M-16,yy+16,110,18,{font:`700 13px ${f.label}`,color:p.accent,align:'right',maxLines:1});
text(ctx,money(sum),W-M-16,yy+rowH-36,110,18,{font:`700 16px ${f.label}`,color:dark?'#fff':p.deep,align:'right',maxLines:1});
});
}
function pricingPage(ctx,p,f,s,dark){
text(ctx,'ИТОГОВАЯ СМЕТА',M,132,W-2*M,34,{font:`600 28px ${f.display}`,color:dark?'#fff':p.deep,maxLines:1});
const pr=s.pricing||{};let y=200;
const rows=[['Стоимость позиций',Number(pr.base||0),false]];
if(Number(pr.manual||0)>0)rows.push(['Скидка',Number(pr.manual),true]);
if(Number(pr.promo||0)>0)rows.push(['Промокод'+(s.promoCode?` «${s.promoCode}»`:''),Number(pr.promo),true]);
rows.push(['Меню после скидок',Number(pr.itemsTotal||0),false]);
rows.push(['Доставка',Number(pr.delivery||0),false]);
rows.forEach(([label,val,negative])=>{
roundRect(ctx,M,y,W-2*M,52,14,dark?'rgba(255,255,255,.04)':p.paper,dark?'rgba(255,255,255,.12)':p.line);
text(ctx,label,M+20,y+17,520,18,{font:`500 14px ${f.label}`,color:dark?'#eef2ee':p.ink,maxLines:1});
text(ctx,(negative?' ':'')+money(val),W-M-20,y+17,260,18,{font:`700 16px ${f.label}`,color:negative?'#c0554a':(dark?'#fff':p.deep),align:'right',maxLines:1});
y+=52+10;
});
y+=8;
roundRect(ctx,M,y,W-2*M,160,20,p.accent,null);
text(ctx,'ИТОГО К ОПЛАТЕ',M+28,y+24,400,16,{font:`700 12px ${f.label}`,color:p.deep,maxLines:1});
text(ctx,money(pr.total),M+28,y+48,W-2*M-56,56,{font:`700 44px ${f.display}`,color:p.deep,maxLines:1});
if(s.guests)text(ctx,'на гостя: '+money(Number(pr.itemsTotal||pr.total||0)/Math.max(1,Number(s.guests))),M+28,y+118,400,20,{font:`600 14px ${f.label}`,color:p.deep,maxLines:1});
y+=160+36;
text(ctx,'Спасибо, что выбираете нас — будем рады сделать ваше событие особенным.',M,y,W-2*M,22,{font:`500 15px ${f.label}`,color:p.muted,maxLines:2});
}
async function renderContentPages(s,id){
await ensureFontsReady(id);
const p=PALETTES[id],f=fontsFor(id),dark=id==='neon-menu';
const items=s.items||[],top=120,bottom=H-90,rowH=dark?96:104,gap=dark?12:14;
const perPage=Math.max(1,Math.floor((bottom-top+gap)/(rowH+gap)));
const pageCount=Math.max(1,Math.ceil(items.length/perPage));
const pages=[];
for(let pi=0;pi<pageCount;pi++){
const {canvas,ctx}=newPage(p,dark);
contentHeader(ctx,p,f,s,`Меню · стр. ${pi+1} из ${pageCount}`,dark);
await itemRowsPage(ctx,p,f,items.slice(pi*perPage,(pi+1)*perPage),top,dark);
pages.push(canvas);
}
const priced=newPage(p,dark);
contentHeader(priced.ctx,p,f,s,'Стоимость',dark);
pricingPage(priced.ctx,p,f,s,dark);
pages.push(priced.canvas);
return pages;
}
async function renderPages(snapshot,baseRenderer){
if(typeof baseRenderer!=='function')throw new Error('Base PDF renderer is unavailable');
const id=String(snapshot?.offerTemplateId||'');
if(!SIGNATURE_SET.has(id))return baseRenderer(snapshot);
const selfContained=id==='cream-elegance'||id==='neon-menu';
const innerId=INNER_THEME[id]||'light',inner={...snapshot,offerTemplateId:innerId};
const [cover,basePages]=await Promise.all([renderCover(snapshot,id),baseRenderer(inner)]),pages=[cover,...(basePages||[])],total=pages.length;
try{const ctx=cover.getContext('2d'),p=PALETTES[id]||PALETTES['cream-elegance'];ctx.save();ctx.setTransform(SCALE,0,0,SCALE,0,0);footer(ctx,p,1,total,snapshot?.brandName,fontsFor(id));ctx.restore()}catch(_){}
correctPageNumbers(basePages||[],2,total,innerId);
const [cover,innerPages]=await Promise.all([renderCover(snapshot,id),selfContained?renderContentPages(snapshot,id):baseRenderer(inner)]);
const pages=[cover,...(innerPages||[])],total=pages.length,p=PALETTES[id]||PALETTES['cream-elegance'];
try{const ctx=cover.getContext('2d');ctx.save();ctx.setTransform(SCALE,0,0,SCALE,0,0);footer(ctx,p,1,total,snapshot?.brandName,fontsFor(id));ctx.restore()}catch(_){}
if(selfContained){
const f=fontsFor(id);
(innerPages||[]).forEach((canvas,i)=>{try{const scale=canvas.width/W,ctx=canvas.getContext('2d');ctx.save();ctx.setTransform(scale,0,0,scale,0,0);footer(ctx,p,2+i,total,snapshot?.brandName,f);ctx.restore()}catch(_){}});
}else{
correctPageNumbers(innerPages||[],2,total,innerId);
}
return pages;
}