Refine six client proposal layouts and transparent company logos

This commit is contained in:
pavlov346346-source 2026-09-18 19:55:04 +03:00
parent cdb43c1653
commit 11e7b4bd56
23 changed files with 310 additions and 67 deletions

View File

@ -0,0 +1,17 @@
# Six client proposal templates · 18 September 2026
The client sees six choices: Светлая классика, Редакционный, Вечерний, Тёплый приём, Гастро, Приглашение. Local Manrope and Playfair fonts, measured full Cyrillic text and a single renderer are shared by preview and PDF download.
All 21 previous identifiers remain readable. `CateriumProposalPDF.resolveTemplate` maps retired designs to the closest curated layout at display time. Orders and snapshots are not bulk-rewritten. The default is `light`; document branding remains scoped to the current company.
PNG uploads preserve alpha and color, trim transparent margins and retain a small transparent margin. Existing snapshot logos receive the same treatment during rendering. Logos have proportional dimensions up to 280 × 76 canvas units, with no thumbnail tile. If the predominant logo color would disappear against the page, the full masthead receives a contrasting background. Company names wrap separately from logos.
The second visual review corrected orphaned totals, oversized gaps below editorial headings, excessive card page breaks and uneven row distribution. Story panels, editorial columns, dark circular thumbnails, bento cards and ticket-style alternating rows give the interiors different structures. Long descriptions flow across pages without truncation. Normal price breakdowns and the grand total stay together.
Reproduce the visual audit from the repository root:
1. Serve `public` with `npx http-server public -p 4182 -c-1`.
2. Run `node ops/pdf/audit-curated.mjs final` in another terminal. This uses isolated, fictional fixtures, blocks external requests and writes six real PDFs to the sibling `caterium-six-proposals-20260918/output/pdf` directory. It also updates the six brand-neutral selection thumbnails.
3. Run `ops/pdf/render-curated.py final` using the bundled Python runtime. Poppler renders every PDF page. The script writes page images, contact sheets, a six-cover overview and a text-bound/collision report under that sibling directory.
Browser regression coverage includes all six choices, legacy IDs, 18 combinations of wide/tall/white logos and light/dark templates, complete long descriptions, grouped totals, optional content, local fonts, cancelled image requests and identical preview/download A4 pages. PDF text remains rasterized by the existing PDF engine; selectable/searchable PDF text is outside this release.

View File

@ -11,7 +11,7 @@
"serverReady": true, "serverReady": true,
"workspaceAutoDiscovery": true, "workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false, "invitesTemporarilyDisabled": false,
"pwaCache": "v105-20260918-russia-proxy-rpc", "pwaCache": "v106-20260918-proposals-six",
"fullOfferDescriptions": true, "fullOfferDescriptions": true,
"dynamicOfferRows": true, "dynamicOfferRows": true,
"pdfOfferDescriptionFix": true, "pdfOfferDescriptionFix": true,
@ -342,7 +342,7 @@
"indexCacheBustCurrent": true, "indexCacheBustCurrent": true,
"backgroundImageMutationBatching": true, "backgroundImageMutationBatching": true,
"opsBootWaitBounded": true, "opsBootWaitBounded": true,
"offerTemplates": 21, "offerTemplates": 6,
"offerTemplatePerOrder": true, "offerTemplatePerOrder": true,
"offerTemplateStructuralPreviews": true, "offerTemplateStructuralPreviews": true,
"offerPdfDistinctLayouts": true, "offerPdfDistinctLayouts": true,
@ -420,5 +420,16 @@
"cloudReadTimeoutMs": 12000, "cloudReadTimeoutMs": 12000,
"cloudFallbackTimeoutMs": 15000, "cloudFallbackTimeoutMs": 15000,
"cloudWriteTimeoutMs": 35000, "cloudWriteTimeoutMs": 35000,
"cloudSafeNetworkRetries": 3 "cloudSafeNetworkRetries": 3,
"proposalCuratedTemplates": [
"light",
"editorial-grid",
"midnight-glass",
"event-story",
"bento-cards",
"event-ticket"
],
"proposalTransparentLogoTrim": true,
"proposalLogoAspectRatio": true,
"proposalLogoContrastMasthead": true
} }

56
ops/pdf/audit-curated.mjs Normal file
View File

@ -0,0 +1,56 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {chromium} from '@playwright/test';
import {boxes} from '../demo/trial-demo-data.mjs';
// Fictional fixtures, isolated browser, blocked external requests. No customer writes.
const phase=process.argv[2]||'final';
const root=path.resolve('..','caterium-six-proposals-20260918');
const out=path.join(root,phase==='final'?'output/pdf':'tmp/pdfs/'+phase);
const assets=path.join(root,'tmp','pdfs',phase);
await fs.mkdir(out,{recursive:true});
await fs.mkdir(assets,{recursive:true});
const browser=await chromium.launch();
const page=await browser.newPage();
await page.route('https://**',r=>r.abort());
await page.goto('http://127.0.0.1:4182/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>Boolean(window.sunClientOfferDebugPdfPages));
const image=async file=>'data:image/'+({'.png':'png','.webp':'webp'}[path.extname(file)]||'jpeg')+';base64,'+(await fs.readFile(path.resolve('public',file))).toString('base64');
const items=await Promise.all(boxes.slice(0,8).map(async(b,i)=>({...b,categoryId:0,categoryName:'Фуршетные боксы',qty:i===0?2:1,unitPrice:b.price,sum:b.price*(i===0?2:1),photoData:await image(b.photo)})));
const base=items.reduce((n,i)=>n+i.sum,0);
const logo=await page.evaluate(async()=>{
await CateriumProposalPDF.ready();
const c=document.createElement('canvas');c.width=1400;c.height=500;const x=c.getContext('2d');
x.fillStyle='#234734';x.font='500 150px "Caterium Playfair"';x.fillText('ЛИСТ',250,270);
x.font='600 38px "Caterium Manrope"';x.fillText('К Е Й Т Е Р И Н Г',260,340);
return c.toDataURL('image/png');
});
const snapshot={brandName:'Лист · кейтеринг',brandCity:'Санкт-Петербург',brandContacts:'Пример оформления · Данные вымышлены',logo,client:'Анна и Михаил',event:'Вечер в кругу друзей',date:'2026-10-24',time:'18:00',guests:20,items,pricing:{base,manual:1500,promo:0,discount:1500,itemsTotal:base-1500,delivery:1500,total:base},foodGrams:9200,foodPieces:156,controlLines:['Согласуем время и адрес доставки','Подготовим заказ к началу вашего события'],extraServices:['Сервировка стола — по согласованию','Обслуживание — по отдельному расчёту'],finalGallery:[await image('offer-gallery/001.jpg'),await image('offer-gallery/002.jpg')],pdfSettings:{texts:{heroNote:'Сезонные вкусы, любимые сочетания и красивые детали вашего вечера.'}}};
await page.evaluate(s=>window.__pdfFixture=s,snapshot);
let choices=await page.evaluate(()=>CateriumProposalPDF.templates);
if(phase==='first-four')choices=choices.slice(0,4);
const report=[];
for(const [index,{id,name}] of choices.entries()){
const result=await page.evaluate(async id=>{
const s={...window.__pdfFixture,offerTemplateId:id};
const pages=await sunClientOfferDebugPdfPages(s);
const jpg=pages.map(c=>({width:c.width,height:c.height,bytes:Uint8Array.from(atob(c.toDataURL('image/jpeg',.94).split(',')[1]),c=>c.charCodeAt(0))}));
const blob=SunPdfEngine.fromJpegs(jpg);
const pdf=await new Promise(resolve=>{const r=new FileReader();r.onload=()=>resolve(r.result.split(',')[1]);r.readAsDataURL(blob)});
const thumb=document.createElement('canvas');thumb.width=420;thumb.height=594;thumb.getContext('2d').drawImage(pages[0],0,0,420,594);
const thumbnail=thumb.toDataURL('image/jpeg',.9).split(',')[1];
const info={id,pages:pages.length,width:pages[0].width,layout:pages.map(p=>p.__proposalLayout),images:pages.map(p=>p.__proposalImages||[])};
pages.forEach(c=>{c.width=0;c.height=0});return {pdf,info,thumbnail};
},id);
const filename=`${String(index+1).padStart(2,'0')}-${id}.pdf`;
await fs.writeFile(path.join(out,filename),Buffer.from(result.pdf,'base64'));
await fs.writeFile(path.join(assets,id+'-cover.jpg'),Buffer.from(result.thumbnail,'base64'));
if(phase==='final'){
// Thumbnails in the app are brand-neutral; actual PDFs use the customer's PNG.
const thumb=await page.evaluate(async id=>{const pages=await CateriumProposalPDF.renderPages({...window.__pdfFixture,offerTemplateId:id,brandName:'Ваша компания',brandCity:'',brandContacts:'',logo:''});const c=document.createElement('canvas');c.width=420;c.height=594;c.getContext('2d').drawImage(pages[0],0,0,420,594);const data=c.toDataURL('image/jpeg',.9).split(',')[1];pages.forEach(p=>{p.width=0;p.height=0});return data},id);
await fs.writeFile(path.resolve('public','offer-templates','quality-'+id+'.jpg'),Buffer.from(thumb,'base64'));
}
report.push({...result.info,name,filename});console.log(name,result.info.pages,'pages');
}
await fs.writeFile(path.join(assets,'report.json'),JSON.stringify(report,null,2));
await browser.close();

42
ops/pdf/render-curated.py Normal file
View File

@ -0,0 +1,42 @@
from pathlib import Path
import sys,json,subprocess,concurrent.futures
from PIL import Image,ImageOps,ImageDraw,ImageFont
phase=sys.argv[1] if len(sys.argv)>1 else 'final'
root=Path(__file__).resolve().parents[3]/'caterium-six-proposals-20260918'
src=root/('output/pdf' if phase=='final' else 'tmp/pdfs/'+phase)
dest=root/'tmp/pdfs'/('render-'+phase)
dest.mkdir(parents=True,exist_ok=True)
poppler=Path.home()/'.cache/codex-runtimes/codex-primary-runtime/dependencies/native/poppler/Library/bin/pdftoppm.exe'
pdfs=sorted(src.glob('*.pdf'))
def render(pdf):
for old in dest.glob(pdf.stem+'-*.png'):old.unlink()
subprocess.run([str(poppler),'-scale-to','1200','-png',str(pdf),str(dest/pdf.stem)],check=True,capture_output=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:list(pool.map(render,pdfs))
font=ImageFont.truetype('C:/Windows/Fonts/arial.ttf',18)
for pdf in pdfs:
images=sorted(dest.glob(pdf.stem+'-*.png'))
canvas=Image.new('RGB',(360*len(images),540),'#d9ded8');draw=ImageDraw.Draw(canvas)
for i,file in enumerate(images):
im=Image.open(file).convert('RGB');im.thumbnail((336,484))
canvas.paste(im,(i*360+12,34));draw.text((i*360+12,8),f'{pdf.stem} / {i+1}',font=font,fill='#15291e')
canvas.save(dest/(pdf.stem+'-contact.jpg'),quality=94)
report=json.loads((root/'tmp/pdfs'/phase/'report.json').read_text(encoding='utf-8'))
problems=[]
for row in report:
for p,boxes in enumerate(row['layout']):
for i,a in enumerate(boxes):
if a['x']<35 or a['x']+a['w']>965 or a['y']<0 or a['y']+a['h']>1405:problems.append({'id':row['id'],'page':p+1,'kind':'bounds','box':a})
for b in boxes[i+1:]:
if min(a['x']+a['w'],b['x']+b['w'])-max(a['x'],b['x'])>2 and min(a['y']+a['h'],b['y']+b['h'])-max(a['y'],b['y'])>2:problems.append({'id':row['id'],'page':p+1,'kind':'collision','a':a,'b':b})
(dest/'layout-problems.json').write_text(json.dumps(problems,ensure_ascii=False,indent=2),encoding='utf-8')
if phase=='final':
overview=Image.new('RGB',(1320,1390),'#e2e6e0');draw=ImageDraw.Draw(overview)
heading=ImageFont.truetype('C:/Windows/Fonts/arialbd.ttf',25)
for i,row in enumerate(report):
col=i%3;r=i//3;x=col*440+20;y=r*685+18
draw.text((x,y),f'{i+1:02d} · {row["name"]}',font=heading,fill='#20362d')
im=Image.open(dest/(Path(row['filename']).stem+'-1.png')).convert('RGB');im.thumbnail((400,596))
overview.paste(im,(x,y+46))
overview.save(root/'output'/'six-templates.png')
print(f'Rendered {len(pdfs)} PDFs. Layout problems: {len(problems)}. {dest}')

View File

@ -565,7 +565,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
let card=$('sunPdfBrandCard'); let card=$('sunPdfBrandCard');
if(!card){ if(!card){
card=document.createElement('section');card.id='sunPdfBrandCard';card.className='enterprise-card wide'; card=document.createElement('section');card.id='sunPdfBrandCard';card.className='enterprise-card wide';
card.innerHTML='<h2>Логотип и название компании</h2><p class="hint">Используются во всех документах вашей компании: предложениях, чеках, бланках заказа, заготовках, маршрутных листах и каталогах. Бренд приложения в левой панели остаётся прежним.</p><div class="sun-default-qr-setting"><div id="sunPdfBrandPreview" class="preview"></div><div><input id="sunPdfBrandLogoFile" type="file" accept="image/png,image/jpeg,image/webp" hidden><label style="display:block;margin:0 0 10px"><span class="hint" style="display:block;margin-bottom:4px">Название компании</span><input id="sunPdfBrandNameInput" type="text" maxlength="160" placeholder="Название вашей компании"></label><div class="actions"><button class="outline" id="sunPdfBrandUpload" type="button">Загрузить / заменить логотип</button><button class="outline" id="sunPdfBrandReset" type="button">Убрать логотип</button></div><p class="hint">PNG, JPEG или WebP, до 15 МБ. Прозрачность PNG сохраняется.</p></div></div>'; card.innerHTML='<h2>Логотип и название компании</h2><p class="hint">Используются во всех документах вашей компании: предложениях, чеках, бланках заказа, заготовках, маршрутных листах и каталогах. Бренд приложения в левой панели остаётся прежним.</p><div class="sun-document-brand-setting"><div id="sunPdfBrandPreview" class="preview"></div><div><input id="sunPdfBrandLogoFile" type="file" accept="image/png,image/jpeg,image/webp" hidden><label style="display:block;margin:0 0 10px"><span class="hint" style="display:block;margin-bottom:4px">Название компании</span><input id="sunPdfBrandNameInput" type="text" maxlength="160" placeholder="Название вашей компании"></label><div class="actions"><button class="outline" id="sunPdfBrandUpload" type="button">Загрузить / заменить логотип</button><button class="outline" id="sunPdfBrandReset" type="button">Убрать логотип</button></div><p class="hint">Рекомендуем PNG с прозрачным фоном, до 15 МБ. Пустые прозрачные поля убираются, пропорции и цвета сохраняются. JPEG и WebP тоже поддерживаются.</p></div></div>';
const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений'); const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');
window.SunSafe.insertBefore(grid,card,audit||null); window.SunSafe.insertBefore(grid,card,audit||null);
$('sunPdfBrandNameInput').value=window.CateriumBranding.documentName(); $('sunPdfBrandNameInput').value=window.CateriumBranding.documentName();
@ -4330,7 +4330,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
window.__sunOfferTemplateV1=true; window.__sunOfferTemplateV1=true;
const KEY='sunOfferTemplateV1'; const KEY='sunOfferTemplateV1';
const DEFAULT_ID='cream-elegance'; const DEFAULT_ID='light';
const TEMPLATES=window.CateriumProposalPDF?.templates||[ const TEMPLATES=window.CateriumProposalPDF?.templates||[
{id:'cream-elegance',name:'Кремовая классика',desc:'Светлый премиальный дизайн: крупная обложка, круглые иконки категорий и аккуратная сетка меню.',thumb:''}, {id:'cream-elegance',name:'Кремовая классика',desc:'Светлый премиальный дизайн: крупная обложка, круглые иконки категорий и аккуратная сетка меню.',thumb:''},
{id:'neon-menu',name:'Неоновое меню',desc:'Тёмный дизайн с неоновыми акцентами, нумерованной фотосеткой блюд и карточками категорий.',thumb:''}, {id:'neon-menu',name:'Неоновое меню',desc:'Тёмный дизайн с неоновыми акцентами, нумерованной фотосеткой блюд и карточками категорий.',thumb:''},
@ -4346,7 +4346,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
function parse(){ function parse(){
try{ try{
const raw=JSON.parse(localStorage.getItem(KEY)||'{}')||{}; const raw=JSON.parse(localStorage.getItem(KEY)||'{}')||{};
const id=TEMPLATES.some(x=>x.id===raw.id)?raw.id:DEFAULT_ID; const candidate=window.CateriumProposalPDF?.resolveTemplate(raw.id)||raw.id;
const id=TEMPLATES.some(x=>x.id===candidate)?candidate:DEFAULT_ID;
return {version:1,id}; return {version:1,id};
}catch(_){return {version:1,id:DEFAULT_ID}} }catch(_){return {version:1,id:DEFAULT_ID}}
} }
@ -4364,7 +4365,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
style.textContent=` style.textContent=`
.sun-offer-template-card{grid-column:1/-1!important} .sun-offer-template-card{grid-column:1/-1!important}
.sun-offer-template-card .hint{max-width:980px} .sun-offer-template-card .hint{max-width:980px}
.sun-offer-template-grid{display:grid;grid-template-columns:repeat(4,minmax(200px,1fr));gap:14px;margin-top:14px} .sun-offer-template-grid{display:grid;grid-template-columns:repeat(3,minmax(200px,1fr));gap:14px;margin-top:14px}
.sun-offer-template-option{position:relative;border:1px solid #e3e6e9;border-radius:16px;background:#fff;padding:10px;cursor:pointer;transition:.16s ease;overflow:hidden;box-shadow:0 4px 16px rgba(30,35,40,.035)} .sun-offer-template-option{position:relative;border:1px solid #e3e6e9;border-radius:16px;background:#fff;padding:10px;cursor:pointer;transition:.16s ease;overflow:hidden;box-shadow:0 4px 16px rgba(30,35,40,.035)}
.sun-offer-template-option:hover{transform:translateY(-1px);box-shadow:0 8px 22px rgba(30,35,40,.08)} .sun-offer-template-option:hover{transform:translateY(-1px);box-shadow:0 8px 22px rgba(30,35,40,.08)}
.sun-offer-template-option.on{border-color:var(--sun-brand-sun,#FFC107);box-shadow:0 0 0 2px color-mix(in srgb,var(--sun-brand-sun,#FFC107) 30%,transparent),0 9px 24px rgba(30,35,40,.09)} .sun-offer-template-option.on{border-color:var(--sun-brand-sun,#FFC107);box-shadow:0 0 0 2px color-mix(in srgb,var(--sun-brand-sun,#FFC107) 30%,transparent),0 9px 24px rgba(30,35,40,.09)}
@ -4381,7 +4382,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
document.head.appendChild(style); document.head.appendChild(style);
function offerTemplateMini(id){ function offerTemplateMini(id){
if(window.CateriumProposalPDF?.IDS.includes(id))return `<img src="offer-templates/quality-${id}.jpg?v=20260918" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#eeece5;z-index:2">`; if(window.CateriumProposalPDF?.IDS.includes(id))return `<img src="offer-templates/quality-${id}.jpg?v=20260918-six" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#eeece5;z-index:2">`;
if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>'; if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>';
if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>'; if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>';
if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>'; if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>';

View File

@ -96,7 +96,7 @@
} }
function installPreviewStyles(){if(document.getElementById('sunClassicOfferPreviewV1767'))return;const style=document.createElement('style');style.id='sunClassicOfferPreviewV1767';style.textContent=` function installPreviewStyles(){if(document.getElementById('sunClassicOfferPreviewV1767'))return;const style=document.createElement('style');style.id='sunClassicOfferPreviewV1767';style.textContent=`
.sun-v1764-offer-template-grid{grid-template-columns:repeat(auto-fill,minmax(128px,1fr))!important;max-height:330px;overflow:auto;padding:2px 3px 5px} .sun-v1764-offer-template-grid{grid-template-columns:repeat(3,minmax(0,1fr));max-height:none;overflow:visible;padding:2px 3px 5px}
.sun-v1764-offer-template-grid button{min-width:0} .sun-v1764-offer-template-grid button{min-width:0}
.sun-offer-template-mini[data-template-mini]{position:relative;isolation:isolate} .sun-offer-template-mini[data-template-mini]{position:relative;isolation:isolate}
.sun-offer-template-mini[data-template-mini="warm-sun"] i,.sun-offer-template-mini[data-template-mini="bento-cards"] i,.sun-offer-template-mini[data-template-mini="event-story"] i,.sun-offer-template-mini[data-template-mini="black-gold"] i,.sun-offer-template-mini[data-template-mini="personal-letter"] i,.sun-offer-template-mini[data-template-mini="event-ticket"] i,.sun-offer-template-mini[data-template-mini="solar-experience"] i,.sun-offer-template-mini[data-template-mini="midnight-compact"] i,.sun-offer-template-mini[data-template-mini="neon-emerald"] i{display:none!important} .sun-offer-template-mini[data-template-mini="warm-sun"] i,.sun-offer-template-mini[data-template-mini="bento-cards"] i,.sun-offer-template-mini[data-template-mini="event-story"] i,.sun-offer-template-mini[data-template-mini="black-gold"] i,.sun-offer-template-mini[data-template-mini="personal-letter"] i,.sun-offer-template-mini[data-template-mini="event-ticket"] i,.sun-offer-template-mini[data-template-mini="solar-experience"] i,.sun-offer-template-mini[data-template-mini="midnight-compact"] i,.sun-offer-template-mini[data-template-mini="neon-emerald"] i{display:none!important}
@ -112,7 +112,7 @@
.sun-v1767-group-label{grid-column:1/-1;font-size:10px;font-weight:900;letter-spacing:.07em;text-transform:uppercase;color:#7d858c;padding:6px 2px 1px} .sun-v1767-group-label{grid-column:1/-1;font-size:10px;font-weight:900;letter-spacing:.07em;text-transform:uppercase;color:#7d858c;padding:6px 2px 1px}
#sunClientOfferPreview .sun-offer-page-canvas[data-sun-classic-cover="1"]{box-shadow:0 18px 50px rgba(31,35,36,.16)} #sunClientOfferPreview .sun-offer-page-canvas[data-sun-classic-cover="1"]{box-shadow:0 18px 50px rgba(31,35,36,.16)}
`;document.head.appendChild(style)} `;document.head.appendChild(style)}
function groupPicker(){const grid=document.querySelector('.sun-v1764-offer-template-grid');if(!grid||grid.dataset.sunV1767Grouped==='1')return;const classic=[...grid.querySelectorAll('button')].filter(b=>CLASSIC_SET.has(b.dataset.v1764OfferTemplate)),archive=[...grid.querySelectorAll('button')].filter(b=>ARCHIVE_SET.has(b.dataset.v1764OfferTemplate));if(!classic.length&&!archive.length)return;grid.dataset.sunV1767Grouped='1';const label=t=>{const d=document.createElement('div');d.className='sun-v1767-group-label';d.textContent=t;return d};const modern=[...grid.querySelectorAll('button')].filter(b=>!CLASSIC_SET.has(b.dataset.v1764OfferTemplate)&&!ARCHIVE_SET.has(b.dataset.v1764OfferTemplate));grid.innerHTML='';if(classic.length){grid.appendChild(label('Классические — как раньше'));classic.forEach(b=>grid.appendChild(b))}if(modern.length){grid.appendChild(label('Современная коллекция'));modern.forEach(b=>grid.appendChild(b))}if(archive.length){grid.appendChild(label('Дополнительные стили'));archive.forEach(b=>grid.appendChild(b))}} function groupPicker(){if(window.CateriumProposalPDF?.CURATED_IDS)return;const grid=document.querySelector('.sun-v1764-offer-template-grid');if(!grid||grid.dataset.sunV1767Grouped==='1')return;const classic=[...grid.querySelectorAll('button')].filter(b=>CLASSIC_SET.has(b.dataset.v1764OfferTemplate)),archive=[...grid.querySelectorAll('button')].filter(b=>ARCHIVE_SET.has(b.dataset.v1764OfferTemplate));if(!classic.length&&!archive.length)return;grid.dataset.sunV1767Grouped='1';const label=t=>{const d=document.createElement('div');d.className='sun-v1767-group-label';d.textContent=t;return d};const modern=[...grid.querySelectorAll('button')].filter(b=>!CLASSIC_SET.has(b.dataset.v1764OfferTemplate)&&!ARCHIVE_SET.has(b.dataset.v1764OfferTemplate));grid.innerHTML='';if(classic.length){grid.appendChild(label('Классические — как раньше'));classic.forEach(b=>grid.appendChild(b))}if(modern.length){grid.appendChild(label('Современная коллекция'));modern.forEach(b=>grid.appendChild(b))}if(archive.length){grid.appendChild(label('Дополнительные стили'));archive.forEach(b=>grid.appendChild(b))}}
function maintain(){installPreviewStyles();groupPicker()} function maintain(){installPreviewStyles();groupPicker()}
const observer=new MutationObserver(()=>setTimeout(maintain,0));if(document.documentElement)observer.observe(document.documentElement,{childList:true,subtree:true});if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',maintain,{once:true});else maintain(); const observer=new MutationObserver(()=>setTimeout(maintain,0));if(document.documentElement)observer.observe(document.documentElement,{childList:true,subtree:true});if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',maintain,{once:true});else maintain();

View File

@ -23,6 +23,21 @@
function changed(){window.dispatchEvent(new CustomEvent('caterium:document-brand-changed'));} function changed(){window.dispatchEvent(new CustomEvent('caterium:document-brand-changed'));}
function saveName(value){localStorage.setItem(NAME_KEY,clean(value));changed();} function saveName(value){localStorage.setItem(NAME_KEY,clean(value));changed();}
function clearLogo(){localStorage.setItem(LOGO_KEY,'');changed();} function clearLogo(){localStorage.setItem(LOGO_KEY,'');changed();}
// Remove empty PNG margins, keeping a small transparent breathing space.
// Used both on upload and on older logos already saved in offer snapshots.
function prepareLogoImage(image){
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
const scale=Math.min(1,960/Math.max(width,height));
const source=document.createElement('canvas');source.width=Math.max(1,Math.round(width*scale));source.height=Math.max(1,Math.round(height*scale));
const ctx=source.getContext('2d',{willReadFrequently:true});ctx.drawImage(image,0,0,source.width,source.height);
const pixels=ctx.getImageData(0,0,source.width,source.height).data;
let left=source.width,top=source.height,right=-1,bottom=-1;
for(let y=0;y<source.height;y++)for(let x=0;x<source.width;x++)if(pixels[(y*source.width+x)*4+3]>8){left=Math.min(left,x);top=Math.min(top,y);right=Math.max(right,x);bottom=Math.max(bottom,y)}
if(right<left)throw new Error('Логотип полностью прозрачный. Выберите файл с изображением.');
const w=right-left+1,h=bottom-top+1,pad=Math.max(1,Math.round(Math.max(w,h)*.02));
const out=document.createElement('canvas');out.width=w+pad*2;out.height=h+pad*2;
out.getContext('2d').drawImage(source,left,top,w,h,pad,pad,w,h);return out;
}
async function saveLogo(file){ async function saveLogo(file){
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]); const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]);
const startedIn=scope(); const startedIn=scope();
@ -30,10 +45,7 @@
if(file.size>15*1024*1024)throw new Error('Логотип должен быть меньше 15 МБ.'); if(file.size>15*1024*1024)throw new Error('Логотип должен быть меньше 15 МБ.');
const src=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=()=>reject(new Error('Не удалось прочитать файл.'));reader.readAsDataURL(file)}); const src=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=()=>reject(new Error('Не удалось прочитать файл.'));reader.readAsDataURL(file)});
const logo=await new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('Не удалось открыть изображение.'));img.src=src}); const logo=await new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('Не удалось открыть изображение.'));img.src=src});
const scale=Math.min(1,1000/Math.max(logo.naturalWidth,logo.naturalHeight)); const canvas=prepareLogoImage(logo);
const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(logo.naturalWidth*scale));canvas.height=Math.max(1,Math.round(logo.naturalHeight*scale));
// Preserve transparency for both light and dark document templates.
canvas.getContext('2d').drawImage(logo,0,0,canvas.width,canvas.height);
const data=canvas.toDataURL('image/png'); const data=canvas.toDataURL('image/png');
if(!safeLogo(data))throw new Error('Изображение слишком сложное. Выберите логотип меньшего размера.'); if(!safeLogo(data))throw new Error('Изображение слишком сложное. Выберите логотип меньшего размера.');
if(startedIn!==scope())throw new Error('Аккаунт или компания изменились. Загрузите логотип ещё раз.'); if(startedIn!==scope())throw new Error('Аккаунт или компания изменились. Загрузите логотип ещё раз.');
@ -71,12 +83,12 @@
} }
function boot(){ function boot(){
const style=document.createElement('style');style.id='caterium-company-branding-style'; const style=document.createElement('style');style.id='caterium-company-branding-style';
style.textContent='body > header .brand .caterium-product-mark{background:#fff7e6;border-radius:14px;padding:6px;filter:none!important}';document.head.appendChild(style); style.textContent='#sunPdfBrandCard .sun-document-brand-setting{display:grid;grid-template-columns:minmax(180px,300px) minmax(0,1fr);gap:24px;align-items:center}#sunPdfBrandPreview{height:140px;display:flex;align-items:center;justify-content:center;padding:16px;border-radius:12px;background:repeating-conic-gradient(#edf0eb 0% 25%,#fff 0% 50%) 0/20px 20px;border:1px solid #dde2d9;color:#475349;font-size:13px}#sunPdfBrandPreview img{width:auto!important;height:auto!important;max-width:100%!important;max-height:108px!important;object-fit:contain;border:0!important;border-radius:0!important}@media(max-width:640px){#sunPdfBrandCard .sun-document-brand-setting{grid-template-columns:1fr}}body > header .brand .caterium-product-mark{background:#fff7e6;border-radius:14px;padding:6px;filter:none!important}';document.head.appendChild(style);
renderSidebar();refreshSidebar(); renderSidebar();refreshSidebar();
} }
window.CateriumBranding=Object.freeze({identity,documentName,documentLogo,documentImage,documentContacts,documentCity, window.CateriumBranding=Object.freeze({identity,documentName,documentLogo,documentImage,documentContacts,documentCity,
nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()), nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()),
escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar}); escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,prepareLogoImage,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar});
for(const event of ['sun:cloud-state-applied','sun:cloud-permissions-changed','suncloudsync'])window.addEventListener(event,()=>{refreshSidebar();changed()}); for(const event of ['sun:cloud-state-applied','sun:cloud-permissions-changed','suncloudsync'])window.addEventListener(event,()=>{refreshSidebar();changed()});
window.addEventListener('storage',event=>{if([NAME_KEY,LOGO_KEY,'sunCompanyProfileV1'].includes(event.key))changed();if(/^sb-.*-auth-token$/.test(event.key||''))resetSidebar()}); window.addEventListener('storage',event=>{if([NAME_KEY,LOGO_KEY,'sunCompanyProfileV1'].includes(event.key))changed();if(/^sb-.*-auth-token$/.test(event.key||''))resetSidebar()});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();

View File

@ -38,9 +38,9 @@
#sunClientOfferModal[data-offer-workspace-mode="templates"] .sun-offer-frame-wrap,#sunClientOfferModal[data-offer-workspace-mode="templates"] #sunOfferEditor{display:none!important} #sunClientOfferModal[data-offer-workspace-mode="templates"] .sun-offer-frame-wrap,#sunClientOfferModal[data-offer-workspace-mode="templates"] #sunOfferEditor{display:none!important}
#sunClientOfferModal[data-offer-workspace-mode="templates"] #sunOfferTemplateV1764{display:block!important} #sunClientOfferModal[data-offer-workspace-mode="templates"] #sunOfferTemplateV1764{display:block!important}
#sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-head{position:sticky;top:-16px;z-index:2;background:#fff;padding:1px 0 10px;margin-bottom:10px} #sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-head{position:sticky;top:-16px;z-index:2;background:#fff;padding:1px 0 10px;margin-bottom:10px}
#sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-grid{grid-template-columns:repeat(4,minmax(170px,1fr));gap:10px} #sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-grid{grid-template-columns:repeat(3,minmax(170px,1fr));gap:10px}
#sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-grid button{min-height:166px;padding:10px;border-radius:15px} #sunClientOfferModal #sunOfferTemplateV1764 .sun-v1764-offer-template-grid button{min-height:166px;padding:10px;border-radius:15px}
#sunClientOfferModal #sunOfferTemplateV1764 .sun-offer-template-mini{height:116px} #sunClientOfferModal #sunOfferTemplateV1764 .sun-offer-template-mini{height:auto;aspect-ratio:1000/1414}
#sunClientOfferModal .sun-offer-editor-gallery{border:1px solid #e0e4e1;border-radius:16px;padding:14px;background:#f8faf8} #sunClientOfferModal .sun-offer-editor-gallery{border:1px solid #e0e4e1;border-radius:16px;padding:14px;background:#f8faf8}
#sunClientOfferModal .sun-offer-editor-gallery-images{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px!important;margin-top:12px} #sunClientOfferModal .sun-offer-editor-gallery-images{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px!important;margin-top:12px}
#sunClientOfferModal .sun-offer-gallery-upload-v1769{position:relative;border:1px solid #d9dfda;border-radius:14px;background:#fff;padding:9px;min-width:0} #sunClientOfferModal .sun-offer-gallery-upload-v1769{position:relative;border:1px solid #d9dfda;border-radius:14px;background:#fff;padding:9px;min-width:0}

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; const VERSION='17.7.3';
const RELEASE='20260918-russia-proxy-rpc'; const RELEASE='20260918-proposals-six';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){ function installAuthBoot(){

View File

@ -32,6 +32,11 @@
]; ];
const THEMES=Object.fromEntries(definitions.map(([id,name,cover,bg,ink,accent,type,menu])=>[id,{id,name,cover,bg,ink,accent,type,menu,dark:!['light','editorial-grid','warm-sun','bento-cards','event-story','personal-letter','event-ticket','solar-experience','cream-elegance'].includes(id)}])); const THEMES=Object.fromEntries(definitions.map(([id,name,cover,bg,ink,accent,type,menu])=>[id,{id,name,cover,bg,ink,accent,type,menu,dark:!['light','editorial-grid','warm-sun','bento-cards','event-story','personal-letter','event-ticket','solar-experience','cream-elegance'].includes(id)}]));
const IDs=Object.keys(THEMES); const IDs=Object.keys(THEMES);
const CURATED_IDS=['light','editorial-grid','midnight-glass','event-story','bento-cards','event-ticket'];
const names={'light':'Светлая классика','editorial-grid':'Редакционный','midnight-glass':'Вечерний','event-story':'Тёплый приём','bento-cards':'Гастро','event-ticket':'Приглашение'};
// Resolve old choices without rewriting orders or their saved snapshots.
const aliases={'warm-sun':'event-story','black-gold':'midnight-glass','personal-letter':'event-story','solar-experience':'bento-cards','midnight-compact':'midnight-glass','emerald-gold':'midnight-glass','neon-emerald':'midnight-glass','cream-elegance':'light','neon-menu':'bento-cards','emerald-circles':'midnight-glass','midnight-checklist':'midnight-glass','gourmet-hero':'bento-cards','diamond-gold':'midnight-glass','premium-dark':'midnight-glass','premium-emerald':'midnight-glass'};
const resolveTemplate=id=>CURATED_IDS.includes(id)?id:aliases[id]||'light';
const number=v=>Number.isFinite(Number(v))?Number(v):0; const number=v=>Number.isFinite(Number(v))?Number(v):0;
const money=v=>Math.round(number(v)).toLocaleString('ru-RU')+' ₽'; const money=v=>Math.round(number(v)).toLocaleString('ru-RU')+' ₽';
const qty=v=>number(v).toLocaleString('ru-RU',{maximumFractionDigits:3}); const qty=v=>number(v).toLocaleString('ru-RU',{maximumFractionDigits:3});
@ -102,9 +107,30 @@
return {ctx,body,display,rect,line,lines,write,fit,photo}; return {ctx,body,display,rect,line,lines,write,fit,photo};
} }
function page(t,kind){const c=document.createElement('canvas');c.width=W*(t.scale||SCALE);c.height=Math.round(H*(t.scale||SCALE));c.dataset.sunProposalTemplate=t.id;c.dataset.sunProposalPage=kind;const d=painter(c,t);d.rect(0,0,W,H,t.bg);return {c,...d}} function page(t,kind){const c=document.createElement('canvas');c.width=W*(t.scale||SCALE);c.height=Math.round(H*(t.scale||SCALE));c.dataset.sunProposalTemplate=t.id;c.dataset.sunProposalPage=kind;const d=painter(c,t);d.rect(0,0,W,H,t.bg);return {c,...d}}
function brand(d,s,t,logo,{x=M,y=60,w=860}={}){ function logoForDocument(image,t){
if(logo){d.rect(x,y-4,78,58,'#fffdf6',8);d.photo(logo,x+6,y,66,50,{contain:true});x+=94;w-=94} if(!image)return null;
d.fit(s.brandName||'Моя компания',x,y+6,w,56,{size:24,min:15,displayFont:false,weight:700}); let canvas;try{canvas=window.CateriumBranding.prepareLogoImage(image)}catch(_){return null}
const ctx=canvas.getContext('2d',{willReadFrequently:true}),pixels=ctx.getImageData(0,0,canvas.width,canvas.height).data;
const linear=v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4};
const luminance=(r,g,b)=>.2126*linear(r)+.7152*linear(g)+.0722*linear(b);
const rgb=t.bg.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),bg=luminance(...rgb);
let light=0,dark=0,weak=0,count=0;
for(let i=0;i<pixels.length;i+=4){const a=pixels[i+3]/255;if(a<.1)continue;const l=luminance(pixels[i],pixels[i+1],pixels[i+2]);count+=a;if((Math.max(l,bg)+.05)/(Math.min(l,bg)+.05)<2)weak+=a;if(l>.4)light+=a;else dark+=a}
// A full masthead, never a square tile, provides contrast for monochrome logos.
const alternate=count&&weak/count>.55;
return {canvas,bg:alternate?(light>dark?'#17251f':'#fffdf8'):t.bg,ink:alternate?(light>dark?'#fffdf8':'#20362d'):t.ink};
}
function brand(d,s,t,logo,{x=M,y=48,w=860}={}){
let ink=t.ink;
if(logo){
ink=logo.ink;
if(logo.bg!==t.bg)d.rect(0,0,W,y+84,logo.bg);
const image=logo.canvas,maxW=Math.min(280,w*.44),scale=Math.min(maxW/image.width,76/image.height),lw=image.width*scale,lh=image.height*scale;
const yy=y+(76-lh)/2;d.ctx.drawImage(image,x,yy,lw,lh);
(d.c.__proposalImages??=[]).push({role:'logo',x,y:yy,w:lw,h:lh,sourceWidth:image.width,sourceHeight:image.height,background:logo.bg});
x+=lw+32;w-=lw+32;
}
d.fit(s.brandName||'Моя компания',x,y+17,w,57,{size:23,min:15,displayFont:false,weight:700,color:ink});
} }
function facts(s,cfg){return [cfg.showDate?[date(s.date),s.time].filter(Boolean).join(' · '):'',cfg.showGuests&&number(s.guests)?`${qty(s.guests)} гостей`:''].filter(Boolean)} function facts(s,cfg){return [cfg.showDate?[date(s.date),s.time].filter(Boolean).join(' · '):'',cfg.showGuests&&number(s.guests)?`${qty(s.guests)} гостей`:''].filter(Boolean)}
function cover(s,t,images,logo,cfg){ function cover(s,t,images,logo,cfg){
@ -122,21 +148,21 @@
case 'split': case 'split':
label(M,198,400);heading(M,265,414,290,62);sub(M,582,395);d.line(M,718,370);note(M,758,380);d.photo(hero,520,195,410,760,{radius:180});fact(M,1010);total(M,1120);break; label(M,198,400);heading(M,265,414,290,62);sub(M,582,395);d.line(M,718,370);note(M,758,380);d.photo(hero,520,195,410,760,{radius:180});fact(M,1010);total(M,1120);break;
case 'night': case 'night':
d.photo(hero,482,0,518,H);d.rect(0,0,482,H,t.bg);brand(d,s,t,logo,{w:340});label(M,235,350);heading(M,310,350,310,58);sub(M,650,345);fact(M,835,345);d.line(M,1000,340);total(M,1050,350);break; d.photo(hero,482,155,518,H-155);d.rect(0,0,482,H,t.bg);brand(d,s,t,logo);label(M,235,350);heading(M,310,350,310,58);sub(M,650,345);fact(M,835,345);d.line(M,1000,340);total(M,1050,350);break;
case 'editorial': case 'editorial':{
label(M,180);heading(M,244,840,200,72);d.line(M,478,860);d.photo(hero,414,526,516,610);d.write('01',M,530,230,{size:54,font:d.display(54),color:accent});sub(M,638,280,145);fact(M,825,290);total(M,999,310);break; label(M,180);const bottom=244+heading(M,244,840,200,72),photoY=Math.max(435,bottom+66);d.line(M,photoY-32,860);d.photo(hero,414,photoY,516,1170-photoY);d.write('МЕНЮ / 01',M,photoY+4,290,{size:19,weight:700,color:accent});sub(M,photoY+95,280,145);fact(M,photoY+290,290);total(M,1040,310);break;}
case 'sun': case 'sun':
d.rect(758,180,172,172,'#f2c55b',86);label(M,200,550);heading(M,280,630,250,64);sub(M,562,780);d.photo(hero,320,698,610,390,{radius:180});fact(M,649,850);total(M,1140,530);break; d.rect(758,180,172,172,'#f2c55b',86);label(M,200,550);heading(M,280,630,250,64);sub(M,562,780);d.photo(hero,320,698,610,390,{radius:180});fact(M,649,850);total(M,1140,530);break;
case 'bento': case 'bento':
label(M,180);heading(M,255,850,200,67);sub(M,480,800);d.photo(hero,M,610,556,430,{radius:26});d.photo(hero2,650,610,280,202,{radius:24});d.photo(hero3,650,838,280,202,{radius:24});fact(M,1080);total(M,1160,740);break; label(M,180);heading(M,255,850,185,67);sub(M,465,800,65);note(M,545,850,52);d.photo(hero,M,635,556,405,{radius:26});d.photo(hero2,650,635,280,190,{radius:24});d.photo(hero3,650,850,280,190,{radius:24});fact(M,1080);total(M,1160,740);break;
case 'story': case 'story':
label(M,182);heading(M,260,835,200,68);sub(M,485,820);d.line(M,635,860);['Меню','Подготовка','Ваше событие'].forEach((v,i)=>{const x=M+i*310;d.rect(x,627,16,16,accent,8);d.write(v,x,664,240,{size:18})});d.photo(hero,M,735,860,360);fact(M,1140,410);total(553,1130,377);break; label(M,182);heading(M,252,835,185,68);sub(M,470,820,75);note(M,556,840,64);d.line(M,670,860);['Меню','Подготовка','Ваше событие'].forEach((v,i)=>{const x=M+i*310;d.rect(x,662,16,16,accent,8);d.write(v,x,697,240,{size:18})});d.photo(hero,M,757,860,340);fact(M,1140,410);total(553,1130,377);break;
case 'photo':{ case 'photo':{
d.photo(hero,0,0,W,H);const g=d.ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'rgba(0,0,0,.82)');g.addColorStop(.48,'rgba(0,0,0,.24)');g.addColorStop(1,'rgba(0,0,0,.95)');d.ctx.fillStyle=g;d.ctx.fillRect(0,0,W,H);brand(d,s,t,logo);label(M,215);heading(M,290,800,270,74);sub(M,620,800);fact(M,1010);total(M,1120,820);break;} d.photo(hero,0,0,W,H);const g=d.ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'rgba(0,0,0,.82)');g.addColorStop(.48,'rgba(0,0,0,.24)');g.addColorStop(1,'rgba(0,0,0,.95)');d.ctx.fillStyle=g;d.ctx.fillRect(0,0,W,H);brand(d,s,t,logo);label(M,215);heading(M,290,800,270,74);sub(M,620,800);fact(M,1010);total(M,1120,820);break;}
case 'letter': case 'letter':
d.rect(44,162,912,1125,'#fffcf5',4);label(94,218,700);d.fit(client?`${client},`:'Дорогие гости,',94,308,760,165,{size:56});d.write('Предложение для вашего мероприятия',94,496,730,{size:22});heading(94,549,730,170,44);note(94,761,470,155);d.photo(hero,690,769,210,210,{shape:'circle'});fact(94,1010,790);total(94,1100,570);break; d.rect(44,162,912,1125,'#fffcf5',4);label(94,218,700);d.fit(client?`${client},`:'Дорогие гости,',94,308,760,165,{size:56});d.write('Предложение для вашего мероприятия',94,496,730,{size:22});heading(94,549,730,170,44);note(94,761,470,155);d.photo(hero,690,769,210,210,{shape:'circle'});fact(94,1010,790);total(94,1100,570);break;
case 'ticket': case 'ticket':
d.rect(M,190,860,1080,'#fbfcfa',18);d.rect(740,190,190,1080,'#dbe8ea',18);d.ctx.save();d.ctx.setLineDash([7,9]);d.ctx.strokeStyle=accent;d.ctx.beginPath();d.ctx.moveTo(716,215);d.ctx.lineTo(716,1240);d.ctx.stroke();d.ctx.restore();label(106,246,570);heading(106,329,560,270,59);sub(106,632,550,115);fact(106,790,550);d.photo(hero,106,899,255,200,{radius:10});total(390,946,280);d.write('МЕНЮ',762,268,150,{size:17,weight:700});if(cfg.showDate){d.fit(date(s.date),765,341,140,180,{size:26,displayFont:false});d.fit(s.time||'',765,570,140,80,{size:34,displayFont:false})}break; d.rect(M,190,860,1080,'#fbfcfa',18);d.rect(740,190,190,1080,'#dbe8ea',18);d.ctx.save();d.ctx.setLineDash([7,9]);d.ctx.strokeStyle=accent;d.ctx.beginPath();d.ctx.moveTo(716,215);d.ctx.lineTo(716,1240);d.ctx.stroke();d.ctx.restore();label(106,246,570);heading(106,329,560,220,59);sub(106,590,550,94);note(106,710,550,64);fact(106,822,550);d.photo(hero,106,930,255,180,{radius:10});total(390,946,280);d.write('МЕНЮ',762,268,150,{size:17,weight:700});if(cfg.showDate){d.fit(date(s.date),765,341,140,180,{size:26,displayFont:false});d.fit(s.time||'',765,570,140,80,{size:34,displayFont:false})}break;
case 'solar': case 'solar':
label(M,205);heading(M,276,460,300,64);d.photo(hero,571,390,359,359,{shape:'circle'});sub(M,649,435,150);d.line(M,860,860);fact(M,913);d.rect(M,1040,860,238,'#fff1b5',30);total(106,1080,780);break; label(M,205);heading(M,276,460,300,64);d.photo(hero,571,390,359,359,{shape:'circle'});sub(M,649,435,150);d.line(M,860,860);fact(M,913);d.rect(M,1040,860,238,'#fff1b5',30);total(106,1080,780);break;
case 'compact': case 'compact':
@ -166,12 +192,13 @@
} }
function composition(item){const values=item.compositionTotal?.length?item.compositionTotal:item.composition||[];return (Array.isArray(values)?values:[values]).map(v=>typeof v==='string'?v:v?.name||'').filter(Boolean).join(' · ')} function composition(item){const values=item.compositionTotal?.length?item.compositionTotal:item.composition||[];return (Array.isArray(values)?values:[values]).map(v=>typeof v==='string'?v:v?.name||'').filter(Boolean).join(' · ')}
async function renderPages(s){ async function renderPages(s){
const cfg=settings(s),items=s.items||[],t={...(THEMES[s.offerTemplateId]||THEMES.light),scale:items.length>24?2:SCALE},cache=new Map(); const cfg=settings(s),items=s.items||[],t={...THEMES[resolveTemplate(s.offerTemplateId)],scale:items.length>24?2:SCALE},cache=new Map();
const img=src=>{if(!src)return Promise.resolve(null);if(!cache.has(src))cache.set(src,loadImage(src));return cache.get(src)}; const img=src=>{if(!src)return Promise.resolve(null);if(!cache.has(src))cache.set(src,loadImage(src));return cache.get(src)};
await ready(); await ready();
const [logo,images,gallery]=await Promise.all([img(s.logo),Promise.all(items.map(i=>img((i.photoData||i.photo)===s.logo?'':i.photoData||i.photo))),Promise.all((s.finalGallery||[]).map(img))]); const [rawLogo,images,gallery]=await Promise.all([img(s.logo),Promise.all(items.map(i=>img((i.photoData||i.photo)===s.logo?'':i.photoData||i.photo))),Promise.all((s.finalGallery||[]).map(img))]);
const logo=logoForDocument(rawLogo,t);
const covers=[cover(s,t,images,logo,cfg)],pages=[...covers];let d,y; const covers=[cover(s,t,images,logo,cfg)],pages=[...covers];let d,y;
const muted=t.dark?'#c0cabf':'#686a5f',lineColor=t.dark?'#426055':'#d9d2c4',panel=t.dark?'#203b30':'#ffffff'; const muted=t.dark?'#d1d6ce':'#515b50',lineColor=t.dark?'#426055':'#d9d2c4',panel=t.dark?'#203b30':'#ffffff';
// A sunny cover is paired with warm paper inside, making long menus easier to read. // A sunny cover is paired with warm paper inside, making long menus easier to read.
const inner={...t,bg:t.id==='solar-experience'?'#fff9e8':t.bg}; const inner={...t,bg:t.id==='solar-experience'?'#fff9e8':t.bg};
function newPage(title){d=page(inner,'content');pages.push(d.c);brand(d,s,t,logo,{y:48});d.line(M,135,860,lineColor);y=172;if(title){y+=d.write(title,M,y,860,{size:36,font:d.display(36),lh:46});y+=28}} function newPage(title){d=page(inner,'content');pages.push(d.c);brand(d,s,t,logo,{y:48});d.line(M,135,860,lineColor);y=172;if(title){y+=d.write(title,M,y,860,{size:36,font:d.display(36),lh:46});y+=28}}
@ -186,32 +213,48 @@
for(const value of covers[0].__proposalOverflow||[])flow(value,{size:22}); for(const value of covers[0].__proposalOverflow||[])flow(value,{size:22});
const meta=it=>[it.categoryName,it.weight?`Вес: ${it.weight}`:'',number(it.pieces)>0?`${qty(it.pieces)} шт. в порции`:''].filter(Boolean).join(' · '); const meta=it=>[it.categoryName,it.weight?`Вес: ${it.weight}`:'',number(it.pieces)>0?`${qty(it.pieces)} шт. в порции`:''].filter(Boolean).join(' · ');
function priceLine(it){return `${qty(it.qty)} × ${money(it.unitPrice)} = ${money(it.sum)}`} function priceLine(it){return `${qty(it.qty)} × ${money(it.unitPrice)} = ${money(it.sum)}`}
function measure(it,width,card){return d.lines(it.name||'Позиция меню',width,d.body(card?25:24,700)).length*(card?33:32)+d.lines(meta(it),width,d.body(16)).length*23+(composition(it)?d.lines(composition(it),width,d.body(18)).length*26+12:0)} function measure(it,width,card){return d.lines(it.name||'Позиция меню',width,d.body(card?25:24,700)).length*(card?33:32)+10+(meta(it)?d.lines(meta(it),width,d.body(18)).length*26+10:0)+(composition(it)?d.lines(composition(it),width,d.body(20)).length*29:0)}
function details(it,x,yy,width,card){ function details(it,x,yy,width,card){
yy+=d.write(it.name||'Позиция меню',x,yy,width,{size:card?25:24,weight:700,lh:card?33:32,role:'item-name'})+10; yy+=d.write(it.name||'Позиция меню',x,yy,width,{size:card?25:24,weight:700,lh:card?33:32,role:'item-name'})+10;
if(meta(it))yy+=d.write(meta(it),x,yy,width,{size:16,lh:23,color:muted,role:'item-meta'})+10; if(meta(it))yy+=d.write(meta(it),x,yy,width,{size:18,lh:26,color:muted,role:'item-meta'})+10;
if(composition(it))yy+=d.write(composition(it),x,yy,width,{size:18,lh:26,color:muted,role:'composition'}); if(composition(it))yy+=d.write(composition(it),x,yy,width,{size:20,lh:29,color:muted,role:'composition'});
return yy; return yy;
} }
function tallItem(it,index){ function tallItem(it,index){
if(y>280)newPage(title+' · продолжение'); if(y>280)newPage(title+' · продолжение');
flow(it.name||'Позиция меню',{size:25,font:d.body(25,700),role:'item-name'});flow(meta(it),{size:16,color:muted});flow(composition(it),{size:18,color:muted,role:'composition'});flow(priceLine(it),{size:21,role:'item-price'});d.line(M,y-4,860,lineColor);y+=24; flow(it.name||'Позиция меню',{size:25,font:d.body(25,700),role:'item-name'});flow(meta(it),{size:18,color:muted});flow(composition(it),{size:20,color:muted,role:'composition'});flow(priceLine(it),{size:21,role:'item-price'});d.line(M,y-4,860,lineColor);y+=24;
} }
if(!items.length)flow('Позиции меню пока не добавлены.',{color:muted}); if(!items.length)flow('Позиции меню пока не добавлены.',{color:muted});
if(t.menu==='cards'){ if(t.id==='event-story'){
for(let index=0;index<items.length;index++){
const it=items[index],photoW=cfg.showGallery&&images[index]?190:0,x=M+24+(photoW?photoW+28:0),width=906-x;
const h=Math.max(photoW?138:0,measure(it,width,false))+90;
if(h>1060){tallItem(it,index);continue}
ensure(h+20,title+' · продолжение');d.rect(M,y,860,h,'#fffaf1',12);
if(photoW)d.photo(images[index],M+24,y+24,photoW,138,{radius:6});
details(it,x,y+24,width,false);const priceY=y+h-44;
d.line(x,priceY-10,width,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x,priceY,width*.52,{size:18,color:muted,role:'quantity-price'});
d.write(money(it.sum),x+width*.55,priceY,width*.45,{size:23,weight:700,align:'right',role:'item-price'});y+=h+20;
}
}else if(t.menu==='cards'){
const cw=415,gap=30,photoH=cfg.showGallery?160:0; const cw=415,gap=30,photoH=cfg.showGallery?160:0;
for(let index=0;index<items.length;){ for(let index=0;index<items.length;){
const pair=items.slice(index,index+2),heights=pair.map(it=>measure(it,cw-40,true)+photoH+132); const pair=items.slice(index,index+2),heights=pair.map(it=>measure(it,cw-40,true)+photoH+132);
if(heights.some(h=>h>1080)){tallItem(items[index],index);index++;continue} if(heights.some(h=>h>1080)){tallItem(items[index],index);index++;continue}
const h=Math.max(...heights);ensure(h+24,title+' · продолжение'); const h=Math.max(...heights);ensure(h+24,title+' · продолжение');
pair.forEach((it,k)=>{const x=M+k*(cw+gap);d.rect(x,y,cw,h,panel,12);if(photoH)d.photo(images[index+k],x,y,cw,photoH,{radius:[12,12,0,0]});details(it,x+20,y+photoH+24,cw-40,true);const priceY=y+h-75;d.line(x+20,priceY-15,cw-40,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x+20,priceY,cw-40,{size:16,color:muted,role:'quantity-price'});d.write(money(it.sum),x+20,priceY+28,cw-40,{size:23,weight:700,align:'right',role:'item-price'})});y+=h+24;index+=pair.length; pair.forEach((it,k)=>{const x=M+k*(cw+gap),r=t.id==='editorial-grid'?0:16;d.rect(x,y,cw,h,t.id==='editorial-grid'?t.bg:panel,r);if(photoH)d.photo(images[index+k],x,y,cw,photoH,{radius:[r,r,0,0]});details(it,x+20,y+photoH+24,cw-40,true);const priceY=y+h-75;d.line(x+20,priceY-15,cw-40,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x+20,priceY,cw-40,{size:18,color:muted,role:'quantity-price'});d.write(money(it.sum),x+20,priceY+28,cw-40,{size:23,weight:700,align:'right',role:'item-price'})});y+=h+24;index+=pair.length;
} }
}else{ }else{
const photoW=cfg.showGallery?(t.menu==='compact'?98:145):0,tx=M+(photoW?photoW+25:0),tw=860-(tx-M)-185; const photoW=cfg.showGallery&&images.some(Boolean)?(t.id==='event-ticket'?90:t.id==='midnight-glass'?120:145):0,tx=M+(photoW?photoW+25:0),tw=860-(tx-M)-185;
const rowHeight=it=>Math.max(photoW,measure(it,tw,false)+40,d.lines(money(it.sum),176,d.body(23,700)).length*32+12+d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)).length*23)+42;
const totalMenuHeight=items.reduce((sum,it)=>sum+rowHeight(it),0),capacity=BOTTOM-y;
const target=totalMenuHeight/Math.max(1,Math.ceil(totalMenuHeight/Math.max(1,capacity)));let menuStart=y;
for(let index=0;index<items.length;index++){ for(let index=0;index<items.length;index++){
const it=items[index],amountLines=d.lines(money(it.sum),176,d.body(23,700)),amountH=amountLines.length*32,unitLines=d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)),h=Math.max(photoW,measure(it,tw,false)+40,amountH+12+unitLines.length*23)+42; const it=items[index],amountLines=d.lines(money(it.sum),176,d.body(23,700)),amountH=amountLines.length*32,unitLines=d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)),h=Math.max(photoW,measure(it,tw,false)+40,amountH+12+unitLines.length*23)+42;
if(h>1080){tallItem(it,index);continue} if(h>1080){tallItem(it,index);continue}
ensure(h,title+' · продолжение');if(photoW)d.photo(images[index],M,y,photoW,photoW,{radius:t.menu==='compact'?49:9});details(it,tx,y,tw,false); // Balance ordinary rows across pages instead of leaving a short tail page.
if(target>0&&y>menuStart&&y+h>menuStart+target+h*.4){newPage(title+' · продолжение');menuStart=y}
ensure(h,title+' · продолжение');if(t.id==='event-ticket'&&index%2===0)d.rect(M-12,y-12,884,h-20,'#e3ebec',6);if(photoW)d.photo(images[index],M,y,photoW,photoW,{radius:t.id==='midnight-glass'?photoW/2:6});details(it,tx,y,tw,false);
d.write(amountLines,754,y,176,{size:23,lh:32,weight:700,align:'right',role:'item-price'}); d.write(amountLines,754,y,176,{size:23,lh:32,weight:700,align:'right',role:'item-price'});
d.write(unitLines,754,y+amountH+12,176,{size:16,lh:23,color:muted,align:'right',role:'quantity-price'}); d.write(unitLines,754,y+amountH+12,176,{size:16,lh:23,color:muted,align:'right',role:'quantity-price'});
d.line(M,y+h-23,860,lineColor);y+=h; d.line(M,y+h-23,860,lineColor);y+=h;
@ -219,13 +262,17 @@
} }
const p=s.pricing||{},text=cfg.texts; const p=s.pricing||{},text=cfg.texts;
const rows=cfg.showPriceBreakdown?[[text.priceItemsLabel||'Стоимость позиций',p.base],...(number(p.manual)?[[text.discountLabel||'Скидка',-number(p.manual)]]:[]),...(number(p.promo)?[[`${text.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-number(p.promo)]]:[]),[text.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[text.deliveryLabel||'Доставка',p.delivery]]:[]; const rows=cfg.showPriceBreakdown?[[text.priceItemsLabel||'Стоимость позиций',p.base],...(number(p.manual)?[[text.discountLabel||'Скидка',-number(p.manual)]]:[]),...(number(p.promo)?[[`${text.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-number(p.promo)]]:[]),[text.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[text.deliveryLabel||'Доставка',p.delivery]]:[];
ensure(200); // Keep the calculation and its total together when they fit on one page.
const priceRowHeight=([label,value])=>Math.max(d.lines(label,610,d.body(19)).length*28,d.lines(money(value),215,d.body(21,700)).length*29)+22;
const summaryHeight=Math.max(60,d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29)).length*39,d.lines(money(p.total),460,d.body(35,700)).length*45);
const pricingHeight=22+d.lines(text.pricingTitle||'Расчёт стоимости',860,d.display(34)).length*34*1.45+24+rows.reduce((n,row)=>n+priceRowHeight(row),0)+28+summaryHeight+26+85;
ensure(Math.min(pricingHeight,BOTTOM-172));
y+=22;flow(text.pricingTitle||'Расчёт стоимости',{size:34,font:d.display(34),gap:24}); y+=22;flow(text.pricingTitle||'Расчёт стоимости',{size:34,font:d.display(34),gap:24});
for(const [label,value] of rows){ for(const [label,value] of rows){
const ls=d.lines(label,610,d.body(19)),rs=d.lines(money(value),215,d.body(21,700)),h=Math.max(ls.length*28,rs.length*29)+22;ensure(h+95); const ls=d.lines(label,610,d.body(19)),rs=d.lines(money(value),215,d.body(21,700)),h=Math.max(ls.length*28,rs.length*29)+22;ensure(h+95);
d.write(ls,M,y,610,{size:19,lh:28,color:muted,role:'pricing-label'});d.write(rs,715,y,215,{size:21,weight:700,lh:29,align:'right',role:'pricing-value'});y+=h; d.write(ls,M,y,610,{size:19,lh:28,color:muted,role:'pricing-label'});d.write(rs,715,y,215,{size:21,weight:700,lh:29,align:'right',role:'pricing-value'});y+=h;
} }
ensure(124);d.line(M,y,860,lineColor);y+=28;const totalLabel=d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29));d.write(totalLabel,M,y,380,{size:29,font:d.display(29),lh:39,role:'total-label'});const amount=d.lines(money(p.total),460,d.body(35,700));y+=Math.max(60,totalLabel.length*39,d.write(amount,470,y,460,{size:35,weight:700,lh:45,align:'right',role:'total-value'}))+26; ensure(28+summaryHeight+26);d.line(M,y,860,lineColor);y+=28;const totalLabel=d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29));d.write(totalLabel,M,y,380,{size:29,font:d.display(29),lh:39,role:'total-label'});const amount=d.lines(money(p.total),460,d.body(35,700));y+=Math.max(60,totalLabel.length*39,d.write(amount,470,y,460,{size:35,weight:700,lh:45,align:'right',role:'total-value'}))+26;
const metrics=[];const guests=number(s.guests); const metrics=[];const guests=number(s.guests);
if(cfg.showBoxCount)metrics.push(`${text.boxCountLabel||'Количество боксов'}: ${qty(items.filter(i=>[0,5].includes(number(i.categoryId))).reduce((n,i)=>n+number(i.qty),0))}`); if(cfg.showBoxCount)metrics.push(`${text.boxCountLabel||'Количество боксов'}: ${qty(items.filter(i=>[0,5].includes(number(i.categoryId))).reduce((n,i)=>n+number(i.qty),0))}`);
if(cfg.showGuests&&guests){if(cfg.showAmountPerGuest)metrics.push(`${text.amountPerGuestLabel||'Сумма на гостя'}: ${money(number(p.itemsTotal)/guests)}`);if(cfg.metricMode==='pieces'){if(number(s.foodPieces))metrics.push(`${text.piecesMetricLabel||'Канапе на гостя'}: ${qty(Math.ceil(number(s.foodPieces)/guests))} шт.`)}else if(number(s.foodGrams))metrics.push(`${text.weightMetricLabel||'Вес еды на гостя'}: ${qty(Math.ceil(number(s.foodGrams)/guests/5)*5)} г`)} if(cfg.showGuests&&guests){if(cfg.showAmountPerGuest)metrics.push(`${text.amountPerGuestLabel||'Сумма на гостя'}: ${money(number(p.itemsTotal)/guests)}`);if(cfg.metricMode==='pieces'){if(number(s.foodPieces))metrics.push(`${text.piecesMetricLabel||'Канапе на гостя'}: ${qty(Math.ceil(number(s.foodPieces)/guests))} шт.`)}else if(number(s.foodGrams))metrics.push(`${text.weightMetricLabel||'Вес еды на гостя'}: ${qty(Math.ceil(number(s.foodGrams)/guests/5)*5)} г`)}
@ -244,5 +291,5 @@
return pages; return pages;
} }
const descriptions={split:'Светлая обложка, вертикальное фото и спокойная типографика.',night:'Тёмная полоса с текстом и фотография на всю высоту.',editorial:'Журнальная сетка, выразительные заголовки и карточки меню.',sun:'Тёплая бумага, солнечный акцент и широкая фотография.',bento:'Модульная фотокомпозиция и меню в двух колонках.',story:'История события с этапами подготовки и панорамным фото.',photo:'Фотография на всю обложку и золотистые акценты.',letter:'Личное обращение на светлой бумаге и курсивные заголовки.',ticket:'Обложка в форме приглашения с отдельной полосой даты.',solar:'Яркая солнечная обложка и светлые внутренние страницы.',compact:'Лаконичная ночная палитра и компактный список меню.',frame:'Тонкая золотистая рамка и симметричная композиция.', 'neon-vertical':'Вертикальный акцент и пара круглых фотографий.',arch:'Кремовая бумага, арочное фото и классические заголовки.',neon:'Яркий заголовок на тёмной бумаге и модульные фото.',circles:'Круглые фотографии разных размеров и изумрудная палитра.',checklist:'Нумерованные акценты меню на строгой тёмной обложке.',gallery:'Три фотографии в арках и крупная журнальная типографика.',diamond:'Фотография в ромбе и изумрудно-золотая палитра.',panorama:'Широкая панорама и сдержанные современные заголовки.',botanical:'Высокая арка, курсивные заголовки и глубокий изумрудный фон.'}; const descriptions={split:'Светлая обложка, вертикальное фото и спокойная типографика.',night:'Тёмная полоса с текстом и фотография на всю высоту.',editorial:'Журнальная сетка, выразительные заголовки и карточки меню.',sun:'Тёплая бумага, солнечный акцент и широкая фотография.',bento:'Модульная фотокомпозиция и меню в двух колонках.',story:'История события с этапами подготовки и панорамным фото.',photo:'Фотография на всю обложку и золотистые акценты.',letter:'Личное обращение на светлой бумаге и курсивные заголовки.',ticket:'Обложка в форме приглашения с отдельной полосой даты.',solar:'Яркая солнечная обложка и светлые внутренние страницы.',compact:'Лаконичная ночная палитра и компактный список меню.',frame:'Тонкая золотистая рамка и симметричная композиция.', 'neon-vertical':'Вертикальный акцент и пара круглых фотографий.',arch:'Кремовая бумага, арочное фото и классические заголовки.',neon:'Яркий заголовок на тёмной бумаге и модульные фото.',circles:'Круглые фотографии разных размеров и изумрудная палитра.',checklist:'Нумерованные акценты меню на строгой тёмной обложке.',gallery:'Три фотографии в арках и крупная журнальная типографика.',diamond:'Фотография в ромбе и изумрудно-золотая палитра.',panorama:'Широкая панорама и сдержанные современные заголовки.',botanical:'Высокая арка, курсивные заголовки и глубокий изумрудный фон.'};
window.CateriumProposalPDF=Object.freeze({VERSION:'20260918-proposal-quality',IDS:IDs,templates:definitions.map(([id,name,style])=>({id,name,desc:descriptions[style],thumb:`offer-templates/quality-${id}.jpg`})),ready,renderPages}); window.CateriumProposalPDF=Object.freeze({VERSION:'20260918-proposals-six',IDS:IDs,CURATED_IDS,resolveTemplate,templates:CURATED_IDS.map(id=>({id,name:names[id],desc:descriptions[THEMES[id].cover],thumb:`offer-templates/quality-${id}.jpg`})),ready,renderPages});
})(); })();

View File

@ -130,7 +130,7 @@
function templateList(){try{return window.SunOfferTemplate?.list?.()||[]}catch(_){return[]}} function templateList(){try{return window.SunOfferTemplate?.list?.()||[]}catch(_){return[]}}
function validTemplate(id){return TEMPLATE_IDS.has(String(id||''))} function validTemplate(id){return TEMPLATE_IDS.has(String(id||''))}
function storedTemplateId(order){ function storedTemplateId(order){
const id=order?.clientOfferSnapshot?.offerTemplateId||order?.clientOfferTemplateId||'';return validTemplate(id)?String(id):''; const id=order?.clientOfferSnapshot?.offerTemplateId||order?.clientOfferTemplateId||'';return validTemplate(id)?(window.CateriumProposalPDF?.resolveTemplate(String(id))||String(id)):'';
} }
function globalTemplateId(){ function globalTemplateId(){
try{const state=originalOfferGet?originalOfferGet():window.SunOfferTemplate?.get?.();return validTemplate(state?.id)?state.id:'light'}catch(_){return'light'} try{const state=originalOfferGet?originalOfferGet():window.SunOfferTemplate?.get?.();return validTemplate(state?.id)?state.id:'light'}catch(_){return'light'}
@ -161,14 +161,14 @@
const id=globalTemplateId();persistOfferTemplate(activeOfferOrderId,id);return id; const id=globalTemplateId();persistOfferTemplate(activeOfferOrderId,id);return id;
} }
function offerTemplateMini(id){ function offerTemplateMini(id){
if(window.CateriumProposalPDF?.IDS.includes(id))return `<img src="offer-templates/quality-${id}.jpg?v=20260918" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#eeece5;z-index:2">`; if(window.CateriumProposalPDF?.IDS.includes(id))return `<img src="offer-templates/quality-${id}.jpg?v=20260918-six" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#eeece5;z-index:2">`;
if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>'; if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>';
if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>'; if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>';
if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>'; if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>';
return '<i class="mini-head"></i><i class="mini-light-hero"></i><i class="mini-light-row a"></i><i class="mini-light-row b"></i><i class="mini-light-row c"></i><i class="mini-light-total"></i>'; return '<i class="mini-head"></i><i class="mini-light-hero"></i><i class="mini-light-row a"></i><i class="mini-light-row b"></i><i class="mini-light-row c"></i><i class="mini-light-total"></i>';
} }
function pickerHtml(){ function pickerHtml(){
const current=activeTemplateId();return `<div class="sun-v1764-offer-template-head"><div><b>Оформление PDF</b><small>Классическая и современная коллекции. Предварительный просмотр совпадает со скачиваемым PDF.</small></div></div><div class="sun-v1764-offer-template-grid">${templateList().map(t=>`<button type="button" data-v1764-offer-template="${esc(t.id)}" class="${t.id===current?'on':''}"><div class="sun-offer-template-mini sun-offer-template-mini-${esc(t.id)}" data-template-mini="${esc(t.id)}" aria-hidden="true">${offerTemplateMini(t.id)}</div><span>${esc(t.name||t.id)}</span></button>`).join('')}</div>`; const current=activeTemplateId();return `<div class="sun-v1764-offer-template-head"><div><b>Оформление PDF</b><small>Шесть макетов для разных событий. Ваш логотип сохраняет пропорции. Просмотр совпадает со скачиваемым PDF.</small></div></div><div class="sun-v1764-offer-template-grid">${templateList().map(t=>`<button type="button" data-v1764-offer-template="${esc(t.id)}" class="${t.id===current?'on':''}"><div class="sun-offer-template-mini sun-offer-template-mini-${esc(t.id)}" data-template-mini="${esc(t.id)}" aria-hidden="true">${offerTemplateMini(t.id)}</div><span>${esc(t.name||t.id)}</span></button>`).join('')}</div>`;
} }
function ensureOfferPicker(){ function ensureOfferPicker(){
patchOfferTemplateApi();const modal=$('sunClientOfferModal'),dialog=modal?.querySelector('.dialog');if(!dialog||!activeOfferOrderId)return false; patchOfferTemplateApi();const modal=$('sunClientOfferModal'),dialog=modal?.querySelector('.dialog');if(!dialog||!activeOfferOrderId)return false;

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -1,5 +1,5 @@
const CACHE='sun-catering-pwa-v105-20260918-russia-proxy-rpc'; const CACHE='sun-catering-pwa-v106-20260918-proposals-six';
const VERSION='20260918-russia-proxy-rpc'; const VERSION='20260918-proposals-six';
const CORE=[ const CORE=[
'./vendor/supabase-2.112.4.min.js', './vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, `./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,

View File

@ -7,6 +7,7 @@ export default defineConfig({
use:{baseURL:'http://127.0.0.1:4173'}, 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}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[ 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:['help-center.spec.mjs','login-recovery.spec.mjs','account-access.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-webkit',testMatch:['help-center.spec.mjs','login-recovery.spec.mjs','account-access.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}

View File

@ -78,11 +78,11 @@ function validLayout(pages){
return pages.flatMap((p,index)=>p.__proposalLayout.filter(b=>b.x<35||b.x+b.w>965||b.y<0||b.y+b.h>1400).map(b=>({page:index+1,...b}))); return pages.flatMap((p,index)=>p.__proposalLayout.filter(b=>b.x<35||b.x+b.w>965||b.y<0||b.y+b.h>1400).map(b=>({page:index+1,...b})));
} }
test('all 21 selections render their actual distinct covers and local Cyrillic fonts',async({page})=>{ test('all six selections render their actual distinct covers and local Cyrillic fonts',async({page})=>{
test.setTimeout(90000);await boot(page); test.setTimeout(90000);await boot(page);
const result=await page.evaluate(async s=>{ const result=await page.evaluate(async s=>{
const out=[]; const out=[];
for(const id of CateriumProposalPDF.IDS){ for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:id}); const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:id});
const text=pages.flatMap(p=>p.__proposalLayout).map(x=>x.text).join('\n'); const text=pages.flatMap(p=>p.__proposalLayout).map(x=>x.text).join('\n');
out.push({id,cover:pages[0].dataset.sunProposalTemplate,width:pages[0].width,pages:pages.length,text,pixel:pages[0].toDataURL('image/jpeg',.1)}); out.push({id,cover:pages[0].dataset.sunProposalTemplate,width:pages[0].width,pages:pages.length,text,pixel:pages[0].toDataURL('image/jpeg',.1)});
@ -90,9 +90,9 @@ test('all 21 selections render their actual distinct covers and local Cyrillic f
} }
return {out,fonts:[...document.fonts].filter(f=>f.family.includes('Caterium')).map(f=>f.status),choices:SunOfferTemplate.list().map(t=>t.id)}; return {out,fonts:[...document.fonts].filter(f=>f.family.includes('Caterium')).map(f=>f.status),choices:SunOfferTemplate.list().map(t=>t.id)};
},base); },base);
expect(result.out).toHaveLength(21);expect(result.choices).toHaveLength(21); expect(result.out).toHaveLength(6);expect(result.choices).toHaveLength(6);
expect(result.fonts).toEqual(['loaded','loaded','loaded']); expect(result.fonts).toEqual(['loaded','loaded','loaded']);
expect(new Set(result.out.map(r=>r.pixel)).size).toBe(21); expect(new Set(result.out.map(r=>r.pixel)).size).toBe(6);
for(const row of result.out){expect(row.cover).toBe(row.id);expect(row.width).toBe(2400);expect(row.text.replaceAll('\n',' ')).toContain(base.items[0].name);expect(row.text).toContain('23');expect(row.text).not.toMatch(/Солнце|SUN \/ CATERING/)} for(const row of result.out){expect(row.cover).toBe(row.id);expect(row.width).toBe(2400);expect(row.text.replaceAll('\n',' ')).toContain(base.items[0].name);expect(row.text).toContain('23');expect(row.text).not.toMatch(/Солнце|SUN \/ CATERING/)}
}); });
@ -103,7 +103,7 @@ test('flow preserves long Cyrillic names and compositions across pages without t
const long='Очень длинное название блюда с запечённым баклажаном и сливочным сыром '; const long='Очень длинное название блюда с запечённым баклажаном и сливочным сыром ';
const composition='НачалоСостава '+('Томаты и печёный перец, сливочный сыр; '.repeat(130))+' КонецСостава'; const composition='НачалоСостава '+('Томаты и печёный перец, сливочный сыр; '.repeat(130))+' КонецСостава';
const sample={...s,event:long.repeat(3),client:long.repeat(2),items:[{...s.items[0],name:long.repeat(4),composition:[composition]}]}; const sample={...s,event:long.repeat(3),client:long.repeat(2),items:[{...s.items[0],name:long.repeat(4),composition:[composition]}]};
for(const id of ['light','editorial-grid','neon-menu','premium-emerald']){ for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await sunClientOfferDebugPdfPages({...sample,offerTemplateId:id}); const pages=await sunClientOfferDebugPdfPages({...sample,offerTemplateId:id});
const layout=pages.flatMap(p=>p.__proposalLayout),text=layout.map(b=>b.text).join(' ').replace(/\s+/g,' '); const layout=pages.flatMap(p=>p.__proposalLayout),text=layout.map(b=>b.text).join(' ').replace(/\s+/g,' ');
const collisions=[]; const collisions=[];
@ -114,6 +114,62 @@ test('flow preserves long Cyrillic names and compositions across pages without t
for(const r of results){expect(r.bounds).toEqual([]);expect(r.collisions).toEqual([]);expect(r.text).toContain('НачалоСостава');expect(r.text).toContain('КонецСостава');expect(r.text).not.toContain('…')} for(const r of results){expect(r.bounds).toEqual([]);expect(r.collisions).toEqual([]);expect(r.text).toContain('НачалоСостава');expect(r.text).toContain('КонецСостава');expect(r.text).not.toContain('…')}
}); });
test('legacy choices resolve to a selectable design without changing saved orders',async({page})=>{
await page.addInitScript(()=>localStorage.setItem('sunOfferTemplateV1',JSON.stringify({id:'cream-elegance'})));
await boot(page);
const result=await page.evaluate(async s=>{
const order={id:'old-design',clientOfferTemplateId:'emerald-gold',clientOfferSnapshot:{...s,offerTemplateId:'emerald-gold'}};
orders=[order];const before=JSON.stringify(order);
const pages=await sunClientOfferDebugPdfPages(order.clientOfferSnapshot);
return {default:SunOfferTemplate.get().id,cover:pages[0].dataset.sunProposalTemplate,unchanged:before===JSON.stringify(order),valid:CateriumProposalPDF.IDS.every(id=>CateriumProposalPDF.CURATED_IDS.includes(CateriumProposalPDF.resolveTemplate(id)))};
},base);
expect(result).toEqual({default:'light',cover:'midnight-glass',unchanged:true,valid:true});
});
test('transparent wide, tall and white PNG logos keep their proportions and remain visible on every design',async({page})=>{
test.setTimeout(90000);await boot(page);
const result=await page.evaluate(async s=>{
const rows=[];
for(const [shape,color] of [['wide','#214735'],['tall','#214735'],['wide','#ffffff']]){
const c=document.createElement('canvas');c.width=1400;c.height=800;const ctx=c.getContext('2d');ctx.fillStyle=color;
const w=shape==='wide'?900:160,h=shape==='wide'?150:560;ctx.fillRect(200,100,w,h);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
const data=await CateriumBranding.saveLogo(new File([blob],'company.png',{type:'image/png'}));
const saved=new Image();saved.src=data;await saved.decode();
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await CateriumProposalPDF.renderPages({...s,offerTemplateId:id,logo:data});
const image=pages[0].__proposalImages[0],canvas=pages[0],scale=canvas.width/1000;
const bg=Array.from(canvas.getContext('2d').getImageData(20*scale,80*scale,1,1).data);
const colorAtLogo=Array.from(canvas.getContext('2d').getImageData((image.x+image.w/2)*scale,(image.y+image.h/2)*scale,1,1).data);
const collisions=pages.flatMap(p=>(p.__proposalImages||[]).flatMap(im=>p.__proposalLayout.filter(b=>Math.min(im.x+im.w,b.x+b.w)-Math.max(im.x,b.x)>2&&Math.min(im.y+im.h,b.y+b.h)-Math.max(im.y,b.y)>2)));
rows.push({id,shape,color,w:image.w,h:image.h,aspect:image.w/image.h,sourceAspect:saved.width/saved.height,bg,colorAtLogo,collisions});
pages.forEach(p=>{p.width=0;p.height=0});
}
}
return rows;
},base);
expect(result).toHaveLength(18);
for(const r of result){
expect(r.aspect).toBeCloseTo(r.sourceAspect,5);expect(r.collisions).toEqual([]);
if(r.shape==='wide')expect(r.w).toBeGreaterThan(250);else expect(r.h).toBe(76);
expect(r.colorAtLogo).not.toEqual(r.bg);
if(r.color==='#ffffff'){expect(r.bg[0]).toBeLessThan(50);expect(r.colorAtLogo[0]).toBeGreaterThan(245)}
}
});
test('multi-page menus keep a normal price breakdown and grand total together',async({page})=>{
await boot(page);
const result=await page.evaluate(async s=>{
const rows=[];
for(const id of CateriumProposalPDF.CURATED_IDS){
const pages=await CateriumProposalPDF.renderPages({...s,offerTemplateId:id,items:Array.from({length:9},(_,i)=>({...s.items[0],name:s.items[0].name+' '+i}))});
rows.push({id,prices:pages.map((p,i)=>p.__proposalLayout.some(b=>b.role==='pricing-value')?i:-1).filter(i=>i>=0),total:pages.findIndex(p=>p.__proposalLayout.some(b=>b.role==='total-value'))});
pages.forEach(p=>{p.width=0;p.height=0});
}return rows;
},base);
for(const r of result){expect(r.prices).toHaveLength(1);expect(r.prices[0]).toBe(r.total)}
});
test('offer settings hide personal data and optional blocks; custom gallery reaches the PDF',async({page})=>{ test('offer settings hide personal data and optional blocks; custom gallery reaches the PDF',async({page})=>{
await boot(page); await boot(page);
const result=await page.evaluate(async s=>{ const result=await page.evaluate(async s=>{
@ -129,14 +185,14 @@ test('offer settings hide personal data and optional blocks; custom gallery reac
expect(result.text).toContain('Специальное меню');expect(result.text).not.toContain(base.client);expect(result.text).not.toContain('октября');expect(result.text).not.toContain('Скидка');expect(result.text).not.toContain('гостей');expect(result.gallery).toBe(true);expect(result.bounds).toEqual([]); expect(result.text).toContain('Специальное меню');expect(result.text).not.toContain(base.client);expect(result.text).not.toContain('октября');expect(result.text).not.toContain('Скидка');expect(result.text).not.toContain('гостей');expect(result.gallery).toBe(true);expect(result.bounds).toEqual([]);
}); });
test('per-order picker retains the classic, modern and additional collections together',async({page})=>{ test('per-order picker presents six curated designs without redundant collection headings',async({page})=>{
await boot(page); await boot(page);
await page.evaluate(()=>{ await page.evaluate(()=>{
const grid=document.createElement('div');grid.className='sun-v1764-offer-template-grid'; const grid=document.createElement('div');grid.className='sun-v1764-offer-template-grid';
for(const id of CateriumProposalPDF.IDS){const b=document.createElement('button');b.dataset.v1764OfferTemplate=id;grid.appendChild(b)}document.body.appendChild(grid); for(const id of CateriumProposalPDF.CURATED_IDS){const b=document.createElement('button');b.dataset.v1764OfferTemplate=id;grid.appendChild(b)}document.body.appendChild(grid);
}); });
await expect(page.locator('.sun-v1764-offer-template-grid button')).toHaveCount(21); await expect(page.locator('.sun-v1764-offer-template-grid button')).toHaveCount(6);
await expect(page.locator('.sun-v1767-group-label')).toHaveCount(3); await expect(page.locator('.sun-v1767-group-label')).toHaveCount(0);
}); });
test('an actual offer downloads the same A4 pages shown in its preview',async({page})=>{ test('an actual offer downloads the same A4 pages shown in its preview',async({page})=>{

View File

@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('v105-20260918-russia-proxy-rpc')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); check(sw.includes('v106-20260918-proposals-six')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-russia-proxy-rpc')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(index.includes('20260918-proposals-six')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production'); check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v105-20260918-russia-proxy-rpc'),'release manifest points to current PWA cache'); check(String(releaseManifest.pwaCache||'').includes('v106-20260918-proposals-six'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
@ -55,7 +55,7 @@ check(runtime.includes("'warm-sun'")&&runtime.includes("'bento-cards'")&&runtime
check(runtime.includes("'midnight-compact'")&&runtime.includes("'black-gold'")&&runtime.includes("'neon-emerald'"),'hidden archive proposal templates are restored'); check(runtime.includes("'midnight-compact'")&&runtime.includes("'black-gold'")&&runtime.includes("'neon-emerald'"),'hidden archive proposal templates are restored');
check(runtime.includes('SunClassicOfferPDFV1767')&&runtime.includes('renderOfferPdfPagesBase'),'classic renderer wraps the same PDF pages used by preview/download'); check(runtime.includes('SunClassicOfferPDFV1767')&&runtime.includes('renderOfferPdfPagesBase'),'classic renderer wraps the same PDF pages used by preview/download');
check(classic.includes('data-sun-classic-cover')&&classic.includes('renderPages'),'classic renderer adds real A4 cover pages'); check(classic.includes('data-sun-classic-cover')&&classic.includes('renderPages'),'classic renderer adds real A4 cover pages');
check(releaseManifest.offerTemplates===21&&releaseManifest.classicOfferTemplates===10,'release manifest exposes all 21 proposal designs'); check(releaseManifest.offerTemplates===6&&releaseManifest.classicOfferTemplates===10,'release manifest exposes six curated proposals with legacy compatibility');
check(developerUX.includes("const VERSION='17.6.8'")&&developerUX.includes('sun_dev_delete_company_v1768')&&developerUX.includes('sun_dev_error_groups_v1768'),'Developer Console v17.6.8 module is versioned'); check(developerUX.includes("const VERSION='17.6.8'")&&developerUX.includes('sun_dev_delete_company_v1768')&&developerUX.includes('sun_dev_error_groups_v1768'),'Developer Console v17.6.8 module is versioned');
check(developerUX.includes('selectedAccountsWorkspace')&&developerUX.includes('sun-dev-plan-matrix'),'accounts are grouped by company and plans use compact matrix'); check(developerUX.includes('selectedAccountsWorkspace')&&developerUX.includes('sun-dev-plan-matrix'),'accounts are grouped by company and plans use compact matrix');
check(developerUX.includes('KNOWN_DOM_RACE')&&developerUX.includes('prepareAsyncRoots'),'Developer Console async DOM race is guarded'); check(developerUX.includes('KNOWN_DOM_RACE')&&developerUX.includes('prepareAsyncRoots'),'Developer Console async DOM race is guarded');
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
check(pkg.version==='17.7.3','package version is v17.7.3'); check(pkg.version==='17.7.3','package version is v17.7.3');
check(index.includes('20260918-russia-proxy-rpc'),'index cache bust is v17.7.3'); check(index.includes('20260918-proposals-six'),'index cache bust is v17.7.3');
check(sw.includes('v105-20260918-russia-proxy-rpc')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(sw.includes('v106-20260918-proposals-six')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');

View File

@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
if(!sw.includes('20260918-russia-proxy-rpc')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); if(!sw.includes('20260918-proposals-six')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-russia-proxy-rpc')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-proposals-six')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');