diff --git a/docs/release-manifest.json b/docs/release-manifest.json
index c8248b5..c9695b9 100644
--- a/docs/release-manifest.json
+++ b/docs/release-manifest.json
@@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
- "pwaCache": "v94-20260918-sync-demo-images-2",
+ "pwaCache": "v95-20260918-proposal-loading",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,
@@ -409,6 +409,11 @@
"proposalLayout": "core/proposal-layout.js",
"proposalLocalCyrillicFonts": true,
"proposalRasterDpi": 290,
+ "proposalPhotoRequestTimeoutMs": 6000,
+ "proposalMediaBudgetMs": 10000,
+ "proposalMediaConcurrent": true,
+ "proposalMissingPhotoNotice": true,
+ "proposalLoadCancellation": true,
"cloudReadTimeoutMs": 12000,
"cloudFallbackTimeoutMs": 15000,
"cloudWriteTimeoutMs": 35000,
diff --git a/docs/releases/2026-09-18-PROPOSAL-LOADING.md b/docs/releases/2026-09-18-PROPOSAL-LOADING.md
new file mode 100644
index 0000000..11e704c
--- /dev/null
+++ b/docs/releases/2026-09-18-PROPOSAL-LOADING.md
@@ -0,0 +1,11 @@
+# Client proposal loading recovery
+
+Release cache: `v95-20260918-proposal-loading`.
+
+The proposal preview could wait indefinitely for a photo response/body or unrelated UI font, leaving the download button disabled. Photos, logo and gallery now load together, with a 6-second limit per request and a 10-second budget for the media batch, including fallback URLs. The PDF renderer waits only for its three local fonts. Unavailable photos no longer block order text, quantities or prices; the preview identifies missing images and offers a refresh.
+
+Closing or refreshing the proposal cancels the previous media load. Rendering failures show a retry message instead of an endless loading indicator. Both preview and download still share the same rendered A4 pages. The order's selected template is copied into newly generated and legacy snapshots before rendering, removing a timing dependency on the template picker.
+
+Repeated cloud notifications no longer rebuild an unchanged proposal template, and a closed proposal does not rerender in the background. Explicit per-order design changes still update the open preview, including older snapshots.
+
+Regression coverage includes a hanging photo, refresh recovery, a stalled response body cancelled on close, unrelated fonts that never finish, render failure/retry, all 21 covers, long Cyrillic content and actual PDF download/preview parity on desktop and mobile.
diff --git a/public/app-runtime.js b/public/app-runtime.js
index 9d5e78a..f0edc09 100644
--- a/public/app-runtime.js
+++ b/public/app-runtime.js
@@ -1045,7 +1045,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
return {id:String(item.id??line.id??''),name:String(item.name||line.name||'Позиция'),categoryId,categoryName:catName(categoryId),catalogSection:String(item.catalogSection||''),qty,unitPrice:price,sum:price*qty,weight:String(item.weight||''),boxGuests:String(item.guests||''),photo:String(item.photo||''),composition:comp,compositionTotal,pieces};
}).filter(x=>x.qty>0);
const extras=all.filter(item=>[1,2,3].includes(Number(item.category))&&!selected.has(String(item.id))&&!item.hidden).slice(0,6).map(item=>({name:String(item.name||''),categoryName:catName(item.category)})).filter(x=>x.name);
- return {version:8,createdAt:new Date().toISOString(),orderId:order?.id||'',sourceSignature:orderSignature(order),event:String(order?.event||''),date:String(order?.date||''),time:String(order?.time||''),client:String(order?.contact||''),guests,foodGrams,foodPieces,drinkMl,pricing:p,promoCode:String(order?.promoCode||''),items,extras,offerOverrides:structuredClone(order?.clientOfferSnapshot?.offerOverrides||{})};
+ const template=order?.clientOfferTemplateId||order?.clientOfferSnapshot?.offerTemplateId;
+ return {version:8,createdAt:new Date().toISOString(),orderId:order?.id||'',offerTemplateId:OFFER_TEMPLATE_IDS.has(template)?template:offerTemplateId(),sourceSignature:orderSignature(order),event:String(order?.event||''),date:String(order?.date||''),time:String(order?.time||''),client:String(order?.contact||''),guests,foodGrams,foodPieces,drinkMl,pricing:p,promoCode:String(order?.promoCode||''),items,extras,offerOverrides:structuredClone(order?.clientOfferSnapshot?.offerOverrides||{})};
}
function persistSnapshot(orderId,snapshot){
let list=getOrders(),i=list.findIndex(o=>String(o.id)===String(orderId));if(i<0)return false;
@@ -1058,7 +1059,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function ensureModal(){
if($('sunClientOfferModal'))return;
const modal=document.createElement('div');modal.id='sunClientOfferModal';modal.className='modal';modal.innerHTML=`
Предложение клиенту
Заказ изменён — предложение будет автоматически обновлено из текущих данных.
Фото «Как это выглядит на вашем столе»В PDF автоматически попадёт 1 или 2 фото только если на последней странице есть свободное место.
Изменения относятся только к этому предложению. Просмотр ниже — те же страницы, которые попадут в скачанный PDF.
`;document.body.appendChild(modal);
- $('sunOfferClose').onclick=()=>window.closeModal?.('sunClientOfferModal');
+ $('sunOfferClose').onclick=()=>{offerRenderSeq++;offerAssetController?.abort();clearTimeout(currentOffer.editTimer);window.closeModal?.('sunClientOfferModal')};
$('sunOfferRefresh').onclick=refreshCurrentOffer;$('sunOfferPdf').onclick=downloadCurrentOfferPdf;$('sunOfferEdit').onclick=toggleOfferEditor;bindOfferEditor();
}
@@ -1086,32 +1087,40 @@ window.SUN_LEGACY_CATALOG_V175=[];
const SAFE_PIXEL='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zwv8AAAAASUVORK5CYII=';
const isSafeRasterDataUrl=src=>/^data:image\/(?!svg\+xml)[a-z0-9.+-]+;/i.test(String(src||''));
- async function asDataUrlOne(src){
- if(!src)return'';
+ async function asDataUrlOne(src,{signal}={}){
+ if(!src||signal?.aborted)return '';
const raw=window.SunSafe.imageAssetSrc(src);
if(isSafeRasterDataUrl(raw))return raw;
- if(/^data:/i.test(raw))return'';
+ if(/^data:/i.test(raw))return '';
+ const controller=new AbortController(),cancel=()=>controller.abort();
+ signal?.addEventListener('abort',cancel,{once:true});
+ const timer=setTimeout(cancel,6000);
try{
const url=new URL(raw,document.baseURI||location.href);
- if(!['http:','https:','blob:'].includes(url.protocol))return'';
+ if(!['http:','https:','blob:'].includes(url.protocol))return '';
const sameOrigin=url.protocol==='blob:'||url.origin===location.origin;
- const res=await fetch(url,{mode:'cors',credentials:sameOrigin?'same-origin':'omit',cache:'force-cache'});
- if(!res.ok)throw new Error(`HTTP ${res.status}`);
- const blob=await res.blob();
- if(!/^image\/(?!svg\+xml)[a-z0-9.+-]+$/i.test(String(blob.type||'')))throw new Error('Неподдерживаемый формат изображения');
- const data=await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result||''));r.onerror=()=>reject(r.error||new Error('FileReader'));r.readAsDataURL(blob)});
+ const read=async()=>{
+ const res=await fetch(url,{mode:'cors',credentials:sameOrigin?'same-origin':'omit',cache:'force-cache',signal:controller.signal});
+ if(!res.ok)throw new Error(`HTTP ${res.status}`);
+ const blob=await res.blob();
+ if(!/^image\/(?!svg\+xml)[a-z0-9.+-]+$/i.test(String(blob.type||'')))return '';
+ return await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result||''));r.onerror=()=>reject(r.error||new Error('FileReader'));r.readAsDataURL(blob)});
+ };
+ // The explicit race also bounds a stalled response body/FileReader, not
+ // just receipt of HTTP headers. Aborting stops the underlying fetch.
+ const data=await Promise.race([read(),new Promise(resolve=>controller.signal.addEventListener('abort',()=>resolve(''),{once:true}))]);
return isSafeRasterDataUrl(data)?data:'';
- }catch(err){return''}
+ }catch(_){return ''}
+ finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);controller.abort()}
}
- async function asDataUrl(src){
+ async function asDataUrl(src,options={}){
const list=window.SunCatalogPhoto?.candidates?.(src)||[src];
- for(const candidate of list){const data=await asDataUrlOne(candidate);if(data)return data;}
- console.warn('PDF image unavailable after all fallbacks:',src);
- return'';
+ for(const candidate of list){if(options.signal?.aborted)break;const data=await asDataUrlOne(candidate,options);if(data)return data;}
+ return '';
}
async function preparedSnapshot(snapshot){
const copy=structuredClone(snapshot),liveCatalog=getBoxes(),legacyCatalog=getLegacyBoxes();
- // Preview uses normal image URLs. Expensive Base64 conversion is deferred until PDF download.
+ // Resolve current company/catalog presentation before the shared preview/PDF media pass.
const companyBrand=window.CateriumBranding.identity();
copy.logo=companyBrand.logo;copy.brandName=companyBrand.name;
copy.brandContacts=companyBrand.contacts;copy.brandCity=companyBrand.city;
@@ -1139,14 +1148,26 @@ window.SUN_LEGACY_CATALOG_V175=[];
try{const cfg=offerSettingsForSnapshot(copy);copy.controlLines=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL;copy.extraServices=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]);copy.controlTitle=cfg.texts?.controlTitle||'';copy.extrasTitle=cfg.texts?.extrasTitle||'';}catch(_){}
return copy;
}
- async function preparedPdfSnapshot(prepared){
- const copy=structuredClone(prepared),cache=new Map();
+ async function preparedPdfSnapshot(prepared,{signal,onProgress}={}){
+ const copy=structuredClone(prepared),cache=new Map(),missing=new Set(),controller=new AbortController();
+ const cancel=()=>controller.abort();signal?.addEventListener('abort',cancel,{once:true});if(signal?.aborted)cancel();
+ // One budget for the whole batch, including all fallback candidates.
+ const timer=setTimeout(cancel,10000);let done=0;
if(copy.orderId&&window.SunOfferWorkspaceV1769?.galleryFor)copy.finalGallery=window.SunOfferWorkspaceV1769.galleryFor(copy.orderId);
- const resolve=src=>{const key=String(src||'');if(!key)return Promise.resolve('');if(!cache.has(key))cache.set(key,asDataUrl(key));return cache.get(key);};
- copy.logo=await resolve(copy.logo);
- copy.finalGallery=(await Promise.all((copy.finalGallery||[]).map(resolve))).filter(Boolean);
- await Promise.all((copy.items||[]).map(async item=>{item.photoData=(await resolve(item.photoData||item.photo||''))||copy.logo;}));
- return copy;
+ const cfg=copy.pdfSettings||offerSettingsForSnapshot(copy);if(cfg.showFinalPhoto===false)copy.finalGallery=[];
+ const resolve=src=>{
+ const key=String(src||'');if(!key)return Promise.resolve('');
+ if(!cache.has(key))cache.set(key,asDataUrl(key,{signal:controller.signal}).then(data=>{if(!data)missing.add(key);onProgress?.(++done,cache.size);return data}));
+ return cache.get(key);
+ };
+ try{
+ // A slow decorative gallery must not delay every product photo.
+ const [logo,photos,gallery]=await Promise.all([resolve(copy.logo),Promise.all((copy.items||[]).map(item=>resolve(item.photoData||item.photo||''))),Promise.all((copy.finalGallery||[]).map(resolve))]);
+ copy.logo=logo;copy.finalGallery=gallery.filter(Boolean);
+ (copy.items||[]).forEach((item,i)=>{item.photoData=photos[i]||logo;item.photo=photos[i]||''});
+ copy.missingPhotoCount=missing.size;
+ return copy;
+ }finally{clearTimeout(timer);signal?.removeEventListener('abort',cancel);controller.abort()}
}
const OFFER_TEMPLATE_KEY='sunOfferTemplateV1';
@@ -1292,15 +1313,42 @@ window.SUN_LEGACY_CATALOG_V175=[];
let currentOffer={orderId:null,snapshot:null,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:false,transient:false,editTimer:null};
function renderOfferPages(pages){const host=$('sunClientOfferPreview');if(!host)return;host.innerHTML='';for(const page of (pages||[])){page.classList.add('sun-offer-page-canvas');page.setAttribute('aria-label','Страница предложения');host.appendChild(page)}}
function offerRenderMessage(text='Обновляю предложение…'){const host=$('sunClientOfferPreview');if(host)host.innerHTML=`
${esc(text)}
`}
+ let offerAssetController=null;
+ let offerRenderTemplateId=null;
async function renderOffer(snapshot){
- const renderToken=++offerRenderSeq;currentOffer.snapshot=snapshot;currentOffer.pdfBlob=null;currentOffer.pdfPromise=null;currentOffer.pages=null;offerRenderMessage();const pdfBtn=$('sunOfferPdf');if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='Готовлю просмотр…'}
- const prepared=await preparedSnapshot(snapshot);const explicitTemplate=String(snapshot?.offerTemplateId||prepared?.offerTemplateId||'').trim();prepared.offerTemplateId=OFFER_TEMPLATE_IDS.has(explicitTemplate)?explicitTemplate:offerTemplateId();
- const pdfPrepared=await preparedPdfSnapshot(prepared),pages=await renderOfferPdfPages(pdfPrepared);
- if(renderToken!==offerRenderSeq)return null;
- currentOffer.snapshot=snapshot;currentOffer.prepared=prepared;currentOffer.pdfPrepared=pdfPrepared;currentOffer.pages=pages;currentOffer.renderToken=renderToken;
- renderOfferPages(pages);syncOfferEditor();
- if(pdfBtn){pdfBtn.disabled=false;pdfBtn.textContent='Скачать PDF'}
- return pages;
+ offerAssetController?.abort();const controller=new AbortController();offerAssetController=controller;
+ const renderToken=++offerRenderSeq,current=()=>renderToken===offerRenderSeq&&!controller.signal.aborted;
+ const explicitTemplate=String(snapshot?.offerTemplateId||'').trim(),selectedTemplate=OFFER_TEMPLATE_IDS.has(explicitTemplate)?explicitTemplate:offerTemplateId();offerRenderTemplateId=selectedTemplate;
+ currentOffer.snapshot=snapshot;currentOffer.pdfBlob=null;currentOffer.pdfPromise=null;currentOffer.pages=null;
+ offerRenderMessage('Загружаю фотографии…');
+ const pdfBtn=$('sunOfferPdf');if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='Готовлю просмотр…'}
+ $('sunOfferAssetWarning')?.remove();
+ try{
+ const prepared=await preparedSnapshot(snapshot);if(!current())return null;
+ prepared.offerTemplateId=selectedTemplate;
+ // Warm only the proposal's fonts while its photos load.
+ const fonts=window.CateriumProposalPDF?.readyFonts?.();
+ const pdfPrepared=await preparedPdfSnapshot(prepared,{signal:controller.signal,onProgress:(done,total)=>{if(current())offerRenderMessage(`Загружаю фотографии: ${done} из ${total}…`)}});
+ if(!current())return null;offerRenderMessage('Готовлю страницы предложения…');
+ const pages=await withTimeout(Promise.resolve(fonts).then(()=>renderOfferPdfPages(pdfPrepared)),15000,'Предпросмотр');
+ if(!current())return null;
+ currentOffer.snapshot=snapshot;currentOffer.prepared=prepared;currentOffer.pdfPrepared=pdfPrepared;currentOffer.pages=pages;currentOffer.renderToken=renderToken;
+ renderOfferPages(pages);syncOfferEditor();
+ if(pdfPrepared.missingPhotoCount){
+ const notice=document.createElement('div');notice.id='sunOfferAssetWarning';notice.className='sun-offer-stale on';notice.setAttribute('role','status');
+ notice.textContent=`Не загрузились фотографии: ${pdfPrepared.missingPhotoCount}. Предложение открыто с доступными изображениями. Нажмите «Обновить», чтобы повторить загрузку фото.`;
+ $('sunClientOfferPreview')?.before(notice);
+ }
+ if(pdfBtn){pdfBtn.disabled=false;pdfBtn.textContent='Скачать PDF'}
+ return pages;
+ }catch(error){
+ if(!current())return null;
+ console.error('Offer preview failed:',error);
+ currentOffer.prepared=null;currentOffer.pdfPrepared=null;currentOffer.pages=null;
+ offerRenderMessage('Не удалось подготовить предложение. Нажмите «Обновить», чтобы повторить.');
+ if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='PDF недоступен'}
+ return null;
+ }finally{controller.abort();if(offerAssetController===controller)offerAssetController=null}
}
function toggleOfferEditor(){const panel=$('sunOfferEditor'),btn=$('sunOfferEdit');if(!panel)return;const on=!panel.classList.contains('on');panel.classList.toggle('on',on);if(btn)btn.textContent=on?'Скрыть редактор':'Редактировать';if(on)syncOfferEditor()}
function offerOverrideTextValue(key){const over=currentOffer.snapshot?.offerOverrides?.texts||{},cfg=offerSettings();return Object.prototype.hasOwnProperty.call(over,key)?String(over[key]??''):String(cfg.texts?.[key]??'')}
@@ -1334,10 +1382,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
// always receive the fresh snapshot so every template/PDF opens consistently.
if(!draftDiff&&!persistSnapshot(saved.id,snapshot))return;
}
+ // Older snapshots can predate the per-order template field. Resolve it
+ // before rendering instead of waiting for the asynchronously mounted picker.
+ if(!OFFER_TEMPLATE_IDS.has(snapshot.offerTemplateId))snapshot={...snapshot,offerTemplateId:OFFER_TEMPLATE_IDS.has(order.clientOfferTemplateId)?order.clientOfferTemplateId:offerTemplateId()};
currentOffer={orderId:saved.id,snapshot,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:Boolean(preferDraft),transient:draftDiff,editTimer:null};
setMeta(order,snapshot,{transient:draftDiff,autoRefreshed});
- window.modal?.('sunClientOfferModal');await renderOffer(snapshot);
- if(autoRefreshed)toast('Предложение автоматически обновлено по текущему заказу.');
+ window.modal?.('sunClientOfferModal');const pages=await renderOffer(snapshot);
+ if(pages&&autoRefreshed)toast('Предложение автоматически обновлено по текущему заказу.');
+ return pages;
}
async function openCurrentDraftOffer(){
const d=getDraft();if(!d?.id)return alert('Сначала сохраните заказ. После сохранения появится предложение клиенту.');
@@ -1346,8 +1398,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
}
async function refreshCurrentOffer(){
if(!currentOffer.orderId)return;
- await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft});
- toast('Предложение обновлено.');
+ const pages=await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft});
+ if(pages)toast('Предложение обновлено.');
}
async function ensureCurrentOfferFresh(){
if(!currentOffer.orderId)return false;
@@ -1679,7 +1731,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
}
const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.style.display='none';a.rel='noopener';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),120000);return 'download';
}
- function withTimeout(promise,ms,label='Операция'){return Promise.race([promise,new Promise((_,reject)=>setTimeout(()=>reject(new Error(label+' превысила время ожидания')),ms))])}
+ function withTimeout(promise,ms,label='Операция'){let timer;return Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(label+' превысила время ожидания')),ms)})]).finally(()=>clearTimeout(timer))}
function prepareCurrentOfferPdf(prepared,renderToken=currentOffer.renderToken){
const btn=$('sunOfferPdf');if(btn){btn.disabled=true;btn.textContent='Готовлю PDF…'}
let promise;
@@ -1720,8 +1772,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
await renderOffer(currentOffer.snapshot);
}
- const refreshOpenOffer=()=>{if(currentOffer.snapshot&¤tOffer.orderId)renderOffer(currentOffer.snapshot).catch(err=>console.warn('Offer refresh:',err))};
- window.addEventListener('sunoffertemplatechange',refreshOpenOffer);
+ const refreshOpenOffer=()=>{if(currentOffer.snapshot&¤tOffer.orderId&&$('sunClientOfferModal')?.classList.contains('on'))renderOffer(currentOffer.snapshot).catch(err=>console.warn('Offer refresh:',err))};
+ window.addEventListener('sunoffertemplatechange',event=>{
+ if(!currentOffer.snapshot||!currentOffer.orderId)return;
+ const change=event.detail||{};if(change.perOffer&&String(change.orderId)!==String(currentOffer.orderId))return;
+ const order=currentSavedOrder(currentOffer.orderId),id=change.perOffer?change.id:(order?.clientOfferTemplateId||order?.clientOfferSnapshot?.offerTemplateId||offerTemplateId());
+ if(!OFFER_TEMPLATE_IDS.has(id)||id===offerRenderTemplateId)return;
+ currentOffer.snapshot={...currentOffer.snapshot,offerTemplateId:id};refreshOpenOffer();
+ });
window.addEventListener('sunclientoffersettingschange',refreshOpenOffer);
window.sunOpenClientOffer=openOfferForOrder;window.sunOpenCurrentClientOffer=openCurrentDraftOffer;window.sunGetClientOfferPdfBlob=()=>currentOffer.pdfBlob;window.sunClientOfferDownload=downloadCurrentOfferPdf;window.sunClientOfferCatalogChanged=catalogPhotoChanged;window.sunClientOfferDebugHtml=offerHtml;window.sunClientOfferDebugPdf=async prepared=>buildOfferPdfBlob(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugPdfPages=async prepared=>renderOfferPdfPages(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugCreateSnapshot=createSnapshot;window.sunClientOfferDebugBoxCount=offerBoxCount;
(()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;ensureButton();patchOrderViewer();},50);};[$('new'),$('orders')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})();
diff --git a/public/core/performance.js b/public/core/performance.js
index 75c39a2..0849c81 100644
--- a/public/core/performance.js
+++ b/public/core/performance.js
@@ -1,7 +1,7 @@
(()=>{
'use strict';
const VERSION='17.7.3';
- const RELEASE='20260918-sync-demo-images-2';
+ const RELEASE='20260918-proposal-loading';
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(){
diff --git a/public/core/proposal-layout.js b/public/core/proposal-layout.js
index a118edb..fb8d70c 100644
--- a/public/core/proposal-layout.js
+++ b/public/core/proposal-layout.js
@@ -45,7 +45,8 @@
const face=new FontFace(family,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});
try{await Promise.race([face.load(),new Promise((_,reject)=>setTimeout(()=>reject(new Error('Font timeout')),8000))]);document.fonts.add(face)}catch(e){console.warn('Proposal font fallback:',family,e.message)}
}));
- await document.fonts.ready;
+ // Other app/UI webfonts may still be loading indefinitely. Only the
+ // three awaited proposal faces determine this canvas layout.
})();
return fontsPromise;
}
diff --git a/public/index.html b/public/index.html
index 734980b..a881b4f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,4 +1,4 @@
-
-
+
diff --git a/public/service-worker.js b/public/service-worker.js
index 5a6600e..13394ca 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -1,5 +1,5 @@
-const CACHE='sun-catering-pwa-v94-20260918-sync-demo-images-2';
-const VERSION='20260918-sync-demo-images-2';
+const CACHE='sun-catering-pwa-v95-20260918-proposal-loading';
+const VERSION='20260918-proposal-loading';
const CORE=[
'./','./index.html',`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./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}`,
diff --git a/tests/proposal-quality.spec.mjs b/tests/proposal-quality.spec.mjs
index 0058775..5e5bd68 100644
--- a/tests/proposal-quality.spec.mjs
+++ b/tests/proposal-quality.spec.mjs
@@ -1,4 +1,72 @@
import {test,expect} from '@playwright/test';
+import {demo} from '../ops/demo/trial-demo-data.mjs';
+
+test('a stalled photo cannot block the actual offer, and refresh recovers the missing image',async({page})=>{
+ test.setTimeout(35000);await boot(page);
+ await page.evaluate(d=>{
+ boxes=structuredClone(d.boxes.slice(0,6));orders=[{id:'slow-photo',event:'Проверка загрузки',lines:boxes.map(b=>({id:b.id,qty:1})),total:14900,status:'Новый',clientOfferTemplateId:'event-ticket'}];
+ const fetchOriginal=window.fetch;window.hangPhoto=true;window.photoAborted=false;
+ window.fetch=(input,init)=>{
+ if(window.hangPhoto&&String(input.url||input).includes('bruschetta-tomato.webp'))return new Promise((_,reject)=>init?.signal?.addEventListener('abort',()=>{window.photoAborted=true;reject(init.signal.reason)},{once:true}));
+ return fetchOriginal(input,init);
+ };
+ void window.sunOpenClientOffer('slow-photo');
+ },demo);
+ await expect(page.locator('#sunClientOfferPreview canvas').first()).toBeVisible({timeout:14000});
+ await expect(page.locator('#sunOfferPdf')).toBeEnabled();await expect(page.locator('#sunOfferAssetWarning')).toContainText('Не загрузились');
+ expect(await page.evaluate(()=>window.photoAborted)).toBe(true);
+ const words=await page.locator('#sunClientOfferPreview canvas').evaluateAll(cs=>cs.flatMap(c=>c.__proposalLayout).map(t=>t.text||'').join(' '));
+ for(const box of demo.boxes.slice(0,6))expect(words.replaceAll('\n',' ')).toContain(box.name);
+ await page.evaluate(()=>{window.hangPhoto=false;document.getElementById('sunOfferRefresh').click()});
+ await expect(page.locator('#sunOfferAssetWarning')).toBeHidden();await expect(page.locator('#sunOfferPdf')).toHaveText('Скачать PDF');
+});
+
+test('unrelated page fonts cannot keep the proposal font loader waiting',async({page})=>{
+ await boot(page);
+ const result=await page.evaluate(async s=>{
+ Object.defineProperty(document.fonts,'ready',{configurable:true,get:()=>new Promise(()=>{})});
+ const pages=await sunClientOfferDebugPdfPages({...s,offerTemplateId:'light'});
+ return {pages:pages.length,fonts:[...document.fonts].filter(f=>f.family.includes('Caterium')).map(f=>f.status)};
+ },base);
+ expect(result.pages).toBeGreaterThan(0);expect(result.fonts).toEqual(['loaded','loaded','loaded']);
+});
+
+test('closing a loading offer cancels its response body and does not overwrite a later offer',async({page})=>{
+ await boot(page);
+ await page.evaluate(d=>{
+ boxes=structuredClone(d.boxes.slice(0,2));orders=boxes.map((b,i)=>({id:'cancel-'+i,event:'Мероприятие '+i,lines:[{id:b.id,qty:1}],total:b.price,status:'Новый',clientOfferTemplateId:i?'event-ticket':'light'}));
+ const fetchOriginal=window.fetch;window.bodyStarted=false;window.bodyAborted=false;
+ window.fetch=async(input,init)=>{
+ if(String(input.url||input).includes('bruschetta-tomato.webp')){
+ init.signal.addEventListener('abort',()=>{window.bodyAborted=true},{once:true});
+ return {ok:true,blob:()=>{window.bodyStarted=true;return new Promise(()=>{})}};
+ }
+ return fetchOriginal(input,init);
+ };
+ window.firstOfferDone=false;void sunOpenClientOffer('cancel-0').then(()=>{window.firstOfferDone=true});
+ },demo);
+ await expect.poll(()=>page.evaluate(()=>window.bodyStarted)).toBe(true);
+ await page.evaluate(()=>document.getElementById('sunOfferClose').click());
+ await expect.poll(()=>page.evaluate(()=>window.bodyAborted&&window.firstOfferDone)).toBe(true);
+ await page.evaluate(()=>sunOpenClientOffer('cancel-1'));
+ await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','event-ticket');
+ const words=await page.locator('#sunClientOfferPreview canvas').evaluateAll(cs=>cs.flatMap(c=>c.__proposalLayout).map(t=>t.text||'').join(' '));
+ expect(words).toContain('Мероприятие 1');expect(words).not.toContain('Мероприятие 0');
+ await expect(page.locator('#sunOfferPdf')).toBeEnabled();await expect(page.locator('#sunOfferAssetWarning')).toBeHidden();
+});
+
+test('a render error shows a retry action instead of leaving a disabled loading button',async({page})=>{
+ await boot(page);
+ await page.evaluate(s=>{
+ boxes=[{id:'fixture',name:s.items[0].name,price:12000,category:0,weight:'1200 г',photo:''}];orders=[{id:'retry-offer',event:s.event,lines:[{id:'fixture',qty:2}],total:24000,status:'Новый'}];
+ window.realProposalPDF=CateriumProposalPDF;window.CateriumProposalPDF={...CateriumProposalPDF,renderPages:async()=>{throw new Error('Simulated render failure')}};
+ void sunOpenClientOffer('retry-offer');
+ },base);
+ await expect(page.locator('#sunClientOfferPreview')).toContainText('Не удалось подготовить предложение');
+ await expect(page.locator('#sunOfferPdf')).not.toHaveText('Готовлю просмотр…');await expect(page.locator('#sunOfferRefresh')).toBeEnabled();
+ await page.evaluate(()=>{window.CateriumProposalPDF=window.realProposalPDF;document.getElementById('sunOfferRefresh').click()});
+ await expect(page.locator('#sunClientOfferPreview canvas').first()).toBeVisible();await expect(page.locator('#sunOfferPdf')).toBeEnabled();
+});
async function boot(page){
await page.route('https://**',r=>r.abort());
@@ -76,6 +144,7 @@ test('an actual offer downloads the same A4 pages shown in its preview',async({p
await page.evaluate(async s=>{
boxes=[{id:'pdf-local',name:s.items[0].name,price:12000,category:0,weight:'1200 г',composition:s.items[0].composition,photo:''}];
orders=[{id:'pdf-local-order',event:s.event,contact:s.client,date:s.date,time:'18:00',guestsCount:20,lines:[{id:'pdf-local',qty:2}],total:24000,status:'Новый',clientOfferTemplateId:'event-ticket'}];
+ orders[0].clientOfferSnapshot=await sunClientOfferDebugCreateSnapshot(orders[0]);delete orders[0].clientOfferSnapshot.offerTemplateId;
await window.sunOpenClientOffer('pdf-local-order');
},base);
await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','event-ticket');
@@ -89,4 +158,12 @@ test('an actual offer downloads the same A4 pages shown in its preview',async({p
expect((body.match(/\/Type \/Page\b/g)||[]).length).toBe(pageCount);
expect((body.match(/\/MediaBox \[0 0 595\.28 841\.89\]/g)||[]).length).toBe(pageCount);
expect(body).toContain('/Width 2400');
+ // Cloud sync re-emits the default template even when this order's design is unchanged.
+ const stable=await page.evaluate(()=>{
+ const canvas=document.querySelector('#sunClientOfferPreview canvas');
+ window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{id:'cream-elegance'}}));
+ return canvas===document.querySelector('#sunClientOfferPreview canvas')&&!document.getElementById('sunOfferPdf').disabled;
+ });expect(stable).toBe(true);
+ await page.evaluate(()=>SunUXFixV1764.persistOfferTemplate('pdf-local-order','light'));
+ await expect(page.locator('#sunClientOfferPreview canvas').first()).toHaveAttribute('data-sun-proposal-template','light');
});
diff --git a/tests/release-check.mjs b/tests/release-check.mjs
index 341ec4b..f7c39cf 100644
--- a/tests/release-check.mjs
+++ b/tests/release-check.mjs
@@ -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('v94-20260918-sync-demo-images-2')&&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-sync-demo-images-2')&&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('v95-20260918-proposal-loading')&&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-proposal-loading')&&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('v94-20260918-sync-demo-images-2'),'release manifest points to current PWA cache');
+check(String(releaseManifest.pwaCache||'').includes('v95-20260918-proposal-loading'),'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=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');
@@ -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('20260918-sync-demo-images-2'),'index cache bust is v17.7.3');
-check(sw.includes('v94-20260918-sync-demo-images-2')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
+check(index.includes('20260918-proposal-loading'),'index cache bust is v17.7.3');
+check(sw.includes('v95-20260918-proposal-loading')&&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');
diff --git a/tests/static-security.mjs b/tests/static-security.mjs
index 37d971b..f82b391 100644
--- a/tests/static-security.mjs
+++ b/tests/static-security.mjs
@@ -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('20260918-sync-demo-images-2')||!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-sync-demo-images-2')||!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('20260918-proposal-loading')||!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-proposal-loading')||!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');