caterium-app/public/core/training-catalog.js
pavlov346346-source 40b5c146c2 feat: add photos for the 34 training-catalog items that had none
The banquet menu (18 dishes) and extras/supplies/delivery items (16)
in the training catalog were seeded with photo:'', which the catalog
tiles render as a plain red placeholder square. Sourced a real photo
for each item (mostly Pexels, free license, picked and visually
reviewed one by one to avoid mismatches -- several first search hits
were wrong: a live chicken for "chicken caesar salad", a person in
frame, branded bottles/cups) and two AI-generated (banquet-caprese,
banquet-roastbeef). Converted to square 1024x1024 WebP with sharp.

Wires the paths through the actual source of truth: ops/demo/trial-
banquet-data.mjs and trial-extras-data.mjs now set photo to the real
path, then ops/demo/build-trial-*.mjs regenerated public/demo/*.json
and the matching Supabase seed-function migrations, so the client's
static JSON fetch and the server-side company-provisioning function
stay in sync.

Bumped the demo-catalog fetch's own cache-busting query string in
training-catalog.js (and its script-tag/precache version), since it's
fetched by the client with a separate version from the outer script
tags -- the same stale-cache class of bug fixed earlier this session.

Verified: all 34 referenced files resolve (200) and decode as real
1024x1024 images; confirmed via the actual rendered catalog tiles
(#tiles img[src]) that the extras category -- where the user's
red-square screenshot was taken -- now serves the real photo paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:00:45 +03:00

168 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Optional learning examples. Visibility belongs to a profile in a workspace;
records stay available for existing orders, recipes and document snapshots. */
(()=>{
'use strict';
if(window.CateriumTrainingCatalog)return;
const KEY='sunTrialDemoV1',EVENT='caterium:training-catalog-changed';
const IDS={"catalog":["demo-v1-bruschetta-tomato","demo-v1-salmon-cream","demo-v1-chicken-sandwich","demo-v1-caprese","demo-v1-mushroom-tartlet","demo-v1-turkey-wrap","demo-v1-cheese-fruit","demo-v1-meat-assortment","demo-v1-vegetables-hummus","demo-v1-berry-dessert","demo-banquet-v1-caprese","demo-banquet-v1-roastbeef","demo-banquet-v1-salmon-roll","demo-banquet-v1-hummus","demo-banquet-v1-caesar","demo-banquet-v1-olivier","demo-banquet-v1-greek","demo-banquet-v1-julienne","demo-banquet-v1-stuffed-mushrooms","demo-banquet-v1-chicken","demo-banquet-v1-cod","demo-banquet-v1-beef-hot","demo-banquet-v1-mash","demo-banquet-v1-rice","demo-banquet-v1-berry-cream","demo-banquet-v1-cheese-honey","demo-banquet-v1-fruit","demo-banquet-v1-bread","demo-extras-v1-salmon-caprese","demo-extras-v1-meat-cheese","demo-extras-v1-mini-buffet","demo-extras-v1-water","demo-extras-v1-sparkling","demo-extras-v1-juice","demo-extras-v1-mors","demo-extras-v1-plate","demo-extras-v1-fork","demo-extras-v1-glass","demo-extras-v1-napkin","demo-extras-v1-ice","demo-extras-v1-tablecloth","demo-extras-v1-serving-kit","demo-extras-v1-delivery-city","demo-extras-v1-delivery-outer"],"stock":["demo-v1-stock-baguette","demo-v1-stock-tomato","demo-v1-stock-basil","demo-v1-stock-oil","demo-v1-stock-rye","demo-v1-stock-salmon","demo-v1-stock-cream","demo-v1-stock-cucumber","demo-v1-stock-dill","demo-v1-stock-toast","demo-v1-stock-chicken","demo-v1-stock-lettuce","demo-v1-stock-mozzarella","demo-v1-stock-cherry","demo-v1-stock-tartlet","demo-v1-stock-mushroom","demo-v1-stock-cooking-cream","demo-v1-stock-gouda","demo-v1-stock-onion","demo-v1-stock-tortilla","demo-v1-stock-turkey","demo-v1-stock-pepper","demo-v1-stock-brie","demo-v1-stock-blue","demo-v1-stock-grape","demo-v1-stock-walnut","demo-v1-stock-honey","demo-v1-stock-cracker","demo-v1-stock-salami","demo-v1-stock-beef","demo-v1-stock-pickle","demo-v1-stock-olive","demo-v1-stock-mustard","demo-v1-stock-carrot","demo-v1-stock-hummus","demo-v1-stock-biscuit","demo-v1-stock-strawberry","demo-v1-stock-blueberry","demo-v1-stock-sugar","demo-v1-stock-box","demo-v1-stock-skewer","demo-v1-stock-cup","demo-v1-stock-sauce-cup","demo-banquet-v1-stock-potato","demo-banquet-v1-stock-butter","demo-banquet-v1-stock-milk","demo-banquet-v1-stock-egg","demo-banquet-v1-stock-peas","demo-banquet-v1-stock-chicken-raw","demo-banquet-v1-stock-whitefish","demo-banquet-v1-stock-lemon","demo-banquet-v1-stock-rice","demo-extras-v1-stock-water","demo-extras-v1-stock-sparkling","demo-extras-v1-stock-juice","demo-extras-v1-stock-mors","demo-extras-v1-stock-plate","demo-extras-v1-stock-fork","demo-extras-v1-stock-glass","demo-extras-v1-stock-napkin","demo-extras-v1-stock-ice","demo-extras-v1-stock-tablecloth","demo-extras-v1-stock-serving-kit"],"suppliers":["demo-v1-supplier-fresh","demo-v1-supplier-protein","demo-v1-supplier-bakery","demo-v1-supplier-grocery","demo-v1-supplier-pack"]};
const sets=Object.fromEntries(Object.entries(IDS).map(([k,v])=>[k,new Set(v)]));
const $=id=>document.getElementById(id),copy=v=>JSON.parse(JSON.stringify(v));
const cloud=()=>window.SunCloudV2,repo=()=>window.CateriumDataV1773;
let changing=false,epoch=0,busy=false,desired=null,bundlePromise=null,lastState='',note='';
function read(key,fallback){const raw=localStorage.getItem(key);if(raw===null)return copy(fallback);return JSON.parse(raw);}
function metadata(){try{const m=read(KEY,{});return m&&typeof m==='object'&&!Array.isArray(m)?m:{}}catch(_){return {}}}
function context(){const c=cloud();return {user:String(c?.getSession?.()?.user?.id||''),workspace:String(c?.getWorkspace?.()?.id||'')};}
function enabled(){
if(changing)return false;
const c=context(),s=metadata().trainingCatalog;
return Boolean(c.user&&c.workspace&&s?.workspaceId===c.workspace&&s?.profiles?.[c.user]===true);
}
function isSample(item,kind='catalog'){
return Boolean(item&&((sets[kind]||sets.catalog).has(String(item.id))||(kind==='stock'&&item.trainingCatalog===true)));
}
function visible(item,kind='catalog'){return item?.hidden!==true&&(!isSample(item,kind)||enabled());}
function badge(item){return isSample(item)&&enabled()?'<small class="ct-training-badge">Учебное</small>':'';}
function canManage(){
const c=cloud(),who=context();
return !changing&&Boolean(who.user&&who.workspace)&&!c?.isSupportMode?.()&&!c?.getSupportMode?.()&&
(c?.hasPermission?.('catalog.edit')===true||c?.hasPermission?.('app.write')===true)&&window.SunSaaSV16?.isWritable?.()!==false;
}
async function loadBundle(){
if(bundlePromise)return bundlePromise;
bundlePromise=(async()=>{
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000);
try{
const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{
const r=await fetch(`demo/${name}.json?v=20260922-training-photos`,{signal:controller.signal,credentials:'same-origin'});
if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.');
const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value;
}));
if(![base.boxes,base.stock,base.suppliers,banquet.banquet,banquet.stock,extras.items,extras.stock].every(Array.isArray))throw new Error('Учебный каталог повреждён.');
const result={boxes:[...base.boxes,...banquet.banquet,...extras.items],stock:[...base.stock,...banquet.stock,...extras.stock],suppliers:base.suppliers,scenario:base.scenario,batch:base.batch};
for(const [domain,rows] of Object.entries({catalog:result.boxes,stock:result.stock,suppliers:result.suppliers})){
if(rows.length!==sets[domain].size||new Set(rows.map(x=>x?.id)).size!==rows.length||rows.some(x=>!sets[domain].has(x?.id)||typeof x?.name!=='string'))throw new Error('Неполный учебный каталог.');
}
return result;
}finally{clearTimeout(timeout);}
})().catch(error=>{bundlePromise=null;throw error;});
return bundlePromise;
}
// Pure additive merge: never overwrite a user's prices, recipes, inventory,
// supplier contacts or orders. New inventory has distinct names so a test
// recipe does not resolve to an existing real product with the same name.
function merge(seed,current){
for(const domain of ['boxes','stock','suppliers'])if(!Array.isArray(current[domain]))throw new Error('Рабочие данные ещё не загружены. Повторите после загрузки.');
const next=copy(current),maps=Object.fromEntries(['boxes','stock','suppliers'].map(k=>[k,new Map(next[k].map(x=>[String(x.id),x]))]));
const sku=(name,unit)=>`${String(name).trim().toLowerCase()}|${String(unit).trim().toLowerCase()}`;
const names=new Set(next.stock.map(p=>sku(p.name,p.unit)));
for(const source of seed.suppliers)if(!maps.suppliers.has(source.id)){
const row={...copy(source),trainingCatalog:true};next.suppliers.push(row);maps.suppliers.set(row.id,row);
}
for(const source of seed.stock)if(!maps.stock.has(source.id)){
let name=`Учебное: ${source.name.replace(/^Демо:\s*/,'')}`,n=2;
while(names.has(sku(name,source.unit)))name=`Учебное: ${source.name} (${n++})`;
const row={...copy(source),name,qty:0,min:0,trainingCatalog:true};next.stock.push(row);maps.stock.set(row.id,row);names.add(sku(name,row.unit));
}
const byIngredient=new Map(seed.stock.map(s=>[sku(s.name,s.unit),maps.stock.get(s.id)]));
for(const source of seed.boxes)if(!maps.boxes.has(source.id)){
const row=copy(source);row.demo=true;
row.ingredients=(row.ingredients||[]).map(part=>{const p=byIngredient.get(sku(part[0],part[2]));return p?[p.name,...part.slice(1)]:part;});
if(row.ttk?.rows)row.ttk.rows=row.ttk.rows.map(r=>{const p=maps.stock.get(r.productId);return p?{...r,name:p.name}:r;});
next.boxes.push(row);maps.boxes.set(row.id,row);
}
return next;
}
function liveCatalog(){return repo()?.catalog?.list?.()||read('sunBoxes',[]);}
function notify(){
renderSettings();window.CateriumTrialDemo?.render?.();
window.dispatchEvent(new CustomEvent(EVENT));
// Only selection UI is refreshed. Stored orders, their line prices and the
// editor node are not filtered, deleted, regenerated or repriced here.
window.render?.();window.sunRenderSuppliers?.();
}
async function setEnabled(value){
if(busy)throw new Error('Дождитесь завершения загрузки учебного каталога.');
if(typeof value!=='boolean')throw new Error('Некорректное значение переключателя.');
if(!canManage())throw new Error('Для изменения учебного каталога нужны права редактирования каталога в своей компании.');
const ticket=epoch,who=context();busy=true;desired=value;note=value?'Загружаю учебные примеры…':'';renderSettings();
try{
const seed=value?await loadBundle():null;
if(ticket!==epoch||JSON.stringify(context())!==JSON.stringify(who))throw new Error('Профиль или компания сменились. Изменение отменено.');
if(!canManage())throw new Error('Права изменились. Учебный каталог не изменён.');
const meta=read(KEY,{});if(!meta||typeof meta!=='object'||Array.isArray(meta))throw new Error('Не удалось прочитать настройки. Ничего не изменено.');
const oldMeta=meta.trainingCatalog;
const nextMeta={...meta,trainingCatalog:{workspaceId:who.workspace,profiles:{...(oldMeta?.workspaceId===who.workspace?oldMeta.profiles:{}),[who.user]:value}}};
const previous={sunBoxes:localStorage.getItem('sunBoxes'),sunStock:localStorage.getItem('sunStock'),sunSuppliers:localStorage.getItem('sunSuppliers'),[KEY]:localStorage.getItem(KEY)};
const before=liveCatalog();let next=null;
if(value){
next=merge(seed,{boxes:before,stock:read('sunStock',[]),suppliers:read('sunSuppliers',[])});
nextMeta.version=1;nextMeta.batch=meta.batch||seed.batch;nextMeta.scenario=meta.scenario||seed.scenario;
}
try{
if(next){
// Commit synchronously before cloud autosync takes its next snapshot.
// persistLocal:false writes only catalog, not unrelated order domains.
localStorage.setItem('sunStock',JSON.stringify(next.stock));
localStorage.setItem('sunSuppliers',JSON.stringify(next.suppliers));
repo().catalog.replace(next.boxes,{persistLocal:false,reason:'training-catalog.enable'});
}
localStorage.setItem(KEY,JSON.stringify(nextMeta));
}catch(error){
for(const [key,raw] of Object.entries(previous)){try{if(raw===null)localStorage.removeItem(key);else localStorage.setItem(key,raw);}catch(_){}}
try{if(typeof boxes!=='undefined'){boxes.length=0;boxes.push(...copy(before));}}catch(_){}
throw new Error('Не удалось сохранить учебный каталог. Проверьте свободное место и повторите.');
}
note=value?'Учебный каталог включён. Откройте «Меню» или «Новый заказ».':'Учебные позиции скрыты. Ваши данные и созданные заказы сохранены.';
lastState=stateSignature();notify();return true;
}catch(error){if(ticket===epoch)note=error?.name==='AbortError'?'Загрузка заняла слишком долго. Проверьте интернет и повторите.':error.message;throw error;}
finally{if(ticket===epoch){busy=false;desired=null;renderSettings();}}
}
function stateSignature(){const c=context();return JSON.stringify([c.user,c.workspace,enabled(),canManage()]);}
function renderSettings(){
const host=$('enterprise-settings')?.querySelector('.enterprise-grid')||$('enterprise-settings');
const who=context();if(!host)return;
if(changing||!who.user||!who.workspace){$('ctTrainingSettings')?.remove();return;}
let card=$('ctTrainingSettings');
if(!card){
card=document.createElement('section');card.id='ctTrainingSettings';card.className='enterprise-card';
card.innerHTML='<h2>Обучение и знакомство</h2><label class="ct-training-toggle"><input id="ctTrainingEnabled" type="checkbox" aria-describedby="ctTrainingDescription ctTrainingSafety"><span><b>Учебный каталог</b><small>Готовые боксы и блюда для знакомства с Caterium</small></span></label><p id="ctTrainingDescription">Фотографии, составы, технологические карты, учебные цены и поставщики. Можно собрать пробный заказ, посмотреть предложение клиенту, заготовки и закупки — без ручного заполнения каталога.</p><p id="ctTrainingSafety">Снимите галочку, чтобы скрыть учебные позиции. Свои блюда, изменения учебных карточек и созданные заказы не удаляются. Переключатель действует для вашего профиля в этой компании. Складские операции выполняются только по вашей команде.</p><p id="ctTrainingAccess" class="hint"></p><p id="ctTrainingStatus" role="status" aria-live="polite"></p>';
host.prepend(card);
card.querySelector('input').addEventListener('change',e=>{setEnabled(e.target.checked).catch(()=>{});});
// The trial guide (order, stock, TTK) is mounted inside this card while training is on.
queueMicrotask(()=>window.CateriumTrialDemo?.render?.());
}
const check=$('ctTrainingEnabled');check.checked=busy?desired:enabled();check.disabled=busy||!canManage()||!repo()?.catalog;
card.setAttribute('aria-busy',String(busy));
const access=!canManage()?'Для включения нужны права редактирования каталога. Режим просмотра не даёт дополнительных прав.':'';
if($('ctTrainingAccess').textContent!==access)$('ctTrainingAccess').textContent=access;
if($('ctTrainingStatus').textContent!==note)$('ctTrainingStatus').textContent=note;
}
function refresh(){
const signature=stateSignature();renderSettings();
if(signature!==lastState){lastState=signature;window.CateriumTrialDemo?.render?.();window.dispatchEvent(new CustomEvent(EVENT));window.render?.();}
}
function boot(){
const style=document.createElement('style');style.textContent=`
#ctTrainingSettings{min-width:0;padding:18px;border:1px solid var(--sun-ui-border,#deded6);border-radius:14px;background:var(--sun-ui-card,#fff);color:var(--sun-ui-text,#27241e)}
#ctTrainingSettings h2{margin:0 0 14px;font-size:18px}#ctTrainingSettings p{font-size:12px;line-height:1.5;margin:10px 0 0}
#ctTrainingSettings .ct-training-toggle{display:flex!important;flex-direction:row!important;align-items:flex-start;gap:12px;margin:0;cursor:pointer}
#ctTrainingSettings #ctTrainingEnabled{width:20px!important;height:20px!important;min-height:20px;margin:2px 0 0;flex:0 0 20px;accent-color:#b88c2e}
#ctTrainingSettings .ct-training-toggle b{display:block;font-size:15px;line-height:1.4}#ctTrainingSettings .ct-training-toggle small{display:block;font-size:12px;line-height:1.5;color:var(--sun-ui-muted,#72766e)}
#ctTrainingSettings input:focus-visible{outline:2px solid #b88c2e;outline-offset:3px}#ctTrainingStatus:empty,#ctTrainingAccess:empty{display:none}
.ct-training-badge{display:inline-block!important;width:auto!important;max-width:100%;padding:2px 5px;margin:3px 0;border-radius:5px;background:#ece6d7;color:#695a37!important;font:700 10px/1.4 Arial,sans-serif!important;white-space:nowrap}
@media print{#ctTrainingSettings{display:none!important}}
`;document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',()=>{changing=true;epoch++;busy=false;desired=null;note='';lastState='';$('ctTrainingSettings')?.remove();});
window.addEventListener('sun:cloud-state-applied',()=>{changing=false;refresh();});
window.addEventListener('sun:cloud-permissions-changed',()=>{changing=false;refresh();});
window.addEventListener('sun:subscription-changed',refresh);
window.addEventListener('storage',e=>{if(e.key===KEY)refresh();});
document.addEventListener('click',e=>{if(e.target.closest?.('header nav button'))renderSettings();});
const attach=()=>{const host=$('enterprise-settings');if(!host)return false;renderSettings();new MutationObserver(()=>{if(!$('ctTrainingSettings'))renderSettings();}).observe(host,{childList:true,subtree:true});return true;};
if(!attach()){const waiting=new MutationObserver(()=>{if(attach())waiting.disconnect();});waiting.observe(document.body,{childList:true});}
refresh();
}
window.CateriumTrainingCatalog=Object.freeze({enabled,visible,isSample,badge,canManage,setEnabled,merge,refresh,renderSettings});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();