Separate account sidebar identity from company document branding

This commit is contained in:
pavlov346346-source 2026-09-17 19:51:01 +03:00
parent c31f868c83
commit 11b2bf84c9
19 changed files with 376 additions and 115 deletions

View File

@ -0,0 +1,13 @@
# Company document branding and one account-specific sidebar
The sidebar uses Caterium and its mark by default. A single server-side assignment can enable «Солнце Кейтеринг» for one confirmed auth user. This assignment is independent of workspace ownership and platform administration. Other employees in that workspace continue to see Caterium.
`core/company-branding.js` owns company name/logo handling. Settings → Documents → «Логотип и название компании» accepts PNG, JPEG and WebP, preserves transparency, and resizes to a maximum 1000 pixels. Company settings use the existing workspace sync and private media storage. Clearing a logo produces neutral documents; there is no fallback to the Sun logo. An upload interrupted by an account/workspace change is rejected.
Print forms, receipts, preparations, route sheets, catalogs and all proposal PDF templates use this company identity. PDF snapshots preserve the selected identity during asynchronous rendering. Existing selectable color themes remain available.
The migration `20260917193000_account_sidebar_brand.sql` was applied to `usfjwhztqoopzzfmfbis`. The account assignment and ordinary owner workspace were provisioned separately, without credentials in the repository and without platform admin rights. Email verification remains required.
Validation: `npm run check:deploy` and Playwright (62 passed, 2 existing skipped tests), including all 18 proposal covers, 21 complete proposal designs, catalog/route/receipt/preparation output, HTML escaping, transparent logos, workspace isolation, sidebar reset/race handling, SQL assignment constraints and RLS, and the previous first-paint theme regression tests.
PWA cache: `v84-20260917-company-branding`.

View File

@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
"pwaCache": "v83-20260917-theme-first-paint",
"pwaCache": "v84-20260917-company-branding",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,

View File

@ -4,7 +4,7 @@
"version": "17.7.3",
"type": "module",
"scripts": {
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js",
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js",
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
"check:release": "node tests/release-check.mjs",
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",

File diff suppressed because one or more lines are too long

View File

@ -87,7 +87,7 @@
return`<h2>Цвета интерфейса</h2><p class="hint">Выберите готовый вариант или настройте цвета вручную. Изменения сначала видны в примере. Реальная левая панель меняется только после «Сохранить настройки».</p>
<div class="sun-theme-toolbar"><button class="outline" type="button" data-theme-preset="standard">Стандартная фиолетовая</button><button class="outline" type="button" data-theme-preset="firm">Фирменная тёмная</button><button class="outline" type="button" data-theme-preset="light">Фирменная светлая</button><button class="outline" type="button" data-theme-preset="emerald">Фирменная зелёная</button><button class="outline" type="button" data-theme-preset="sunset">Солнечная графитовая</button></div>
<div class="sun-theme-sections">
<section class="sun-theme-box"><h3>Левая панель</h3><div class="sun-theme-panel-main"><div class="sun-theme-preview"><div class="sun-theme-preview-head"><div class="sun-theme-preview-logo"></div><div><b>Солнце</b><small>Предпросмотр боковой панели</small></div></div><div class="sun-theme-preview-menu"><div class="sun-theme-preview-row"><i></i>Главная</div><div class="sun-theme-preview-row on"><i></i>Заказы</div><div class="sun-theme-preview-row"><i></i>Настройки</div></div></div><div><h3 style="margin-top:0">Главные цвета панели</h3><div class="sun-sidebar-core-colors">${field('sidebar','background','Основной цвет панели',previewState.sidebar.background)}${field('sidebar','background2','Низ / второй цвет',previewState.sidebar.background2)}${field('sidebar','icons','Цвет значков',previewState.sidebar.icons)}</div><p class="hint">Основной цвет это фон всей большой левой панели. Для однотонной панели поставьте одинаковый цвет в первых двух полях.</p></div></div><details style="margin-top:12px"><summary><b>Дополнительные цвета панели</b></summary><div class="sun-brand-setting-grid" style="margin-top:10px">${Object.keys(SIDEBAR_FIRM).filter(k=>!['background','background2','icons'].includes(k)).map(k=>field('sidebar',k,SIDE_LABELS[k],previewState.sidebar[k])).join('')}</div></details></section>
<section class="sun-theme-box"><h3>Левая панель</h3><div class="sun-theme-panel-main"><div class="sun-theme-preview"><div class="sun-theme-preview-head"><div class="sun-theme-preview-logo"></div><div><b>Caterium</b><small>Предпросмотр боковой панели</small></div></div><div class="sun-theme-preview-menu"><div class="sun-theme-preview-row"><i></i>Главная</div><div class="sun-theme-preview-row on"><i></i>Заказы</div><div class="sun-theme-preview-row"><i></i>Настройки</div></div></div><div><h3 style="margin-top:0">Главные цвета панели</h3><div class="sun-sidebar-core-colors">${field('sidebar','background','Основной цвет панели',previewState.sidebar.background)}${field('sidebar','background2','Низ / второй цвет',previewState.sidebar.background2)}${field('sidebar','icons','Цвет значков',previewState.sidebar.icons)}</div><p class="hint">Основной цвет это фон всей большой левой панели. Для однотонной панели поставьте одинаковый цвет в первых двух полях.</p></div></div><details style="margin-top:12px"><summary><b>Дополнительные цвета панели</b></summary><div class="sun-brand-setting-grid" style="margin-top:10px">${Object.keys(SIDEBAR_FIRM).filter(k=>!['background','background2','icons'].includes(k)).map(k=>field('sidebar',k,SIDE_LABELS[k],previewState.sidebar[k])).join('')}</div></details></section>
<section class="sun-theme-box"><h3>Основной интерфейс</h3><div class="sun-brand-setting-grid">${Object.keys(UI_FIRM).map(k=>field('ui',k,UI_LABELS[k],previewState.ui[k])).join('')}</div><details class="sun-brand-palette-toggle"><summary>Показать фирменные цвета «Солнца»</summary><div class="sun-brand-palette">${paletteHtml()}</div></details></section>
</div><div class="sun-theme-savebar"><div class="sun-theme-dirty" data-theme-dirty>${dirty?'<strong>Есть несохранённые изменения</strong> · пока меняется только предпросмотр':'Все изменения сохранены'}</div><div class="actions"><button class="outline" type="button" data-theme-cancel>Отменить изменения</button><button class="danger" type="button" data-reset-standard>Вернуть стандартную версию</button><button class="primary" type="button" data-theme-save>Сохранить настройки</button></div></div>`;
}

View File

@ -49,19 +49,19 @@
function sun(ctx,cx,cy,r,color,rays=18){ctx.save();ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=2;for(let i=0;i<rays;i++){const a=i*Math.PI*2/rays,ri=r+8,ro=r+24+(i%2)*8;ctx.beginPath();ctx.moveTo(cx+Math.cos(a)*ri,cy+Math.sin(a)*ri);ctx.lineTo(cx+Math.cos(a)*ro,cy+Math.sin(a)*ro);ctx.stroke()}ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.restore()}
function factPills(ctx,s,p,x,y,w,{dark=false}={}){const facts=[];if(s.date)facts.push(dateText(s.date));if(s.guests)facts.push(String(s.guests)+' '+plural(s.guests,'гость','гостя','гостей'));if(s.time)facts.push('Доставка '+String(s.time));facts.push(String(Math.round(classicBoxCount(s)))+' '+plural(s.boxCount,'бокс','бокса','боксов'));const gap=8,cw=(w-gap*(facts.length-1))/facts.length;facts.forEach((f,i)=>{const xx=x+i*(cw+gap);roundRect(ctx,xx,y,cw,50,14,dark?'rgba(255,255,255,.07)':p.paper,dark?'rgba(255,255,255,.22)':p.line);text(ctx,f,xx+cw/2,y+16,cw-16,18,{font:'12px Arial',color:dark?'#f7f2e7':p.ink,align:'center',maxLines:1})})}
function totalBlock(ctx,s,p,x,y,w,h,{dark=false,accentFill=false}={}){roundRect(ctx,x,y,w,h,18,accentFill?p.accent:(dark?'rgba(10,10,10,.58)':p.paper),dark?'rgba(255,255,255,.2)':p.line);text(ctx,'ИТОГОВАЯ СТОИМОСТЬ',x+22,y+18,w-44,18,{font:'700 11px Arial',color:accentFill?p.deep:p.accent,maxLines:1});text(ctx,money(s.pricing?.total),x+22,y+48,w-44,46,{font:'500 34px Georgia',color:accentFill?p.deep:(dark?'#fff':p.deep),maxLines:1});if(s.guests)text(ctx,'на гостя: '+money((Number(s.pricing?.itemsTotal||s.pricing?.total||0))/Math.max(1,Number(s.guests))),x+22,y+96,w-44,20,{font:'12px Arial',color:accentFill?p.deep:(dark?'#ddd3c1':p.muted),maxLines:1})}
function logoOrBrand(ctx,logo,p,{x=M,y=46,dark=false}={}){if(logo){drawContain(ctx,logo,x,y,190,64);return}sun(ctx,x+28,y+29,15,p.accent,16);text(ctx,'СОЛНЦЕ',x+60,y+11,220,28,{font:'700 24px Arial',color:dark?'#fff':p.ink,maxLines:1});text(ctx,'КЕЙТЕРИНГ',x+61,y+41,180,18,{font:'11px Arial',color:p.accent,maxLines:1})}
function logoOrBrand(ctx,logo,p,{x=M,y=46,dark=false}={}){if(logo){const scale=Math.min(190/logo.naturalWidth,64/logo.naturalHeight);ctx.drawImage(logo,x,y,logo.naturalWidth*scale,logo.naturalHeight*scale);return}text(ctx,p.brandName||'Моя компания',x,y+8,300,28,{font:'700 22px Arial',color:dark?'#fff':p.ink,maxLines:2})}
function eventTitle(s){const event=String(s.event||'Персональное предложение').trim();return String(s.client||'').trim()?'Предложение для '+String(s.client).trim():event}
function menuPreview(ctx,s,p,x,y,w,h,{dark=false,cards=4}={}){const items=(s.items||[]).slice(0,cards),gap=9,rowH=(h-gap*Math.max(0,items.length-1))/Math.max(1,items.length);items.forEach((item,i)=>{const yy=y+i*(rowH+gap);roundRect(ctx,x,yy,w,rowH,14,dark?'rgba(255,255,255,.07)':p.paper,dark?'rgba(255,255,255,.15)':p.line);text(ctx,item.name||'Позиция меню',x+16,yy+12,w-92,21,{font:dark?'600 13px Arial':'500 14px Georgia',color:dark?'#fff':p.ink,maxLines:2});text(ctx,'× '+Math.round(Number(item.qty||1)),x+w-16,yy+14,60,18,{font:'700 11px Arial',color:p.accent,align:'right',maxLines:1})})}
function footer(ctx,p,page,total){line(ctx,M,H-48,W-M,H-48,p.line,1);text(ctx,'Солнце Кейтеринг · предложение клиенту',M,H-34,500,16,{font:'10px Arial',color:p.muted,maxLines:1});text(ctx,String(page)+' / '+String(total),W-M,H-34,100,16,{font:'10px Arial',color:p.muted,align:'right',maxLines:1})}
function footer(ctx,p,page,total,brandName='Моя компания'){line(ctx,M,H-48,W-M,H-48,p.line,1);text(ctx,(brandName+" · предложение клиенту"),M,H-34,500,16,{font:'10px Arial',color:p.muted,maxLines:1});text(ctx,String(page)+' / '+String(total),W-M,H-34,100,16,{font:'10px Arial',color:p.muted,align:'right',maxLines:1})}
async function renderCover(s,id){
const p=PALETTES[id]||PALETTES.light,canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);const ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=p.bg;ctx.fillRect(0,0,W,H);
const p={...(PALETTES[id]||PALETTES.light),brandName:s.brandName||'Моя компания'},canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);const ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=p.bg;ctx.fillRect(0,0,W,H);
const logo=await loadImage(s.logo||''),heroSrc=(s.items||[]).find(x=>safeImage(x.photoData||x.photo))?.photoData||(s.finalGallery||[]).find(safeImage)||'',hero=await loadImage(heroSrc),title=eventTitle(s),event=String(s.event||'Персональное предложение').trim();
if(id==='light'){
logoOrBrand(ctx,logo,p);text(ctx,'ПЕРСОНАЛЬНОЕ ПРЕДЛОЖЕНИЕ',M,165,390,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title,M,202,405,54,{font:'500 42px Georgia',color:p.ink,maxLines:3});text(ctx,event,M,375,405,30,{font:'500 20px Georgia',color:p.deep,maxLines:2});line(ctx,M,445,445,445,p.accent,1);text(ctx,'Меню собрано под формат вашего события, количество гостей и пожелания.',M,475,385,25,{font:'14px Arial',color:p.muted,maxLines:4});if(hero)coverImage(ctx,hero,500,150,446,600,28);factPills(ctx,s,p,M,795,W-2*M);menuPreview(ctx,s,p,M,875,565,300,{cards:4});totalBlock(ctx,s,p,640,875,306,175);text(ctx,'Всё под контролем команды Солнца',640,1080,300,26,{font:'500 19px Georgia',color:p.deep,maxLines:2});text(ctx,'Доставка · свежие продукты · аккуратная подача · готовность к сервировке',640,1140,290,22,{font:'12px Arial',color:p.muted,maxLines:4});
logoOrBrand(ctx,logo,p);text(ctx,'ПЕРСОНАЛЬНОЕ ПРЕДЛОЖЕНИЕ',M,165,390,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title,M,202,405,54,{font:'500 42px Georgia',color:p.ink,maxLines:3});text(ctx,event,M,375,405,30,{font:'500 20px Georgia',color:p.deep,maxLines:2});line(ctx,M,445,445,445,p.accent,1);text(ctx,'Меню собрано под формат вашего события, количество гостей и пожелания.',M,475,385,25,{font:'14px Arial',color:p.muted,maxLines:4});if(hero)coverImage(ctx,hero,500,150,446,600,28);factPills(ctx,s,p,M,795,W-2*M);menuPreview(ctx,s,p,M,875,565,300,{cards:4});totalBlock(ctx,s,p,640,875,306,175);text(ctx,'Всё под контролем нашей команды',640,1080,300,26,{font:'500 19px Georgia',color:p.deep,maxLines:2});text(ctx,'Доставка · свежие продукты · аккуратная подача · готовность к сервировке',640,1140,290,22,{font:'12px Arial',color:p.muted,maxLines:4});
}else if(id==='midnight-glass'){
ctx.fillStyle='#080907';ctx.fillRect(0,0,W,H);if(hero)coverImage(ctx,hero,485,0,515,H,0);const g=ctx.createLinearGradient(0,0,720,0);g.addColorStop(0,'rgba(5,5,4,.99)');g.addColorStop(.72,'rgba(5,5,4,.84)');g.addColorStop(1,'rgba(5,5,4,.05)');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);ctx.strokeStyle=p.accent;ctx.lineWidth=1;ctx.strokeRect(27,27,W-54,H-54);logoOrBrand(ctx,logo,p,{x:58,y:58,dark:true});text(ctx,'LUXURY CATERING',58,210,380,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title.toUpperCase(),58,252,390,60,{font:'500 43px Georgia',color:'#fff',maxLines:4});text(ctx,event,58,500,370,28,{font:'16px Arial',color:'#d9d1c4',maxLines:3});factPills(ctx,s,p,58,615,370,{dark:true});menuPreview(ctx,s,p,58,705,370,330,{dark:true,cards:4});totalBlock(ctx,s,p,58,1070,370,170,{dark:true});text(ctx,'САНКТ-ПЕТЕРБУРГ',58,1275,280,18,{font:'10px Arial',color:p.accent,maxLines:1});
ctx.fillStyle='#080907';ctx.fillRect(0,0,W,H);if(hero)coverImage(ctx,hero,485,0,515,H,0);const g=ctx.createLinearGradient(0,0,720,0);g.addColorStop(0,'rgba(5,5,4,.99)');g.addColorStop(.72,'rgba(5,5,4,.84)');g.addColorStop(1,'rgba(5,5,4,.05)');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);ctx.strokeStyle=p.accent;ctx.lineWidth=1;ctx.strokeRect(27,27,W-54,H-54);logoOrBrand(ctx,logo,p,{x:58,y:58,dark:true});text(ctx,'LUXURY CATERING',58,210,380,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title.toUpperCase(),58,252,390,60,{font:'500 43px Georgia',color:'#fff',maxLines:4});text(ctx,event,58,500,370,28,{font:'16px Arial',color:'#d9d1c4',maxLines:3});factPills(ctx,s,p,58,615,370,{dark:true});menuPreview(ctx,s,p,58,705,370,330,{dark:true,cards:4});totalBlock(ctx,s,p,58,1070,370,170,{dark:true});text(ctx,String(s.brandCity||'').toUpperCase(),58,1275,280,18,{font:'10px Arial',color:p.accent,maxLines:1});
}else if(id==='editorial-grid'){
logoOrBrand(ctx,logo,p);text(ctx,'EDITORIAL / EVENT MENU',M,155,400,18,{font:'700 10px Arial',color:p.accent,maxLines:1});text(ctx,title,M,190,500,66,{font:'500 52px Georgia',color:p.ink,maxLines:3});line(ctx,M,405,520,405,p.ink,2);const sections=[['01','СОБЫТИЕ',event],['02','ФОРМАТ',s.guests?String(s.guests)+' гостей':'Персонально'],['03','МЕНЮ',String((s.items||[]).length)+' позиций'],['04','ДЕТАЛИ',s.time?'Доставка '+s.time:'Время согласуем']];sections.forEach((it,i)=>{const yy=455+i*115;text(ctx,it[0],M,yy,50,24,{font:'700 15px Arial',color:p.accent,maxLines:1});text(ctx,it[1],M+62,yy,170,20,{font:'700 10px Arial',color:p.muted,maxLines:1});text(ctx,it[2],M+62,yy+26,270,30,{font:'500 18px Georgia',color:p.ink,maxLines:2});line(ctx,M+60,yy+86,430,yy+86,p.line,1)});if(hero)coverImage(ctx,hero,540,170,406,670,0);menuPreview(ctx,s,p,540,875,406,245,{cards:3});totalBlock(ctx,s,p,540,1150,406,160);text(ctx,'Составлено специально для вашего события',M,1050,390,33,{font:'italic 24px Georgia',color:p.deep,maxLines:2});
}else if(id==='warm-sun'){
@ -73,11 +73,11 @@
}else if(id==='black-gold'){
if(hero)coverImage(ctx,hero,0,0,W,H,0);const grad=ctx.createLinearGradient(0,0,0,H);grad.addColorStop(0,'rgba(0,0,0,.35)');grad.addColorStop(.55,'rgba(0,0,0,.62)');grad.addColorStop(1,'rgba(0,0,0,.94)');ctx.fillStyle=grad;ctx.fillRect(0,0,W,H);logoOrBrand(ctx,logo,p,{x:M,y:52,dark:true});text(ctx,'FOOD FIRST',M,200,400,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title,M,240,760,70,{font:'500 54px Georgia',color:'#fff',maxLines:3});text(ctx,event,M,475,600,30,{font:'18px Arial',color:'#e2d9ca',maxLines:2});factPills(ctx,s,p,M,590,W-2*M,{dark:true});menuPreview(ctx,s,p,M,690,560,330,{dark:true,cards:4});totalBlock(ctx,s,p,640,690,306,180,{dark:true});text(ctx,'Еда — главный герой вашего события.',M,1080,650,46,{font:'italic 30px Georgia',color:'#fff',maxLines:2});text(ctx,'Соберём, приготовим и доставим всё вовремя.',M,1195,600,24,{font:'14px Arial',color:'#d6ccb9',maxLines:2});
}else if(id==='personal-letter'){
logoOrBrand(ctx,logo,p);roundRect(ctx,M,150,W-2*M,1110,8,p.paper,p.line);text(ctx,'Персональное письмо',M+55,205,320,20,{font:'italic 15px Georgia',color:p.accent,maxLines:1});text(ctx,String(s.client||'Здравствуйте')+',',M+55,265,520,60,{font:'italic 46px Georgia',color:p.deep,maxLines:2});text(ctx,'Мы собрали для вас предложение, в котором всё уже продумано: от состава меню до удобной доставки в день события.',M+55,390,520,32,{font:'20px Georgia',color:p.ink,maxLines:5});text(ctx,event,M+55,575,520,34,{font:'italic 25px Georgia',color:p.accent,maxLines:2});if(hero)coverImage(ctx,hero,650,230,240,360,120);line(ctx,M+55,720,W-M-55,720,p.line,1);factPills(ctx,s,p,M+55,770,W-2*M-110);menuPreview(ctx,s,p,M+55,855,520,250,{cards:3});totalBlock(ctx,s,p,620,855,270,170);text(ctx,'С теплом,\nкоманда «Солнце Кейтеринг»',M+55,1140,470,35,{font:'italic 24px Georgia',color:p.deep,maxLines:2});
logoOrBrand(ctx,logo,p);roundRect(ctx,M,150,W-2*M,1110,8,p.paper,p.line);text(ctx,'Персональное письмо',M+55,205,320,20,{font:'italic 15px Georgia',color:p.accent,maxLines:1});text(ctx,String(s.client||'Здравствуйте')+',',M+55,265,520,60,{font:'italic 46px Georgia',color:p.deep,maxLines:2});text(ctx,'Мы собрали для вас предложение, в котором всё уже продумано: от состава меню до удобной доставки в день события.',M+55,390,520,32,{font:'20px Georgia',color:p.ink,maxLines:5});text(ctx,event,M+55,575,520,34,{font:'italic 25px Georgia',color:p.accent,maxLines:2});if(hero)coverImage(ctx,hero,650,230,240,360,120);line(ctx,M+55,720,W-M-55,720,p.line,1);factPills(ctx,s,p,M+55,770,W-2*M-110);menuPreview(ctx,s,p,M+55,855,520,250,{cards:3});totalBlock(ctx,s,p,620,855,270,170);text(ctx,("С теплом,\nкоманда «"+p.brandName+"»"),M+55,1140,470,35,{font:'italic 24px Georgia',color:p.deep,maxLines:2});
}else if(id==='event-ticket'){
logoOrBrand(ctx,logo,p);roundRect(ctx,M,160,W-2*M,1080,28,p.paper,p.deep,2);line(ctx,695,160,695,1240,p.line,2,[8,9]);text(ctx,'EVENT PASS',M+38,205,260,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title,M+38,250,560,58,{font:'700 43px Arial',color:p.deep,maxLines:3});text(ctx,event,M+38,425,560,28,{font:'16px Arial',color:p.muted,maxLines:2});const labels=[['DATE',dateText(s.date)||'Уточняется'],['TIME',s.time||'Уточняется'],['GUESTS',s.guests||'—'],['BOXES',classicBoxCount(s)||0]];labels.forEach((it,i)=>{const x=M+38+(i%2)*286,y=535+Math.floor(i/2)*115;roundRect(ctx,x,y,264,92,12,'#faf7ee',p.line);text(ctx,it[0],x+16,y+14,100,16,{font:'700 9px Arial',color:p.accent,maxLines:1});text(ctx,String(it[1]),x+16,y+38,225,26,{font:'600 16px Arial',color:p.ink,maxLines:2})});menuPreview(ctx,s,p,M+38,785,550,270,{cards:3});totalBlock(ctx,s,p,M+38,1080,550,130,{accentFill:true});text(ctx,'SUN / CATERING / '+String(s.date||'EVENT'),725,205,185,24,{font:'700 12px Arial',color:p.deep,maxLines:3});for(let i=0;i<42;i++){const x=735+i*4.2,w=i%5===0?3:i%3===0?2:1;ctx.fillStyle=p.ink;ctx.fillRect(x,830,w,190)}text(ctx,'№ '+String(s.id||s.orderId||'0000'),725,1040,180,24,{font:'700 15px Arial',color:p.ink,maxLines:1});text(ctx,'Покажите этот билет хорошему настроению.',725,1100,170,30,{font:'12px Arial',color:p.muted,maxLines:4});
logoOrBrand(ctx,logo,p);roundRect(ctx,M,160,W-2*M,1080,28,p.paper,p.deep,2);line(ctx,695,160,695,1240,p.line,2,[8,9]);text(ctx,'EVENT PASS',M+38,205,260,20,{font:'700 11px Arial',color:p.accent,maxLines:1});text(ctx,title,M+38,250,560,58,{font:'700 43px Arial',color:p.deep,maxLines:3});text(ctx,event,M+38,425,560,28,{font:'16px Arial',color:p.muted,maxLines:2});const labels=[['DATE',dateText(s.date)||'Уточняется'],['TIME',s.time||'Уточняется'],['GUESTS',s.guests||'—'],['BOXES',classicBoxCount(s)||0]];labels.forEach((it,i)=>{const x=M+38+(i%2)*286,y=535+Math.floor(i/2)*115;roundRect(ctx,x,y,264,92,12,'#faf7ee',p.line);text(ctx,it[0],x+16,y+14,100,16,{font:'700 9px Arial',color:p.accent,maxLines:1});text(ctx,String(it[1]),x+16,y+38,225,26,{font:'600 16px Arial',color:p.ink,maxLines:2})});menuPreview(ctx,s,p,M+38,785,550,270,{cards:3});totalBlock(ctx,s,p,M+38,1080,550,130,{accentFill:true});text(ctx,p.brandName+' / '+String(s.date||'EVENT'),725,205,185,24,{font:'700 12px Arial',color:p.deep,maxLines:3});for(let i=0;i<42;i++){const x=735+i*4.2,w=i%5===0?3:i%3===0?2:1;ctx.fillStyle=p.ink;ctx.fillRect(x,830,w,190)}text(ctx,'№ '+String(s.id||s.orderId||'0000'),725,1040,180,24,{font:'700 15px Arial',color:p.ink,maxLines:1});text(ctx,'Покажите этот билет хорошему настроению.',725,1100,170,30,{font:'12px Arial',color:p.muted,maxLines:4});
}else if(id==='solar-experience'){
const grad=ctx.createLinearGradient(0,0,W,H);grad.addColorStop(0,'#ffd43d');grad.addColorStop(.5,'#f7b40b');grad.addColorStop(1,'#ef8800');ctx.fillStyle=grad;ctx.fillRect(0,0,W,H);sun(ctx,90,90,25,'#fff5c2',24);text(ctx,'СОЛНЦЕ / EXPERIENCE',135,62,360,26,{font:'700 17px Arial',color:p.deep,maxLines:1});text(ctx,title,M,210,430,64,{font:'700 48px Arial',color:p.deep,maxLines:3});text(ctx,event,M,400,390,30,{font:'16px Arial',color:'#5d4719',maxLines:2});ctx.save();ctx.strokeStyle='rgba(255,255,255,.55)';ctx.lineWidth=2;ctx.beginPath();ctx.arc(720,430,330,0,Math.PI*2);ctx.stroke();ctx.beginPath();ctx.arc(720,430,270,0,Math.PI*2);ctx.stroke();ctx.restore();circleImage(ctx,hero,735,445,215);const steps=[['01','Меню'],['02','Готовим'],['03','Доставляем']];steps.forEach((it,i)=>{const yy=650+i*115;roundRect(ctx,M,yy,330,86,43,'rgba(255,255,255,.42)','rgba(255,255,255,.6)');text(ctx,it[0],M+26,yy+18,50,24,{font:'700 15px Arial',color:p.deep,maxLines:1});text(ctx,it[1],M+86,yy+24,190,30,{font:'700 18px Arial',color:p.deep,maxLines:1})});roundRect(ctx,515,800,431,330,28,'rgba(16,47,51,.92)',null);menuPreview(ctx,s,{...p,paper:'rgba(255,255,255,.08)',line:'rgba(255,255,255,.2)',ink:'#fff',accent:'#ffd95f'},545,835,371,220,{dark:true,cards:3});text(ctx,'ИТОГО',M,1080,220,24,{font:'700 13px Arial',color:p.deep,maxLines:1});text(ctx,money(s.pricing?.total),M,1118,420,64,{font:'700 47px Arial',color:p.deep,maxLines:1});text(ctx,'Событие, которое хочется запомнить.',M,1210,600,32,{font:'700 22px Arial',color:p.deep,maxLines:2});
const grad=ctx.createLinearGradient(0,0,W,H);grad.addColorStop(0,'#ffd43d');grad.addColorStop(.5,'#f7b40b');grad.addColorStop(1,'#ef8800');ctx.fillStyle=grad;ctx.fillRect(0,0,W,H);logoOrBrand(ctx,logo,p,{x:M,y:52});text(ctx,title,M,210,430,64,{font:'700 48px Arial',color:p.deep,maxLines:3});text(ctx,event,M,400,390,30,{font:'16px Arial',color:'#5d4719',maxLines:2});ctx.save();ctx.strokeStyle='rgba(255,255,255,.55)';ctx.lineWidth=2;ctx.beginPath();ctx.arc(720,430,330,0,Math.PI*2);ctx.stroke();ctx.beginPath();ctx.arc(720,430,270,0,Math.PI*2);ctx.stroke();ctx.restore();circleImage(ctx,hero,735,445,215);const steps=[['01','Меню'],['02','Готовим'],['03','Доставляем']];steps.forEach((it,i)=>{const yy=650+i*115;roundRect(ctx,M,yy,330,86,43,'rgba(255,255,255,.42)','rgba(255,255,255,.6)');text(ctx,it[0],M+26,yy+18,50,24,{font:'700 15px Arial',color:p.deep,maxLines:1});text(ctx,it[1],M+86,yy+24,190,30,{font:'700 18px Arial',color:p.deep,maxLines:1})});roundRect(ctx,515,800,431,330,28,'rgba(16,47,51,.92)',null);menuPreview(ctx,s,{...p,paper:'rgba(255,255,255,.08)',line:'rgba(255,255,255,.2)',ink:'#fff',accent:'#ffd95f'},545,835,371,220,{dark:true,cards:3});text(ctx,'ИТОГО',M,1080,220,24,{font:'700 13px Arial',color:p.deep,maxLines:1});text(ctx,money(s.pricing?.total),M,1118,420,64,{font:'700 47px Arial',color:p.deep,maxLines:1});text(ctx,'Событие, которое хочется запомнить.',M,1210,600,32,{font:'700 22px Arial',color:p.deep,maxLines:2});
}
canvas.dataset.sunClassicTemplate=id;canvas.dataset.sunClassicCover='1';return canvas;
}
@ -90,7 +90,7 @@
if(!CLASSIC_SET.has(id))return baseRenderer(snapshot);
const innerId=INNER_THEME[id]||'light',inner={...snapshot,offerTemplateId:innerId};
const [cover,basePages]=await Promise.all([renderCover(snapshot,id),baseRenderer(inner)]),pages=[cover,...(basePages||[])],total=pages.length;
try{const ctx=cover.getContext('2d'),p=PALETTES[id]||PALETTES.light;ctx.save();ctx.setTransform(SCALE,0,0,SCALE,0,0);footer(ctx,p,1,total);ctx.restore()}catch(_){}
try{const ctx=cover.getContext('2d'),p=PALETTES[id]||PALETTES.light;ctx.save();ctx.setTransform(SCALE,0,0,SCALE,0,0);footer(ctx,p,1,total,snapshot.brandName);ctx.restore()}catch(_){}
correctPageNumbers(basePages||[],2,total,innerId);
return pages;
}

View File

@ -0,0 +1,83 @@
(()=>{
'use strict';
if(window.CateriumBranding)return;
const NAME_KEY='sunPdfBrandNameV1',LOGO_KEY='sunPdfBrandLogoV1';
const PIXEL='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==';
const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const read=key=>{try{return localStorage.getItem(key)||''}catch(_){return ''}};
const object=key=>{try{const v=JSON.parse(read(key)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch(_){return {}}};
const safeLogo=src=>/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=\s]+$/i.test(String(src||''))&&String(src).length<=2000000?String(src):'';
const clean=value=>String(value||'').trim().slice(0,160);
function identity(){
const profile=object('sunCompanyProfileV1');
const storedName=read(NAME_KEY);
// An explicitly cleared logo must not reappear from an older profile.
let hasLogo=false;try{hasLogo=localStorage.getItem(LOGO_KEY)!==null}catch(_){}
return {
name:clean(storedName||profile.shortName||profile.name)||'Моя компания',
logo:safeLogo(hasLogo?read(LOGO_KEY):profile.logo),
city:clean(profile.city),
contacts:[profile.phone,profile.email,profile.website].map(clean).filter(Boolean).join(' · ')
};
}
function changed(){window.dispatchEvent(new CustomEvent('caterium:document-brand-changed'));}
function saveName(value){localStorage.setItem(NAME_KEY,clean(value));changed();}
function clearLogo(){localStorage.setItem(LOGO_KEY,'');changed();}
async function saveLogo(file){
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]);
const startedIn=scope();
if(!file||!/^image\/(?:png|jpeg|webp)$/i.test(file.type))throw new Error('Выберите PNG, JPEG или WebP.');
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 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=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');
if(!safeLogo(data))throw new Error('Изображение слишком сложное. Выберите логотип меньшего размера.');
if(startedIn!==scope())throw new Error('Аккаунт или компания изменились. Загрузите логотип ещё раз.');
localStorage.setItem(LOGO_KEY,data);changed();return data;
}
const documentName=()=>identity().name;
const documentLogo=()=>identity().logo;
const documentImage=()=>documentLogo()||PIXEL;
const documentContacts=()=>identity().contacts;
const documentCity=()=>identity().city;
function snapshot(value){const brand=identity();return {...value,brandName:brand.name,logo:brand.logo,brandContacts:brand.contacts,brandCity:brand.city};}
let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false};
let request=0,checkedUser='',pendingUser='';
function sidebarHTML(){return `<img class="real-logo${sidebar.special?'':' caterium-product-mark'}" src="${esc(sidebar.logo)}" alt="${esc(sidebar.name)}"><span>${esc(sidebar.name)}</span>`;}
function renderSidebar(){
const node=document.querySelector('body > header .brand');if(!node)return;
const html=sidebarHTML();if(node.innerHTML!==html)node.innerHTML=html;
node.dataset.cateriumBrand=sidebar.special?'solnce':'caterium';
}
function resetSidebar(){request++;checkedUser='';pendingUser='';sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false};renderSidebar();}
async function refreshSidebar(){
const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||'';
if(!uid){resetSidebar();return;}
if(checkedUser===uid||pendingUser===uid){renderSidebar();return;}
resetSidebar();pendingUser=uid;const current=request;
try{
const result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand');
if(current!==request||window.SunCloudV2?.getSession?.()?.user?.id!==uid)return;
if(result?.error||!result?.data)return;
if(result.data.variant==='solnce')sidebar={name:'Солнце Кейтеринг',logo:'sun-logo.png',special:true};
checkedUser=uid;renderSidebar();
}catch(_){/* Until identity is confirmed, show the shared product brand. */}
finally{if(current===request)pendingUser='';}
}
function boot(){
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);
renderSidebar();refreshSidebar();
}
window.CateriumBranding=Object.freeze({identity,documentName,documentLogo,documentImage,documentContacts,documentCity,
nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()),
escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar});
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()});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -71,9 +71,9 @@
function coverImage(ctx,img,x,y,w,h,r=24){if(!img)return;const s=Math.max(w/img.naturalWidth,h/img.naturalHeight),sw=w/s,sh=h/s,sx=(img.naturalWidth-sw)/2,sy=(img.naturalHeight-sh)/2;ctx.save();roundRect(ctx,x,y,w,h,r);ctx.clip();ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h);ctx.restore()}
function circleImage(ctx,img,cx,cy,r){if(!img)return;const d=r*2,s=Math.max(d/img.naturalWidth,d/img.naturalHeight),sw=d/s,sh=d/s,sx=(img.naturalWidth-sw)/2,sy=(img.naturalHeight-sh)/2;ctx.save();ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.clip();ctx.drawImage(img,sx,sy,sw,sh,cx-r,cy-r,d,d);ctx.restore()}
function sun(ctx,cx,cy,r,color,rays=18){ctx.save();ctx.strokeStyle=color;ctx.fillStyle=color;ctx.lineWidth=2;for(let i=0;i<rays;i++){const a=i*Math.PI*2/rays,ri=r+8,ro=r+24+(i%2)*8;ctx.beginPath();ctx.moveTo(cx+Math.cos(a)*ri,cy+Math.sin(a)*ri);ctx.lineTo(cx+Math.cos(a)*ro,cy+Math.sin(a)*ro);ctx.stroke()}ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.restore()}
function logoOrBrand(ctx,logo,p,{x=M,y=46,dark=false,fonts}={},brandName='Солнце Кейтеринг'){if(logo){const s=Math.min(190/logo.naturalWidth,64/logo.naturalHeight),dw=logo.naturalWidth*s,dh=logo.naturalHeight*s;ctx.drawImage(logo,x,y,dw,dh);return}const df=fonts?.display||'Arial',lf=fonts?.label||'Arial';sun(ctx,x+28,y+29,15,p.accent,16);const parts=String(brandName||'Солнце Кейтеринг').trim().split(/\s+/),line1=(parts[0]||'Солнце').toUpperCase(),line2=(parts.slice(1).join(' ')||'Кейтеринг').toUpperCase();text(ctx,line1,x+60,y+11,220,28,{font:`700 23px ${df}`,color:dark?'#fff':p.ink,maxLines:1});text(ctx,line2,x+61,y+42,180,18,{font:`600 11px ${lf}`,color:p.accent,maxLines:1})}
function logoOrBrand(ctx,logo,p,{x=M,y=46,dark=false,fonts}={},brandName='Моя компания'){if(logo){const scale=Math.min(190/logo.naturalWidth,64/logo.naturalHeight);ctx.drawImage(logo,x,y,logo.naturalWidth*scale,logo.naturalHeight*scale);return}text(ctx,brandName||'Моя компания',x,y+8,300,28,{font:`700 22px ${fonts?.display||'Arial'}`,color:dark?'#fff':p.ink,maxLines:2})}
function eventTitle(s){const event=String(s.event||'Персональное предложение').trim();return String(s.client||'').trim()?'Предложение для '+String(s.client).trim():event}
function footer(ctx,p,page,total,brandName='Солнце Кейтеринг',fonts){const lf=fonts?.label||'Arial';line(ctx,M,H-48,W-M,H-48,p.line,1);text(ctx,String(brandName||'Солнце Кейтеринг')+' · предложение клиенту',M,H-34,500,16,{font:`500 10px ${lf}`,color:p.muted,maxLines:1});text(ctx,String(page)+' / '+String(total),W-M,H-34,100,16,{font:`500 10px ${lf}`,color:p.muted,align:'right',maxLines:1})}
function footer(ctx,p,page,total,brandName='Моя компания',fonts){const lf=fonts?.label||'Arial';line(ctx,M,H-48,W-M,H-48,p.line,1);text(ctx,String(brandName||'Моя компания')+' · предложение клиенту',M,H-34,500,16,{font:`500 10px ${lf}`,color:p.muted,maxLines:1});text(ctx,String(page)+' / '+String(total),W-M,H-34,100,16,{font:`500 10px ${lf}`,color:p.muted,align:'right',maxLines:1})}
function iconCircle(ctx,cx,cy,r,glyph,{fill,stroke,glyphColor='#fff',size=20}={}){roundRect(ctx,cx-r,cy-r,r*2,r*2,r,fill,stroke,1.4);text(ctx,glyph,cx,cy-size/2-1,r*2,size+4,{font:`${size}px Arial`,color:glyphColor,align:'center',maxLines:1})}
// Small line-drawn icon badges (calendar/people/gauge/box) for the "premium" templates, so those stat cards use real iconography instead of emoji.
function iconBadge(ctx,x,y,size,p,draw){roundRect(ctx,x,y,size,size,size*0.28,p.deep,p.accent,1.4);ctx.save();ctx.translate(x+size/2,y+size/2);ctx.strokeStyle=p.accent;ctx.fillStyle=p.accent;ctx.lineWidth=Math.max(1.6,size*0.05);ctx.lineCap='round';ctx.lineJoin='round';draw(ctx,size*0.32);ctx.restore()}
@ -154,7 +154,7 @@
const feats=['Разнообразие блюд','Свежие ингредиенты','Премиальное качество'];feats.forEach((t,i)=>{const xx=M+i*145;roundRect(ctx,xx,430,132,48,13,'rgba(255,255,255,.05)',p.line);text(ctx,t,xx+66,446,112,14,{font:`600 10px ${f.label}`,color:p.accent2,align:'center',maxLines:2})});
menuList(ctx,s,p,M,510,470,66,5,{dark:true,fonts:f});await photoGridCircles(ctx,s,p,565,520,380,3,58,{numbered:true,fonts:f});
statCards(ctx,s,p,M,940,W-2*M,78,[[Math.round(classicBoxCount(s))||0,'КОРОБОК'],[(s.items||[]).length,'ПОЗИЦИЙ ',],[s.guests||'—','ГОСТЕЙ']],{dark:true,fonts:f});priceBand(ctx,s,p,M,1032,W-2*M,90,{dark:true,fonts:f});
checklistCard(ctx,p,M,1140,470,170,'ВСЁ ПОД КОНТРОЛЕМ КОМАНДЫ СОЛНЦА',CHECK_LINES,{dark:true,fonts:f});addonRow(ctx,p,540,1140,406,170,ADDON_LINES,{dark:true,fonts:f});
checklistCard(ctx,p,M,1140,470,170,'ВСЁ ПОД КОНТРОЛЕМ НАШЕЙ КОМАНДЫ',CHECK_LINES,{dark:true,fonts:f});addonRow(ctx,p,540,1140,406,170,ADDON_LINES,{dark:true,fonts:f});
}else if(id==='midnight-checklist'){
logoOrBrand(ctx,logo,p,{x:M,y:48,dark:true,fonts:f},s.brandName);if(s.client)text(ctx,String(s.client),W-M,58,300,20,{font:`700 15px ${f.display}`,color:'#fff',align:'right',maxLines:1});const metaLine=[dateText(s.date),s.guests?String(s.guests)+' гостей':''].filter(Boolean).join(' · ');if(metaLine)text(ctx,metaLine,W-M,84,300,16,{font:`500 11px ${f.label}`,color:p.muted,align:'right',maxLines:1});
roundRect(ctx,M,120,W-2*M,60,14,'rgba(255,255,255,.04)',p.line);text(ctx,'Меню составлено из расчёта количества гостей. Все позиции приедут готовыми к подаче на стол.',M+20,138,W-2*M-40,20,{font:`500 12px ${f.label}`,color:p.muted,maxLines:2});
@ -162,7 +162,7 @@
menuList(ctx,s,p,M,255,W-2*M,58,8,{dark:true,fonts:f});
await photoGridSquare(ctx,s,p,M,860,W-2*M,190,5,2,{numbered:false,fonts:f});
statCards(ctx,s,p,M,1075,470,74,[[Math.round(classicBoxCount(s))||0,'КОРОБОК'],[(s.items||[]).length,'ПОЗИЦИЙ']],{dark:true,fonts:f});priceBand(ctx,s,p,540,1075,406,74,{dark:true,fonts:f});
checklistCard(ctx,p,M,1170,470,150,'ВСЁ ПОД КОНТРОЛЕМ КОМАНДЫ СОЛНЦА',CHECK_LINES,{dark:true,fonts:f});addonRow(ctx,p,540,1170,406,150,ADDON_LINES,{dark:true,fonts:f});
checklistCard(ctx,p,M,1170,470,150,'ВСЁ ПОД КОНТРОЛЕМ НАШЕЙ КОМАНДЫ',CHECK_LINES,{dark:true,fonts:f});addonRow(ctx,p,540,1170,406,150,ADDON_LINES,{dark:true,fonts:f});
}else if(id==='gourmet-hero'){
logoOrBrand(ctx,logo,p,{x:W-M-190,y:44,dark:true,fonts:f},s.brandName);text(ctx,'КЕЙТЕРИНГ ДЛЯ',M,96,460,44,{font:`700 32px ${f.display}`,color:'#fff',maxLines:1});text(ctx,'ВАШЕГО СОБЫТИЯ',M,140,460,44,{font:`700 32px ${f.display}`,color:'#fff',maxLines:1});text(ctx,'Индивидуальное меню, безупречная подача и сервис, о котором будут говорить ваши гости.',M,235,400,24,{font:`500 14px ${f.label}`,color:p.muted,maxLines:3});if(hero)coverImage(ctx,hero,520,60,426,340,18);roundRect(ctx,520,420,240,42,12,p.paper,p.line);text(ctx,String((s.items||[]).length)+' блюд в меню',540,432,220,18,{font:`700 12px ${f.label}`,color:p.accent,maxLines:1});
const realCats=[...new Set((s.items||[]).map(it=>it.categoryName).filter(Boolean))].slice(0,5);
@ -203,7 +203,7 @@
// ---- Fully-styled inner pages for the 2 polished templates, so the whole document reads as one design instead of a nice cover glued to the generic base pages. ----
function newPage(p,dark){const canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);const ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=p.bg;ctx.fillRect(0,0,W,H);if(dark){ctx.save();roundRect(ctx,20,20,W-40,H-40,0,null,p.accent,1.4);ctx.restore()}return{canvas,ctx}}
function contentHeader(ctx,p,f,s,label,dark){text(ctx,String(s.brandName||'Солнце Кейтеринг').toUpperCase(),M,50,320,16,{font:`700 11px ${f.label}`,color:p.accent,maxLines:1});text(ctx,eventTitle(s),M,70,520,26,{font:`600 20px ${f.display}`,color:dark?'#fff':p.ink,maxLines:1});text(ctx,label,W-M,58,260,16,{font:`600 11px ${f.label}`,color:p.muted,align:'right',maxLines:1});line(ctx,M,108,W-M,108,p.line,1)}
function contentHeader(ctx,p,f,s,label,dark){text(ctx,String(s.brandName||'Моя компания').toUpperCase(),M,50,320,16,{font:`700 11px ${f.label}`,color:p.accent,maxLines:1});text(ctx,eventTitle(s),M,70,520,26,{font:`600 20px ${f.display}`,color:dark?'#fff':p.ink,maxLines:1});text(ctx,label,W-M,58,260,16,{font:`600 11px ${f.label}`,color:p.muted,align:'right',maxLines:1});line(ctx,M,108,W-M,108,p.line,1)}
async function itemRowsPage(ctx,p,f,items,y0,dark){
const rowH=dark?96:104,gap=dark?12:14;
const imgs=await Promise.all(items.map(it=>loadImage(it.photoData||'')));

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
const CACHE='sun-catering-pwa-v83-20260917-theme-first-paint';
const VERSION='20260917-theme-first-paint';
const CACHE='sun-catering-pwa-v84-20260917-company-branding';
const VERSION='20260917-company-branding';
const CORE=[
'./','./index.html',`./core/brand-theme.js?v=${VERSION}`,
'./','./index.html',`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,
'./offer-gallery/001.jpg','./offer-gallery/002.jpg',
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
@ -9,7 +9,7 @@ const CORE=[
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
];
const CRITICAL_FRESH=new Set([
'/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
'/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
]);
self.addEventListener('install',event=>{
event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting()));

View File

@ -0,0 +1,24 @@
begin;
-- One exclusive sidebar identity. Ordinary company/profile edits cannot assign it.
create table public.caterium_sidebar_brand_assignment (
singleton boolean primary key default true check (singleton),
user_id uuid not null unique references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
alter table public.caterium_sidebar_brand_assignment enable row level security;
revoke all on public.caterium_sidebar_brand_assignment from public,anon,authenticated;
grant all on public.caterium_sidebar_brand_assignment to service_role;
create function public.caterium_my_sidebar_brand()
returns jsonb language sql stable security definer set search_path=public,pg_temp as $$
select jsonb_build_object('variant',case when exists(
select 1 from public.caterium_sidebar_brand_assignment a
join auth.users u on u.id=a.user_id
where a.user_id=auth.uid() and u.email_confirmed_at is not null
) then 'solnce' else 'caterium' end)
$$;
revoke all on function public.caterium_my_sidebar_brand() from public,anon;
grant execute on function public.caterium_my_sidebar_brand() to authenticated;
notify pgrst,'reload schema';
commit;

View File

@ -9,7 +9,7 @@ const fullAppTests=new Set([
'v17.6.5 stays free of timer page errors during idle'
]);
test.beforeEach(async({page},testInfo)=>{
if(!fullAppTests.has(testInfo.title))await page.route('**/index.html',route=>route.fulfill({contentType:'text/html',body:'<!doctype html><html><head></head><body></body></html>'}));
if(!fullAppTests.has(testInfo.title))await page.route('**/index.html',route=>route.fulfill({contentType:'text/html',body:'<!doctype html><html><head><script src="/core/company-branding.js"></script></head><body></body></html>'}));
});
async function loadModule(page,file,globalName){
await page.goto('/index.html', { waitUntil:'domcontentloaded' });

View File

@ -4,7 +4,7 @@ const manifest=JSON.parse(fs.readFileSync('public/manifest.webmanifest','utf8'))
let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++};
check(index.includes('<title>Caterium</title>'),'browser title uses Caterium');
check(index.includes('<meta name="apple-mobile-web-app-title" content="Caterium">'),'Apple PWA title uses Caterium');
check(index.includes('<div class="brand"><span class="sun">☼</span>Caterium</div>'),'visible header uses Caterium');
check(index.includes('<div class="brand"><img class="real-logo caterium-product-mark" src="caterium-mark-light.svg" alt="Caterium"><span>Caterium</span></div>'),'visible header uses Caterium');
check(!index.includes('<title>Солнце Кейтеринг</title>'),'legacy browser title is removed');
check(!index.includes('<meta name="apple-mobile-web-app-title" content="Солнце">'),'legacy Apple PWA title is removed');
check(manifest.name==='Caterium'&&manifest.short_name==='Caterium','manifest identity uses Caterium');

View File

@ -0,0 +1,117 @@
import {test,expect} from '@playwright/test';
async function fixture(page){
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><head><script src="/core/company-branding.js"></script></head><body><header><div class="brand"></div></header></body></html>'}));
await page.goto('/index.html');
}
async function logo(page,color='#146b3d'){
return page.evaluate(async color=>{
const c=document.createElement('canvas');c.width=48;c.height=24;const x=c.getContext('2d');x.fillStyle=color;x.fillRect(8,4,32,16);
const blob=await new Promise(r=>c.toBlob(r,'image/png'));
return window.CateriumBranding.saveLogo(new File([blob],'logo.png',{type:'image/png'}));
},color);
}
test('company logo preserves transparency, persists and clears without changing product brand',async({page})=>{
await fixture(page);const data=await logo(page);
await page.evaluate(()=>window.CateriumBranding.saveName('Кухня <Гости> & Ко'));
const result=await page.evaluate(async()=>{
const b=window.CateriumBranding,img=new Image();img.src=b.documentImage();await img.decode();const c=document.createElement('canvas');c.width=48;c.height=24;const x=c.getContext('2d');x.drawImage(img,0,0);
return {name:b.documentName(),escaped:b.nameHTML(),alpha:x.getImageData(0,0,1,1).data[3],body:document.querySelector('header').textContent};
});
expect(result).toEqual({name:'Кухня <Гости> & Ко',escaped:'Кухня &lt;Гости&gt; &amp; Ко',alpha:0,body:'Caterium'});
await page.reload();expect(await page.evaluate(()=>window.CateriumBranding.documentLogo())).toBe(data);
await page.evaluate(()=>{localStorage.setItem('sunCompanyProfileV1',JSON.stringify({logo:window.CateriumBranding.documentLogo()}));window.CateriumBranding.clearLogo()});
expect(await page.evaluate(()=>window.CateriumBranding.documentLogo())).toBe('');
expect(await page.evaluate(async()=>{const i=new Image();i.src=window.CateriumBranding.documentImage();await i.decode();return i.naturalWidth})).toBe(1);
});
test('exclusive sidebar is server assigned, resets on account changes and ignores stale replies',async({page})=>{
await fixture(page);
await page.evaluate(async()=>{
window.__user='owner';window.__calls=[];
window.SunCloudV2={getSession:()=>window.__user?{user:{id:window.__user}}:null,getClient:()=>({rpc:async name=>{window.__calls.push(name);return {data:{variant:window.__user==='owner'?'solnce':'caterium'}}}})};
await window.CateriumBranding.refreshSidebar();
});
await expect(page.locator('header .brand')).toHaveText('Солнце Кейтеринг');
await logo(page);await page.evaluate(()=>window.CateriumBranding.saveName('Другой бренд документов'));
await expect(page.locator('header .brand')).toHaveText('Солнце Кейтеринг');
await page.evaluate(async()=>{window.__user='employee';await window.CateriumBranding.refreshSidebar()});
await expect(page.locator('header .brand')).toHaveText('Caterium');
await page.evaluate(async()=>{
window.__user='owner';window.SunCloudV2.getClient=()=>({rpc:()=>new Promise(r=>window.__reply=r)});
const pending=window.CateriumBranding.refreshSidebar();window.__user=null;window.CateriumBranding.resetSidebar();window.__reply({data:{variant:'solnce'}});await pending;
});
await expect(page.locator('header .brand')).toHaveText('Caterium');
});
test('document branding follows workspace storage without leaking the previous company',async({page})=>{
await fixture(page);const first=await logo(page);
await page.evaluate(()=>window.CateriumBranding.saveName('Компания А'));
const saved=await page.evaluate(()=>({...localStorage}));
await page.evaluate(()=>{localStorage.clear();localStorage.setItem('sunCompanyProfileV1',JSON.stringify({name:'Компания Б'}));window.dispatchEvent(new Event('sun:cloud-state-applied'))});
expect(await page.evaluate(()=>window.CateriumBranding.identity())).toMatchObject({name:'Компания Б',logo:''});
const second=await logo(page,'#801599');expect(second).not.toBe(first);
await page.evaluate(saved=>{localStorage.clear();for(const [k,v] of Object.entries(saved))localStorage.setItem(k,v);window.dispatchEvent(new Event('sun:cloud-state-applied'))},saved);
expect(await page.evaluate(()=>window.CateriumBranding.identity())).toMatchObject({name:'Компания А',logo:first});
});
test('every classic and signature PDF uses the supplied company logo and no legacy company text',async({page})=>{
test.setTimeout(60000);await fixture(page);
for(const module of ['classic-offer-pdf-v1767.js','signature-offer-pdf-v18.js'])await page.addScriptTag({url:'/core/'+module});
const data=await logo(page);
const result=await page.evaluate(async data=>{
const texts=[],images=[],proto=CanvasRenderingContext2D.prototype,fill=proto.fillText,draw=proto.drawImage;
proto.fillText=function(t,...a){texts.push(String(t));return fill.call(this,t,...a)};
proto.drawImage=function(i,...a){images.push(i.src);return draw.call(this,i,...a)};
const b={brandName:'Компания Лист',brandCity:'Казань',logo:data,client:'Анна',event:'Обед',items:[],pricing:{total:15000},guests:10};
const counts=[];
try{for(const api of [window.SunClassicOfferPDFV1767,window.SunSignatureOfferPDFV18])for(const id of api.CLASSIC_IDS||api.SIGNATURE_IDS){const before=images.length;await api.renderCover({...b,offerTemplateId:id},id);counts.push({id,logo:images.slice(before).includes(data)})}}finally{proto.fillText=fill;proto.drawImage=draw}
return {counts,legacy:texts.filter(t=>/солнц|SUN \/ CATERING|САНКТ-ПЕТЕРБУРГ/i.test(t))};
},data);
expect(result.counts).toHaveLength(18);expect(result.counts.filter(x=>!x.logo)).toEqual([]);expect(result.legacy).toEqual([]);
});
test('real order blank, receipt and preparation share uploaded company identity',async({page})=>{
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>Boolean(window.sunShowOrderBlankV5));
const data=await logo(page);
await page.evaluate(()=>window.CateriumBranding.saveName('Компания <Лист>'));
const result=await page.evaluate(()=>{
const out=[];for(const fn of ['sunShowOrderBlankV5','sunShowConfiguredReceipt','openPreview']){
window[fn]();const root=document.querySelector('#preview .dialog');out.push({fn,text:root.textContent,logos:[...root.querySelectorAll('.sun-doc-brand img,.prep-doc-brand img')].map(x=>x.src),unsafe:!!root.querySelector('лист')});
}return out;
});
for(const row of result){expect(row.text).toContain('Компания <Лист>');expect(row.text).not.toMatch(/Солнце|Моя компания/);expect(row.logos).toEqual([data]);expect(row.unsafe).toBe(false)}
await page.evaluate(()=>document.querySelector('header nav button[data-nav-label="Настройки"]').click());
await expect(page.locator('#sunPdfBrandCard')).toHaveAttribute('data-settings-tab','documents');
await page.locator('#sunPdfBrandLogoFile').setInputFiles({name:'company.png',mimeType:'image/png',buffer:Buffer.from(data.split(',')[1],'base64')});
await expect.poll(()=>page.evaluate(()=>window.CateriumBranding.documentLogo())).toBe(data);
});
test('catalog, route sheet and full PDF pages retain the company brand',async({page})=>{
test.setTimeout(60000);
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>Boolean(window.sunClientOfferDebugPdfPages));
const data=await logo(page);
const result=await page.evaluate(async data=>{
const brand=window.CateriumBranding;brand.saveName('Лист');window.print=()=>{};
const texts=[],images=[],proto=CanvasRenderingContext2D.prototype,fill=proto.fillText,draw=proto.drawImage;
proto.fillText=function(t,...a){texts.push(String(t));return fill.call(this,t,...a)};
proto.drawImage=function(i,...a){images.push(i.src);return draw.call(this,i,...a)};
const allIds=[...window.SunClassicOfferPDFV1767.CLASSIC_IDS,...window.SunClassicOfferPDFV1767.ARCHIVE_IDS,...window.SunSignatureOfferPDFV18.SIGNATURE_IDS],counts=[];
try{
for(const id of allIds){const pages=await window.sunClientOfferDebugPdfPages({offerTemplateId:id,brandName:'Лист',brandCity:'Казань',logo:data,event:'Обед',items:[],pricing:{total:1000},guests:10});counts.push(pages.length)}
await window.sunCatalogDebugPdfPages(0,1);
}finally{proto.fillText=fill;proto.drawImage=draw}
await window.sunPrintLiveCatalog();
const catalog=document.querySelector('#sunLiveCatalogPrintRoot');const catalogOK=catalog.textContent.includes('ЛИСТ')&&[...catalog.querySelectorAll('.sun-live-catalog-logo')].every(i=>i.src===data);
window.sunOpenRoutes();await document.getElementById('routePrint').onclick();
const route=document.querySelector('#preview .dialog');
const html=window.sunClientOfferDebugHtml({brandName:'Лист',brandCity:'Казань',logo:'',items:[],pricing:{},event:'Обед'});
return {counts,catalogOK,routeName:route.querySelector('.sun-custom-brand')?.textContent,routeLogo:route.querySelector('.sun-custom-brand img')?.src,logoDrawn:images.includes(data),legacy:texts.filter(t=>/солнц|SUN \/ CATERING|САНКТ-ПЕТЕРБУРГ/i.test(t)),neutralHeader:html.includes('<strong>Лист</strong>')};
},data);
expect(result.counts).toHaveLength(21);expect(result.counts.every(n=>n>0)).toBe(true);
expect(result.catalogOK).toBe(true);expect(result.routeName).toContain('Лист');expect(result.routeLogo).toBe(data);
expect(result.logoDrawn).toBe(true);expect(result.legacy).toEqual([]);expect(result.neutralHeader).toBe(true);
});

View File

@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url';
export default defineConfig({
testDir:'.',
testMatch:['app.spec.mjs','theme-startup.spec.mjs'],
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs'],
timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},

View File

@ -7,6 +7,18 @@ async function denied(s,p,re){await assert.rejects(q(s,p),re);}
await db.exec(`create function realtime.send(jsonb,text,text,boolean) returns void language plpgsql as $$begin end$$;`);
for(let i=0;i<ids.length;i++)await q("insert into auth.users(id,email,email_confirmed_at) values($1,$2,now())",[ids[i],`test${i}@example.invalid`]);
await q('insert into public.sun_platform_admins(user_id) values($1)',[ids[0]]);
await q('insert into public.caterium_sidebar_brand_assignment(user_id) values($1)',[ids[1]]);
await denied('insert into public.caterium_sidebar_brand_assignment(user_id) values($1)',[ids[2]],/duplicate key/);
await actor(ids[1]);
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'solnce');
await denied('select * from public.caterium_sidebar_brand_assignment',[],/permission denied/);
await denied('update public.caterium_sidebar_brand_assignment set user_id=$1',[ids[2]],/permission denied/);
await db.exec('reset role');
await q('update auth.users set email_confirmed_at=null where id=$1',[ids[1]]);
await actor(ids[1]);assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
await db.exec('reset role');await q('update auth.users set email_confirmed_at=now() where id=$1',[ids[1]]);
await actor(ids[2]);assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
await db.exec('reset role; set role anon');await denied('select public.caterium_my_sidebar_brand()',[],/permission denied/);
await actor(ids[0]);await denied('select public.sun_dev_dashboard()',[],/AAL2/);
await actor(ids[0],'aal2');
for(let i=1;i<=2;i++)await q('select public.sun_dev_create_trial_promo($1,$2,14,7,1,\'full\',null)',[`RECOVERY-TEST-${i}`,`test${i}@example.invalid`]);
@ -22,6 +34,10 @@ await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payl
const snap=(await one('select public.sun_v17_entity_snapshot($1) as s',[ws])).s;
assert.equal(snap.orders.length,1);assert.equal(snap.catalog.length,1);
const client=await one('select * from public.sun_v17_save_client_v1772($1,$2,$3,null,$4)',[ws,'p:79990000000',{identity:{name:'Test client',phone:'+79990000000'}},'recovery-test']);
payload.storage.sunPdfBrandNameV1='Company A';
payload.storage.sunPdfBrandLogoV1='data:image/png;base64,dGVzdA==';
row=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,payload,'brand-owner',row.revision]);
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[ws])).payload.storage.sunPdfBrandNameV1,'Company A');
assert.equal(client.version,1);
await q('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws]);
await q("select public.sun_v17_create_backup($1,'daily','test')",[ws]);
@ -33,10 +49,14 @@ await q('select * from public.sun_list_workspace_members($1)',[ws]);
await q("select public.sun_employee_prepare_v28($1,'test3@example.invalid','Viewer','viewer')",[ws]);
await q("select public.sun_employee_finalize_v28($1,$2,'Viewer','viewer')",[ws,ids[3]]);
await actor(ids[2]);const foreign=(await one('select public.sun_create_workspace() as id')).id;
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).payload.storage.sunPdfBrandLogoV1,undefined);
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
await denied('select * from public.sun_fetch_app_state($1)',[ws],/Access denied/);
await denied('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/);
await denied('select public.sun_dev_dashboard()',[],/administrator/);
await actor(ids[3]);
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,{...payload,storage:{...payload.storage,sunPdfBrandNameV1:'Hijacked'}},'viewer-brand',row.revision],/Нет права/);
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,{...payload,storage:{...payload.storage,sunOrders:{t:'j',v:[{id:2}]}}},'viewer',row.revision],/Нет права/);
await actor(ids[0],'aal2');
for(const f of ['sun_dev_dashboard','sun_dev_list_companies','sun_dev_list_companies_v1768','sun_dev_list_users','sun_dev_list_errors','sun_dev_list_activity','sun_dev_list_trial_promos'])await q(`select * from public.${f}()`);

View File

@ -23,6 +23,8 @@ create publication supabase_realtime;
grant usage on schema public,auth,storage to anon,authenticated,service_role;
grant execute on all functions in schema auth to anon,authenticated,service_role;`);
const files=JSON.parse(fs.readFileSync(new URL('manifest.json',import.meta.url),'utf8'));
// The manifest is the immutable fresh baseline; apply subsequent migrations too.
files.push(...fs.readdirSync(new URL('supabase/migrations/',root)).filter(name=>name>'20260917150000_fresh_caterium.sql'&&name.endsWith('.sql')).sort().map(name=>'supabase/migrations/'+name));
for(const file of files){try{await db.exec(fs.readFileSync(new URL(file,root),'utf8'));console.log('PASS '+file);}catch(e){console.error(JSON.stringify({file,message:e.message,detail:e.detail,where:e.where,position:e.position},null,2));process.exit(1);}}
console.log((await db.query(`select count(*) as tables from pg_tables where schemaname='public'`)).rows);
if(process.argv.includes('--smoke')){try{await (await import('./smoke.mjs')).run(db);}catch(e){console.error(JSON.stringify({message:e.message,detail:e.detail,where:e.where,query:e.query,internalQuery:e.internalQuery},null,2));process.exit(1);}}

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(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(sw.includes('v83-20260917-theme-first-paint')&&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('20260917-theme-first-paint')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(sw.includes('v84-20260917-company-branding')&&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('20260917-company-branding')&&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("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');
@ -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(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v83-20260917-theme-first-paint'),'release manifest points to current PWA cache');
check(String(releaseManifest.pwaCache||'').includes('v84-20260917-company-branding'),'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(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&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');
@ -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(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(index.includes('20260917-theme-first-paint'),'index cache bust is v17.7.3');
check(sw.includes('v83-20260917-theme-first-paint')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(index.includes('20260917-company-branding'),'index cache bust is v17.7.3');
check(sw.includes('v84-20260917-company-branding')&&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(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');

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');
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(!sw.includes('20260917-theme-first-paint')||!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('20260917-theme-first-paint')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!sw.includes('20260917-company-branding')||!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('20260917-company-branding')||!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("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');