Compare commits

..

No commits in common. "main" and "demo-photos-and-orders" have entirely different histories.

64 changed files with 349 additions and 1393 deletions

View File

@ -1,78 +0,0 @@
name: Caterium QA
on:
push:
pull_request:
concurrency:
group: caterium-qa-${{ github.ref }}
cancel-in-progress: true
jobs:
qa:
runs-on: ubuntu-latest
env:
# registry.npmjs.org sits behind Cloudflare IPs that this self-hosted
# runner cannot reach (times out on both IPv4 and IPv6). npmmirror.com
# mirrors the full npm registry and is reachable, so point npm at it
# instead of failing every install. GitHub-hosted runners don't need
# this, which is why .github/workflows/qa.yml doesn't set it.
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Audit dependencies (retry service errors only)
# npmmirror.com (see NPM_CONFIG_REGISTRY above) doesn't implement
# npm's bulk security-advisories endpoint, so this always reports
# "audit endpoint returned an error" here even though installs
# work fine. The real audit still runs on GitHub-hosted runners
# against registry.npmjs.org (.github/workflows/qa.yml), so don't
# fail the whole Gitea pipeline over a check this runner can't
# physically perform.
continue-on-error: true
timeout-minutes: 4
shell: bash
run: |
set -euo pipefail
report="$(mktemp)"
trap 'rm -f "$report"' EXIT
for attempt in 1 2 3; do
set +e
npm audit --audit-level=high --loglevel=verbose > "$report" 2>&1
status=$?
set -e
cat "$report"
if [ "$status" -eq 0 ]; then exit 0; fi
# An actual vulnerability report fails immediately. A failed
# registry response is retried, never accepted as a clean audit.
if ! grep -Eq 'audit endpoint returned an error|ENOTFOUND|ECONNRESET|EAI_AGAIN|ETIMEDOUT|E429|E503' "$report"; then
exit "$status"
fi
if [ "$attempt" -eq 3 ]; then exit "$status"; fi
echo "Audit service unavailable; retry $attempt/3 after a delay."
sleep "$((attempt * 20))"
done
exit 1
- run: npm run check:deploy
- name: Install PHP CLI
# The self-hosted act_runner image (unlike GitHub's ubuntu-latest)
# ships without PHP at all, so the lint/proxy steps below fail with
# "php: command not found" unless we install it first. php-curl is
# needed too: tests/proxy-http.php calls CURLOPT_* constants, which
# the bare php-cli package doesn't define.
run: |
apt-get update -y
apt-get install -y --no-install-recommends php-cli php-curl
- run: php -l public/api/index.php && php -l ops/timeweb/api-proxy.php
- run: php tests/proxy-http.php app && php tests/proxy-http.php api
- run: npx playwright install --with-deps chromium webkit
- name: Run e2e tests
# This VPS (2 CPU / 4GB) also runs Gitea + Postgres + Caddy. Playwright's
# default worker count (one per CPU, all projects in parallel) spins up
# chromium and webkit processes concurrently and has twice now driven the
# host into an unresponsive state (server needed a hard reboot) partway
# through this step. --workers=1 runs the same test suite serially
# instead of in parallel — slower, but it keeps peak memory/CPU to one
# browser process at a time. GitHub-hosted runners have real headroom,
# so .github/workflows/qa.yml keeps full parallelism.
run: npx playwright test --config=tests/playwright.config.mjs --workers=1

View File

@ -45,9 +45,7 @@ jobs:
app-runtime.js app-runtime.js
core/hotfix-v1763.js core/hotfix-v1763.js
core/trial-promo-developer-v181.js core/trial-promo-developer-v181.js
core/single-item-pdf.js
core/account-center-v1780.js core/account-center-v1780.js
core/auth-security-v1774.js
core/company-branding.js core/company-branding.js
core/sun-safe.js core/sun-safe.js
core/login-signature-v1776.js core/login-signature-v1776.js

View File

@ -11,7 +11,7 @@
"serverReady": true, "serverReady": true,
"workspaceAutoDiscovery": true, "workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false, "invitesTemporarilyDisabled": false,
"pwaCache": "v111-20260918-ui-stability", "pwaCache": "v110-20260918-ui-stability",
"fullOfferDescriptions": true, "fullOfferDescriptions": true,
"dynamicOfferRows": true, "dynamicOfferRows": true,
"pdfOfferDescriptionFix": true, "pdfOfferDescriptionFix": true,

View File

@ -1,39 +0,0 @@
{
"note": "Photos for the 34 training-catalog items that previously had none (banquet-v1 dishes, extras-v1 sets/supplies/delivery). Sourced from Pexels (free license, no attribution required) by search + manual visual review; two items (banquet-caprese, banquet-roastbeef) were AI-generated instead. Files live in public/demo/images/<file>.webp, 1024x1024, resized/converted with sharp. Regenerate the catalog JSON with ops/demo/build-trial-banquet.mjs and ops/demo/build-trial-extras.mjs after editing the *-data.mjs sources.",
"items": [
{"itemId": "demo-banquet-v1-caprese", "file": "banquet-caprese", "source": "ai-generated"},
{"itemId": "demo-banquet-v1-roastbeef", "file": "banquet-roastbeef", "source": "ai-generated"},
{"itemId": "demo-banquet-v1-salmon-roll", "file": "banquet-salmon-roll", "source": "pexels"},
{"itemId": "demo-banquet-v1-hummus", "file": "banquet-hummus", "source": "pexels"},
{"itemId": "demo-banquet-v1-caesar", "file": "banquet-caesar", "source": "pexels"},
{"itemId": "demo-banquet-v1-olivier", "file": "banquet-olivier", "source": "pexels"},
{"itemId": "demo-banquet-v1-greek", "file": "banquet-greek", "source": "pexels"},
{"itemId": "demo-banquet-v1-julienne", "file": "banquet-julienne", "source": "pexels"},
{"itemId": "demo-banquet-v1-stuffed-mushrooms", "file": "banquet-stuffed-mushrooms", "source": "pexels"},
{"itemId": "demo-banquet-v1-chicken", "file": "banquet-chicken", "source": "pexels"},
{"itemId": "demo-banquet-v1-cod", "file": "banquet-cod", "source": "pexels"},
{"itemId": "demo-banquet-v1-beef-hot", "file": "banquet-beef-hot", "source": "pexels"},
{"itemId": "demo-banquet-v1-mash", "file": "banquet-mash", "source": "pexels"},
{"itemId": "demo-banquet-v1-rice", "file": "banquet-rice", "source": "pexels"},
{"itemId": "demo-banquet-v1-berry-cream", "file": "banquet-berry-cream", "source": "pexels"},
{"itemId": "demo-banquet-v1-cheese-honey", "file": "banquet-cheese-honey", "source": "pexels"},
{"itemId": "demo-banquet-v1-fruit", "file": "banquet-fruit", "source": "pexels"},
{"itemId": "demo-banquet-v1-bread", "file": "banquet-bread", "source": "pexels"},
{"itemId": "demo-extras-v1-salmon-caprese", "file": "extras-salmon-caprese", "source": "pexels"},
{"itemId": "demo-extras-v1-meat-cheese", "file": "extras-meat-cheese", "source": "pexels"},
{"itemId": "demo-extras-v1-mini-buffet", "file": "extras-mini-buffet", "source": "pexels"},
{"itemId": "demo-extras-v1-water", "file": "extras-water", "source": "pexels"},
{"itemId": "demo-extras-v1-sparkling", "file": "extras-sparkling", "source": "pexels"},
{"itemId": "demo-extras-v1-juice", "file": "extras-juice", "source": "pexels"},
{"itemId": "demo-extras-v1-mors", "file": "extras-mors", "source": "pexels"},
{"itemId": "demo-extras-v1-plate", "file": "extras-plate", "source": "pexels"},
{"itemId": "demo-extras-v1-fork", "file": "extras-fork", "source": "pexels"},
{"itemId": "demo-extras-v1-glass", "file": "extras-glass", "source": "pexels"},
{"itemId": "demo-extras-v1-napkin", "file": "extras-napkin", "source": "pexels"},
{"itemId": "demo-extras-v1-ice", "file": "extras-ice", "source": "pexels"},
{"itemId": "demo-extras-v1-tablecloth", "file": "extras-tablecloth", "source": "pexels"},
{"itemId": "demo-extras-v1-serving-kit", "file": "extras-serving-kit", "source": "pexels"},
{"itemId": "demo-extras-v1-delivery-city", "file": "extras-delivery-city", "source": "pexels"},
{"itemId": "demo-extras-v1-delivery-outer", "file": "extras-delivery-outer", "source": "pexels"}
]
}

View File

@ -68,7 +68,7 @@ const recipes=[
export const banquet=recipes.map(([key,name,catalogSection,price,quantities,allergens,steps,waterGrams=0],index)=>{ export const banquet=recipes.map(([key,name,catalogSection,price,quantities,allergens,steps,waterGrams=0],index)=>{
const rows=quantities.map(([key,gross,net])=>{const p=products.get(key);return {productId:p.id,name:p.name,unit:p.unit,gross:gross/1000,net:net/1000,unitCost:p.cost}}); const rows=quantities.map(([key,gross,net])=>{const p=products.get(key);return {productId:p.id,name:p.name,unit:p.unit,gross:gross/1000,net:net/1000,unitCost:p.cost}});
const outputGrams=Math.round(rows.reduce((sum,r)=>sum+r.net*1000,waterGrams)); const outputGrams=Math.round(rows.reduce((sum,r)=>sum+r.net*1000,waterGrams));
return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:`demo/images/banquet-${key}.webp`,demo:true, return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:'',demo:true,
composition:[rows.map(r=>r.name).join(', ')],ingredients:rows.map(r=>[r.name,r.gross,r.unit]), composition:[rows.map(r=>r.name).join(', ')],ingredients:rows.map(r=>[r.name,r.gross,r.unit]),
ttk:{number:`ДЕМО-Б${String(index+1).padStart(2,'0')}`,basis:'На 1 порцию / 1 гостя',outputGrams,waterGrams,pieces:1,rows,steps,allergens, ttk:{number:`ДЕМО-Б${String(index+1).padStart(2,'0')}`,basis:'На 1 порцию / 1 гостя',outputGrams,waterGrams,pieces:1,rows,steps,allergens,
ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100, ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100,

View File

@ -10,7 +10,7 @@ const premium=mixes.map(([key,name,price,pieces,sourceIds],index)=>{
const sources=sourceIds.map(id=>boxes.find(b=>b.id===`demo-v1-${id}`)),map=new Map(); const sources=sourceIds.map(id=>boxes.find(b=>b.id===`demo-v1-${id}`)),map=new Map();
for(const b of sources)for(const r of b.ttk.rows){const old=map.get(r.productId)||{...r,gross:0,net:0};old.gross=round(old.gross+r.gross/2);old.net=round(old.net+r.net/2);map.set(r.productId,old)} for(const b of sources)for(const r of b.ttk.rows){const old=map.get(r.productId)||{...r,gross:0,net:0};old.gross=round(old.gross+r.gross/2);old.net=round(old.net+r.net/2);map.set(r.productId,old)}
const rows=[...map.values()],outputGrams=Math.round(rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0)); const rows=[...map.values()],outputGrams=Math.round(rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0));
return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:`demo/images/extras-${key}.webp`,demo:true, return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:'',demo:true,
composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]), composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]),
ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows, ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows,
steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'], steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'],
@ -31,6 +31,6 @@ const specs=[
['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи'] ['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи']
]; ];
const stock=specs.map(([key,category,name,price,productName,cost,unit,supplier])=>({id:`demo-extras-v1-stock-${key}`,name:productName,cost,unit,qty:0,min:unit==='кг'?1:10,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0})); const stock=specs.map(([key,category,name,price,productName,cost,unit,supplier])=>({id:`demo-extras-v1-stock-${key}`,name:productName,cost,unit,qty:0,min:unit==='кг'?1:10,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0}));
const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:`demo/images/extras-${key}.webp`,composition:[description],ingredients:[[productName,1,unit]],demo:true})); const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:'',composition:[description],ingredients:[[productName,1,unit]],demo:true}));
const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:`demo/images/extras-delivery-${key}.webp`,composition:[note],ingredients:[],demo:true})); const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:'',composition:[note],ingredients:[],demo:true}));
export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock}; export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock};

View File

@ -1,169 +0,0 @@
-- Caterium v17.9.1
-- Public owners may register without a promo code. No-promo workspaces are
-- permanently limited to the Basic plan. A valid promo keeps the existing
-- server-enforced trial plan and duration.
begin;
create or replace function public.caterium_public_signup_policy()
returns jsonb
language sql
stable
security invoker
set search_path = public
as $function$
select jsonb_build_object(
'promo_optional', true,
'default_plan', 'basic'
);
$function$;
create or replace function public.sun_create_workspace(p_name text default 'Новая компания'::text)
returns uuid
language plpgsql
security definer
set search_path = public, auth, pg_temp
as $function$
declare
v_user uuid:=auth.uid();
v_workspace uuid;
v_name text:='Новая компания';
v_email text;
v_profile jsonb;
v_storage jsonb;
v_code text;
v_has_promo boolean:=false;
v_promo public.caterium_trial_promos%rowtype;
v_trial_end timestamptz;
begin
if v_user is null then raise exception 'Authentication required'; end if;
if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then
raise exception 'Подтвердите email';
end if;
if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
raise exception 'Аккаунт уже относится к компании';
end if;
select lower(email),public.caterium_normalize_trial_code(raw_user_meta_data->>'promo_code')
into v_email,v_code
from auth.users
where id=v_user;
v_has_promo:=coalesce(v_code,'')<>'';
if v_has_promo then
if exists(select 1 from public.caterium_trial_redemptions where user_id=v_user) then
raise exception 'Пробный период для этого аккаунта уже использован';
end if;
select * into v_promo
from public.caterium_trial_promos
where code=v_code
for update;
if not found then raise exception 'Промокод не найден'; end if;
if not v_promo.is_active then raise exception 'Промокод отключён'; end if;
if v_promo.valid_until is not null and v_promo.valid_until<=now() then raise exception 'Срок действия промокода истёк'; end if;
if v_promo.use_count>=v_promo.max_uses then raise exception 'Промокод уже использован'; end if;
if v_promo.client_email is not null and lower(v_promo.client_email)<>v_email then raise exception 'Промокод предназначен для другого email'; end if;
v_trial_end:=now()+make_interval(days=>v_promo.trial_days);
end if;
insert into public.sun_workspaces(name,created_by)
values(v_name,v_user)
returning id into v_workspace;
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
values(
v_workspace,
v_user,
'admin',
coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce(v_email,'Администратор'),'@',1)),
true,
public.sun_role_default_permissions('admin')
);
v_profile:=jsonb_build_object(
'name','','shortName','','logo','','tagline','','city','','phone','','email',coalesce(v_email,''),
'website','','address','','legalName','','inn','','kpp','','ogrn','','legalAddress','',
'bank','','bik','','account','','corrAccount','','legacyLocked',false
);
v_storage:=jsonb_build_object('sunCompanyProfileV1',v_profile::text);
insert into public.sun_app_state(workspace_id,payload,client_id)
values(v_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',v_storage),'bootstrap')
on conflict(workspace_id) do nothing;
if v_has_promo then
insert into public.sun_workspace_subscriptions(
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
)
values(
v_workspace,v_promo.plan_id,'trialing',now(),v_trial_end,null,null,v_trial_end+interval '7 days','promo_trial','Trial by promo '||v_promo.code
)
on conflict(workspace_id) do update
set plan_id=excluded.plan_id,
status=excluded.status,
trial_started_at=excluded.trial_started_at,
trial_ends_at=excluded.trial_ends_at,
current_period_start=null,
current_period_end=null,
grace_until=excluded.grace_until,
source=excluded.source,
note=excluded.note,
updated_at=now();
insert into public.caterium_trial_redemptions(promo_id,workspace_id,user_id,email,trial_ends_at)
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end);
update public.caterium_trial_promos
set use_count=use_count+1,updated_at=now()
where id=v_promo.id;
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
values(
v_user,'trial_promo.redeem',v_workspace,v_user,
jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)
);
else
insert into public.sun_workspace_subscriptions(
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
)
values(
v_workspace,'basic','active',null,null,now(),null,null,'public_signup_basic','Basic access without promo'
)
on conflict(workspace_id) do update
set plan_id='basic',
status='active',
trial_started_at=null,
trial_ends_at=null,
current_period_start=now(),
current_period_end=null,
grace_until=null,
source='public_signup_basic',
note='Basic access without promo',
updated_at=now();
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
values(
v_user,'public_signup.basic',v_workspace,v_user,
jsonb_build_object('plan','basic','promo',false)
);
end if;
update auth.users
set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name'
where id=v_user;
return v_workspace;
end;
$function$;
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;
revoke all on function public.sun_create_workspace(text) from public,anon;
grant execute on function public.sun_create_workspace(text) to authenticated,service_role;
commit;

View File

@ -4,7 +4,7 @@
"version": "17.7.3", "version": "17.7.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:syntax": "node --check public/core/catalog-pricing.js && 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/single-item-pdf.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 && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.js", "check:syntax": "node --check public/core/catalog-pricing.js && 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 && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js && node --check public/core/proposal-layout.js && node --check public/core/mobile-order.js && node --check public/core/help-center.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 && node tests/client-sync-v1780.mjs", "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 && node tests/client-sync-v1780.mjs",
"check:release": "node tests/release-check.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", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",

View File

@ -252,7 +252,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
.sun-discount-panel label>span{display:block;color:#68717a;font-size:12px;margin-bottom:5px} .sun-discount-panel label>span{display:block;color:#68717a;font-size:12px;margin-bottom:5px}
.sun-order-summary-discount{color:#8a5b00} .sun-order-summary-discount{color:#8a5b00}
.sun-order-summary-net{font-weight:800;color:#15364c} .sun-order-summary-net{font-weight:800;color:#15364c}
.sun-order-summary-promo small{color:#7b858d;margin-left:5px}.sun-order-line-sum{display:flex;align-items:center;justify-content:flex-end;gap:8px}.sun-order-line-delete{width:28px;height:28px;flex:0 0 28px;border:1px solid #e2b8b8;border-radius:8px;background:#fff7f7;color:#a83535;font-size:19px;line-height:1;cursor:pointer}.sun-order-line-delete:hover{background:#ffeaea;border-color:#d98d8d} .sun-order-summary-promo small{color:#7b858d;margin-left:5px}
.sun-doc-qr{width:92px;height:92px;object-fit:contain;border:1px solid #e0e3e5;border-radius:7px;padding:4px;background:#fff} .sun-doc-qr{width:92px;height:92px;object-fit:contain;border:1px solid #e0e3e5;border-radius:7px;padding:4px;background:#fff}
.sun-doc-title-with-qr{display:flex;align-items:flex-start;gap:14px;justify-content:flex-end} .sun-doc-title-with-qr{display:flex;align-items:flex-start;gap:14px;justify-content:flex-end}
.sun-doc-title-with-qr .sun-doc-title{text-align:right} .sun-doc-title-with-qr .sun-doc-title{text-align:right}
@ -429,11 +429,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
function renderOrderLines(){ function renderOrderLines(){
if(!$('lines'))return; if(!$('lines'))return;
if(!(draft.lines||[]).length){$('lines').innerHTML='<p class="empty">Выберите позиции из каталога.</p>';return} if(!(draft.lines||[]).length){$('lines').innerHTML='<p class="empty">Выберите позиции из каталога.</p>';return}
const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty-1})"></button><input type="number" min="1" value="${qty}" onchange="qty(${SunSafe.jsArg(line.id)},+this.value)"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty+1})"></button></div><div class="sun-order-line-price"><input type="text" inputmode="decimal" autocomplete="off" value="${price}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b><button class="sun-order-line-delete" type="button" onclick="sunRemoveOrderLine(${index})" aria-label="Удалить позицию" title="Удалить позицию">×</button></div></div>`}).join(''); const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `<div class="sun-order-line-row" data-line-index="${index}"><div class="sun-order-line-name" title="${esc(item.name)}">${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`<small>${item.weight?`<b>Вес:</b> ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`<b>Количество:</b> ${inferBoxPieces(item)} шт.`:''}</small>`:''}</div><div class="sun-order-line-qty"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty-1})"></button><input type="number" min="1" value="${qty}" onchange="qty(${SunSafe.jsArg(line.id)},+this.value)"><button type="button" onclick="qty(${SunSafe.jsArg(line.id)},${qty+1})"></button></div><div class="sun-order-line-price"><input type="text" inputmode="decimal" autocomplete="off" value="${price}" onchange="sunOrderLinePriceChanged(${index},this)"></div><div class="sun-order-line-sum"><b>${money(price*qty)}</b></div></div>`}).join('');
$('lines').innerHTML=`<div class="sun-order-lines-table"><div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div><div class="sun-order-line-body">${rows}</div><div class="sun-order-line-total"><span>Стоимость позиций</span><b>${money(baseTotal(draft))}</b></div></div>`; $('lines').innerHTML=`<div class="sun-order-lines-table"><div class="sun-order-line-head"><span>Наименование</span><span>Кол.</span><span>Цена</span><span>Стоимость</span></div><div class="sun-order-line-body">${rows}</div><div class="sun-order-line-total"><span>Стоимость позиций</span><b>${money(baseTotal(draft))}</b></div></div>`;
} }
window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;const value=Number(String(input.value||0).replace(',','.'));if(!Number.isFinite(value)){input.value=String(lineUnitPrice(line));return;}line.price=Math.max(0,Math.round(value*100)/100);const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;const value=Number(String(input.value||0).replace(',','.'));if(!Number.isFinite(value)){input.value=String(lineUnitPrice(line));return;}line.price=Math.max(0,Math.round(value*100)/100);const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();};
window.sunRemoveOrderLine=(index)=>{if(!Array.isArray(draft.lines))return false;const i=Number(index);if(!Number.isInteger(i)||i<0||i>=draft.lines.length)return false;draft.lines.splice(i,1);renderCatalogV5();updateOrderSummary();return true;};
function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null} function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null}
function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null} function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null}
function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)}
@ -660,17 +659,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `<section class="sun-receipt-party"><h3>${esc(title)}</h3><div class="sun-receipt-party-grid">${list.map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div></section>`} function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `<section class="sun-receipt-party"><h3>${esc(title)}</h3><div class="sun-receipt-party-grid">${list.map(([k,v])=>`<div><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join('')}</div></section>`}
// ---------------- Order blank + compact receipt ---------------- // ---------------- Order blank + compact receipt ----------------
function customerVisibleOrderNote(value){return String(value||'').split(/\r?\n/).filter(line=>{const text=line.trim();if(!text)return false;return !/^(?:заказ\s+из\s+telegram|импорт(?:ирован)?(?:о|ный)?\s+из\s+telegram|источник\s*[:—-]?\s*telegram|source\s*[:—-]?\s*telegram)\b/i.test(text)}).join('\n').trim()}
function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5,6].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)} function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5,6].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)}
const docDate=raw=>{if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')}; const docDate=raw=>{if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')};
const docActions=()=>`<div class="sun-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>`; const docActions=()=>`<div class="sun-doc-actions"><button class="primary" type="button" onclick="window.print()">Печать / PDF</button><button class="outline" type="button" onclick="closeModal('preview')">Закрыть</button></div>`;
function showOrderBlank(){ function showOrderBlank(){
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim(),visibleNote=customerVisibleOrderNote($('note')?.value); captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim();
dialog.innerHTML=`<div class="sun-doc"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title-with-qr"><div class="sun-doc-title"><h2>Заказ № ${esc(number)}</h2><p>${esc($('event')?.value||'Новое мероприятие')}</p>${draft.status?`<span class="sun-doc-badge">${esc(draft.status)}</span>`:''}</div>${qr?`<div class="sun-doc-qr-wrap"><img class="sun-doc-qr" src="${qr}" alt="QR-код"><div id="sunBlankQrText" class="sun-doc-qr-note" contenteditable="true" spellcheck="true" data-placeholder="Нажмите и добавьте текст рядом с QR">${esc(qrNote)}</div></div>`:''}</div></div><div class="sun-doc-grid"><div class="sun-doc-field"><span>Клиент</span><b>${esc($('contact')?.value||'—')}</b></div><div class="sun-doc-field"><span>Телефон</span><b>${esc($('phone')?.value||'—')}</b></div><div class="sun-doc-field"><span>Дата и время выдачи</span><b>${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}</b></div><div class="sun-doc-field"><span>Адрес доставки</span><b>${esc($('address')?.value||'—')}</b></div>${b.name?`<div class="sun-doc-field"><span>Компания покупателя</span><b>${esc(b.name)}</b></div>`:''}</div><table><thead><tr><th class="doc-num">№</th><th class="doc-name">Позиция</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name"><b>${esc(r.name)}</b><span class="doc-sub">${esc(r.category)}</span>${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td><b>${money(r.sum)}</b></td></tr>`).join(''):'<tr><td colspan="5">В заказе пока нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b> ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b> ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Предоплата</span><b>${money(p.paid)}</b></p><p class="grand"><span>Остаток к оплате</span><span>${money(p.balance)}</span></p></div>${visibleNote?`<div class="sun-doc-note"><b>Комментарий к заказу</b>${esc(visibleNote)}</div>`:''}<div class="sun-doc-footer"><div><b>${window.CateriumBranding.nameHTML()}</b><br>Спасибо, что выбрали нас.<div class="sun-doc-sign">Ответственный / подпись</div></div><div>Заказчик подтверждает состав заказа, дату, время и адрес выдачи.<div class="sun-doc-sign">Заказчик / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);} dialog.innerHTML=`<div class="sun-doc"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${window.CateriumBranding.nameHTML()}</strong><small>${window.CateriumBranding.cityHTML()}</small></div></div><div class="sun-doc-title-with-qr"><div class="sun-doc-title"><h2>Заказ № ${esc(number)}</h2><p>${esc($('event')?.value||'Новое мероприятие')}</p>${draft.status?`<span class="sun-doc-badge">${esc(draft.status)}</span>`:''}</div>${qr?`<div class="sun-doc-qr-wrap"><img class="sun-doc-qr" src="${qr}" alt="QR-код"><div id="sunBlankQrText" class="sun-doc-qr-note" contenteditable="true" spellcheck="true" data-placeholder="Нажмите и добавьте текст рядом с QR">${esc(qrNote)}</div></div>`:''}</div></div><div class="sun-doc-grid"><div class="sun-doc-field"><span>Клиент</span><b>${esc($('contact')?.value||'—')}</b></div><div class="sun-doc-field"><span>Телефон</span><b>${esc($('phone')?.value||'—')}</b></div><div class="sun-doc-field"><span>Дата и время выдачи</span><b>${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}</b></div><div class="sun-doc-field"><span>Адрес доставки</span><b>${esc($('address')?.value||'—')}</b></div>${b.name?`<div class="sun-doc-field"><span>Компания покупателя</span><b>${esc(b.name)}</b></div>`:''}</div><table><thead><tr><th class="doc-num">№</th><th class="doc-name">Позиция</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name"><b>${esc(r.name)}</b><span class="doc-sub">${esc(r.category)}</span>${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td><b>${money(r.sum)}</b></td></tr>`).join(''):'<tr><td colspan="5">В заказе пока нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b> ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b> ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Предоплата</span><b>${money(p.paid)}</b></p><p class="grand"><span>Остаток к оплате</span><span>${money(p.balance)}</span></p></div>${$('note')?.value?`<div class="sun-doc-note"><b>Комментарий к заказу</b>${esc($('note').value)}</div>`:''}<div class="sun-doc-footer"><div><b>${window.CateriumBranding.nameHTML()}</b><br>Спасибо, что выбрали нас.<div class="sun-doc-sign">Ответственный / подпись</div></div><div>Заказчик подтверждает состав заказа, дату, время и адрес выдачи.<div class="sun-doc-sign">Заказчик / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);}
} }
function showReceipt(){ function showReceipt(){
captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog'),visibleNote=customerVisibleOrderNote($('note')?.value);if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]); captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]);
dialog.innerHTML=`<div class="sun-doc sun-receipt"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${esc(s.sellerName||(window.CateriumBranding.documentName()))}</strong><small>${esc(s.sellerLegalName||'')}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${esc(draft.id||'—')}</h2><p>${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}</p></div></div>${seller}${buyer}<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${esc(r.name)}${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td>${money(r.sum)}</td></tr>`).join(''):'<tr><td colspan="5">В заказе нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b> ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b> ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Оплачено</span><b>${money(p.paid)}</b></p><p class="grand"><span>К оплате</span><span>${money(p.balance)}</span></p></div>${visibleNote?`<div class="sun-doc-note"><b>Примечание</b>${esc(visibleNote)}</div>`:''}<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview'); dialog.innerHTML=`<div class="sun-doc sun-receipt"><div class="sun-doc-head"><div class="sun-doc-brand"><img src="${window.CateriumBranding.logoHTML()}" alt="${window.CateriumBranding.nameHTML()}"><div><strong>${esc(s.sellerName||(window.CateriumBranding.documentName()))}</strong><small>${esc(s.sellerLegalName||'')}</small></div></div><div class="sun-doc-title"><h2>Товарный чек № ${esc(draft.id||'—')}</h2><p>${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}</p></div></div>${seller}${buyer}<table><thead><tr><th class="doc-num">№</th><th class="doc-name">Наименование</th><th>Цена</th><th>Кол.</th><th>Сумма</th></tr></thead><tbody>${rows.length?rows.map(r=>`<tr><td class="doc-num">${r.index}</td><td class="doc-name">${esc(r.name)}${(r.weight||r.pieces)?`<span class="doc-sub sun-doc-weight">${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}</span>`:''}</td><td>${money(r.price)}</td><td>${r.qty}</td><td>${money(r.sum)}</td></tr>`).join(''):'<tr><td colspan="5">В заказе нет позиций.</td></tr>'}</tbody></table><div class="sun-doc-summary"><p><span>Стоимость позиций</span><b>${money(p.base)}</b></p>${p.manual?`<p><span>Скидка</span><b> ${money(p.manual)}</b></p>`:''}${p.promo?`<p><span>Промокод ${esc(draft.promoCode||'')}</span><b> ${money(p.promo)}</b></p>`:''}<p><span>Доставка</span><b>${money(p.delivery)}</b></p><p><span>Итого</span><b>${money(p.total)}</b></p><p><span>Оплачено</span><b>${money(p.paid)}</b></p><p class="grand"><span>К оплате</span><span>${money(p.balance)}</span></p></div>${$('note')?.value?`<div class="sun-doc-note"><b>Примечание</b>${esc($('note').value)}</div>`:''}<div class="sun-doc-footer"><div><div class="sun-doc-sign">Продавец / подпись</div></div><div><div class="sun-doc-sign">Покупатель / подпись</div></div></div>${docActions()}</div>`;window.modal?.('preview');
} }
window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank; window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank;
function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});} function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});}
@ -4071,7 +4069,6 @@ window.SUN_LEGACY_CATALOG_V175=[];
try{window.renderStats?.()}catch(_){} try{window.renderStats?.()}catch(_){}
try{window.sunRenderOrderCards?.()}catch(_){} try{window.sunRenderOrderCards?.()}catch(_){}
window.dispatchEvent(new CustomEvent('sun:order-deleted',{detail:{id:order.id}})); window.dispatchEvent(new CustomEvent('sun:order-deleted',{detail:{id:order.id}}));
try{const sync=window.SunCloudV2?.syncNow?.();if(sync&&typeof sync.catch==='function')sync.catch(error=>console.warn('Order delete cloud sync failed',error));}catch(error){console.warn('Order delete cloud sync failed',error)}
window.SunEnterprise?.toast?.(`Заказ №${order.id} удалён.`,'success'); window.SunEnterprise?.toast?.(`Заказ №${order.id} удалён.`,'success');
return true; return true;
}; };
@ -4164,7 +4161,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
.sun-crm-stats-table th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em} .sun-crm-stats-table th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em}
.sun-crm-stats-table td b{color:#241d45} .sun-crm-stats-table td b{color:#241d45}
.sun-lead-settings-list{display:grid;gap:7px;margin:10px 0} .sun-lead-settings-list{display:grid;gap:7px;margin:10px 0}
.sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center} .sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px}
@media(max-width:700px){.sun-crm-stats-table{font-size:10px}.sun-crm-stats-table th,.sun-crm-stats-table td{padding:7px 4px}.sun-crm-hide-mobile{display:none}} @media(max-width:700px){.sun-crm-stats-table{font-size:10px}.sun-crm-stats-table th,.sun-crm-stats-table td{padding:7px 4px}.sun-crm-hide-mobile{display:none}}
`;document.head.appendChild(style); `;document.head.appendChild(style);
@ -4197,7 +4194,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
function ensureSettingsCard(){ function ensureSettingsCard(){
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
let card=$('sunLeadSourcesSettingsCard'); let card=$('sunLeadSourcesSettingsCard');
if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card wide';card.innerHTML=`<h2>Источники клиентов</h2><p class="hint">Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.</p><div class="sun-lead-settings-list" id="sunLeadSourcesRows"></div><div class="actions"><button class="outline" id="sunLeadSourceAdd" type="button">Добавить источник</button><button class="primary" id="sunLeadSourcesSave" type="button">Сохранить список</button></div>`; if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card';card.innerHTML=`<h2>Источники клиентов</h2><p class="hint">Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.</p><div class="sun-lead-settings-list" id="sunLeadSourcesRows"></div><div class="actions"><button class="outline" id="sunLeadSourceAdd" type="button">Добавить источник</button><button class="primary" id="sunLeadSourcesSave" type="button">Сохранить список</button></div>`;
const eventCard=$('sunEventTypesSettingsCard');if(eventCard)eventCard.insertAdjacentElement('afterend',card);else grid.appendChild(card); const eventCard=$('sunEventTypesSettingsCard');if(eventCard)eventCard.insertAdjacentElement('afterend',card);else grid.appendChild(card);
$('sunLeadSourceAdd').onclick=()=>addSourceSettingsRow('');$('sunLeadSourcesSave').onclick=()=>saveSources(qa('#sunLeadSourcesRows input').map(x=>x.value)); $('sunLeadSourceAdd').onclick=()=>addSourceSettingsRow('');$('sunLeadSourcesSave').onclick=()=>saveSources(qa('#sunLeadSourcesRows input').map(x=>x.value));
} }
@ -4618,83 +4615,6 @@ window.SUN_LEGACY_CATALOG_V175=[];
})(); })();
; ;
/* ===== MODULE: order-audit-log.js ===== */
/* Server-side journal of who created, edited or deleted an order, and when.
Written by the database itself (see sun_save_app_state in the 20260922120000
migration), so it can't be spoofed or wiped by whoever made the change
unlike the old per-browser "История изменений" list. Visible to owners and
administrators only. */
(()=>{
'use strict';
if(window.__sunOrderAuditLogCard)return;window.__sunOrderAuditLogCard=true;
const $=id=>document.getElementById(id);
const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=window.SunSafe.escapeHTML;
const cloud=()=>window.SunCloudV2||null;
const client=()=>cloud()?.getClient?.()||null;
const workspace=()=>cloud()?.getWorkspace?.()||null;
const ACTION_LABELS={create:'Создан',update:'Изменён',delete:'Удалён'};
const money=v=>{const n=Number(v);return Number.isFinite(n)?n.toLocaleString('ru-RU')+' ₽':''};
function canSee(){return String(workspace()?.role||'')==='admin'}
function installStyle(){
if($('sunOrderAuditStyle'))return;
const s=document.createElement('style');s.id='sunOrderAuditStyle';s.textContent=`
.sun-order-audit-list{display:grid;gap:0;margin-top:10px;border-top:1px solid #e7eaee}
.sun-order-audit-row{display:grid;grid-template-columns:96px 1fr auto;gap:10px;align-items:baseline;padding:9px 2px;border-bottom:1px dashed #e7eaee;font-size:12.5px}
.sun-order-audit-row small{display:block;color:#8a919b;font-size:10.5px}
.sun-order-audit-row b{color:#243b30}
.sun-order-audit-action{justify-self:end;padding:3px 8px;border-radius:999px;font-size:10.5px;font-weight:800;white-space:nowrap}
.sun-order-audit-action.create{background:#e7f4ea;color:#2f7a45}
.sun-order-audit-action.update{background:#fdf3de;color:#9a7016}
.sun-order-audit-action.delete{background:#fbe7e7;color:#a64040}
@media(max-width:640px){.sun-order-audit-row{grid-template-columns:1fr;gap:2px}.sun-order-audit-action{justify-self:start}}
`;document.head.appendChild(s);
}
function row(item){
const at=new Date(item.created_at);
const when=Number.isNaN(at.getTime())?'':at.toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit',hour:'2-digit',minute:'2-digit'});
const d=item.details||{};
const meta=[d.status?String(d.status):'',Number.isFinite(Number(d.total))?money(d.total):''].filter(Boolean).join(' · ');
return `<div class="sun-order-audit-row"><small>${esc(when)}</small><div><b>${esc(item.summary||('Заказ №'+item.order_id))}</b><small>${esc(item.actor_name||'Сотрудник')}${item.actor_role?` · ${esc(item.actor_role)}`:''}${meta?` · ${esc(meta)}`:''}</small></div><span class="sun-order-audit-action ${esc(item.action)}">${esc(ACTION_LABELS[item.action]||item.action)}</span></div>`;
}
async function load(body){
const c=client(),ws=workspace();
if(!c||!ws?.id){body.innerHTML='<p class="hint">Доступно после входа в облачную рабочую базу.</p>';return}
body.innerHTML='<p class="hint">Загрузка…</p>';
try{
const {data,error}=await c.rpc('sun_list_order_audit',{p_workspace:ws.id,p_limit:200});
if(error)throw error;
const rows=Array.isArray(data)?data:[];
body.innerHTML=rows.length?`<div class="sun-order-audit-list">${rows.map(row).join('')}</div>`:'<p class="hint">Изменений заказов пока нет.</p>';
}catch(e){body.innerHTML=`<p class="hint">Не удалось загрузить журнал: ${esc(e.message||String(e))}</p>`;}
}
function ensureCard(){
if(!canSee()){$('sunOrderAuditLogCard')?.remove();return}
const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;
let card=$('sunOrderAuditLogCard');
if(!card){
installStyle();
card=document.createElement('section');card.className='enterprise-card wide';card.id='sunOrderAuditLogCard';
card.innerHTML='<h2>Журнал заказов</h2><p class="hint">Кто и когда создал, изменил или удалил заказ — включая сотрудников с доступом к заказам. Запись ведётся на сервере, её нельзя стереть из браузера.</p><div class="actions"><button class="outline" id="sunOrderAuditRefresh" type="button">Обновить</button></div><div id="sunOrderAuditBody"></div>';
const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');
window.SunSafe.insertBefore(grid,card,audit||null);
$('sunOrderAuditRefresh').onclick=()=>load($('sunOrderAuditBody'));
}
const body=$('sunOrderAuditBody');if(body&&!body.dataset.loaded){body.dataset.loaded='1';load(body)}
}
document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(ensureCard,90)},true);
const host=$('enterprise-settings');if(host)new MutationObserver(()=>{if(host.classList.contains('on'))setTimeout(ensureCard,60)}).observe(host,{childList:true,subtree:true});
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(ensureCard,120));
if($('enterprise-settings')?.classList.contains('on'))setTimeout(ensureCard,140);
})();
;
/* ===== MODULE: saas-v16.js ===== */ /* ===== MODULE: saas-v16.js ===== */
/* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */ /* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */
(()=>{ (()=>{
@ -5361,10 +5281,10 @@ window.SUN_LEGACY_CATALOG_V175=[];
const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; const qa=(sel,root=document)=>[...root.querySelectorAll(sel)];
const TAB_KEY='sunSettingsActiveTabV1'; const TAB_KEY='sunSettingsActiveTabV1';
const TABS=[ const TABS=[
{id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи, журнал заказов и история изменений.'}, {id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'},
{id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели, готовые цветовые варианты и цвета оплаты заказов.'}, {id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели и готовые цветовые варианты.'},
{id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'}, {id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'},
{id:'orders',label:'Заказы',hint:'Статусы заказов.'}, {id:'orders',label:'Заказы',hint:'Статусы заказов и цвета состояния оплаты.'},
{id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'}, {id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'},
{id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'} {id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'}
]; ];
@ -5405,14 +5325,14 @@ window.SUN_LEGACY_CATALOG_V175=[];
function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()} function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()}
function classify(card){ function classify(card){
const id=String(card?.id||''),h=heading(card); const id=String(card?.id||''),h=heading(card);
if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||id==='sunOrderAuditLogCard'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|Журнал заказов|История изменений)$/.test(h)){ if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){
const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:id==='sunOrderAuditLogCard'||h==='Журнал заказов'?80:90; const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90;
return['account',order]; return['account',order];
} }
if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10]; if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10];
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['appearance',20];
if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10]; if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10];
if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20]; if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20];
if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['orders',10];
if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20]; if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20];
if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10]; if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10];
if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20]; if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20];

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.9.1-public-basic-signup'; const VERSION='17.8.5-single-session-login';
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
let busy=false; let busy=false;
@ -9,26 +9,7 @@
const cloud=()=>window.SunCloudV2||null; const cloud=()=>window.SunCloudV2||null;
const client=()=>cloud()?.getClient?.()||null; const client=()=>cloud()?.getClient?.()||null;
const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??''); const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??'');
const PLAN_LABELS={basic:'Базовый',professional:'Профессиональный',full:'Полный'};
let signupPolicy={checked:false,promoOptional:false,defaultPlan:'basic'},signupPolicyPromise=null;
async function refreshSignupPolicy(c=client(),force=false){
if(signupPolicy.checked&&!force)return signupPolicy;
if(signupPolicyPromise)return signupPolicyPromise;
if(!c)return signupPolicy;
const task=(async()=>{
try{
const {data,error}=await c.rpc('caterium_public_signup_policy');
if(error)throw error;
signupPolicy={checked:true,promoOptional:data?.promo_optional===true,defaultPlan:data?.default_plan==='basic'?'basic':'basic'};
}catch(_){signupPolicy={checked:true,promoOptional:false,defaultPlan:'basic'};}
return signupPolicy;
})();
signupPolicyPromise=task;
try{return await task}finally{if(signupPolicyPromise===task)signupPolicyPromise=null;}
}
function promoHintText(){return signupPolicy.promoOptional?'Без промокода будет создана компания на Базовом тарифе. Промокод может активировать другой тариф и пробный период.':'Для создания новой компании требуется действующий промокод.';}
function renderPromoPolicy(){const label=$('sunGatePromoLabelTextV181'),hint=$('sunGatePromoHintV181');if(label)label.textContent=signupPolicy.promoOptional?'Промокод — необязательно':'Промокод пробной версии';if(hint&&!String($('sunGatePromoV181')?.value||'').trim())hint.textContent=promoHintText();}
function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}} function redirectUrl(){try{const u=new URL(location.href);u.hash='';return u.toString();}catch(_){return location.href.split('#')[0];}}
function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}} function savePendingRegistration(email,promoCode){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:'Новая компания',promoCode:String(promoCode||'').trim().toUpperCase(),createdAt:new Date().toISOString()}));}catch(_){}}
@ -42,20 +23,16 @@
const fields=$('sunGateRegisterFieldsV27');if(!fields)return; const fields=$('sunGateRegisterFieldsV27');if(!fields)return;
const company=$('sunGateCompanyV3');if(company?.parentElement)company.parentElement.remove(); const company=$('sunGateCompanyV3');if(company?.parentElement)company.parentElement.remove();
if(!$('sunGatePromoV181')){ if(!$('sunGatePromoV181')){
const label=document.createElement('label');label.id='sunGatePromoLabelV181';label.innerHTML='<span id="sunGatePromoLabelTextV181">Промокод пробной версии</span><input id="sunGatePromoV181" autocomplete="off" maxlength="32" placeholder="Например, CTM-A7K4P9"><small id="sunGatePromoHintV181" style="display:block;margin-top:5px;opacity:.72">Проверяю условия регистрации…</small>'; const label=document.createElement('label');label.id='sunGatePromoLabelV181';label.innerHTML='<span>Промокод пробной версии</span><input id="sunGatePromoV181" autocomplete="off" maxlength="32" placeholder="Например, CTM-A7K4P9"><small id="sunGatePromoHintV181" style="display:block;margin-top:5px;opacity:.72">Промокод создаёт компанию и активирует пробный период.</small>';
fields.appendChild(label); fields.appendChild(label);
const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');if(!input.value)renderPromoPolicy();}); const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');});
input?.addEventListener('blur',async()=>{const code=String(input.value||'').trim(),email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),h=$('sunGatePromoHintV181');if(!h)return;if(!code){await refreshSignupPolicy();renderPromoPolicy();return;}try{const c=client();if(!c)return;const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email||null});if(error)throw error;const label=PLAN_LABELS[data?.plan]||data?.plan||'тариф по промокоду';h.textContent=data?.valid?`Промокод принят · ${label} · ${Number(data.trial_days||14)} дней`:(data?.reason||'Промокод недействителен');}catch(_){h.textContent='Не удалось проверить промокод.';}}); input?.addEventListener('blur',async()=>{const code=String(input.value||'').trim(),email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),h=$('sunGatePromoHintV181');if(!code||!h)return;try{const c=client();if(!c)return;const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email||null});if(error)throw error;h.textContent=data?.valid?`Промокод принят · ${Number(data.trial_days||14)} дней пробного доступа`:(data?.reason||'Промокод недействителен');}catch(_){h.textContent='Не удалось проверить промокод.';}});
} }
void refreshSignupPolicy().then(renderPromoPolicy);
} }
async function validatePromo(c,email,code){ async function validatePromo(c,email,code){if(!code)throw new Error('Введите промокод пробной версии Caterium.');const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email});if(error)throw error;if(!data?.valid)throw new Error(data?.reason||'Промокод недействителен.');return data;}
if(!code){const policy=await refreshSignupPolicy(c);if(policy.promoOptional)return {valid:true,plan:'basic',withoutPromo:true};throw new Error('Введите промокод пробной версии Caterium.');}
const {data,error}=await c.rpc('caterium_trial_promo_preview',{p_code:code,p_email:email});if(error)throw error;if(!data?.valid)throw new Error(data?.reason||'Промокод недействителен.');return data;
}
function showPublicConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Регистрация Caterium</div></div></div><p class="hint">Мы отправили письмо на <b>${esc(email)}</b>. После подтверждения войдите в Caterium — компания и доступ будут созданы автоматически. Без промокода активируется Базовый тариф; с промокодом — тариф и срок по условиям кода. Название компании вы укажете в настройках.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunAuthGoLoginV1774" type="button">Перейти ко входу</button></div></div>`;gate.querySelector('#sunAuthGoLoginV1774')?.addEventListener('click',()=>location.reload(),{once:true});} function showPublicConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Регистрация Caterium</div></div></div><p class="hint">Мы отправили письмо на <b>${esc(email)}</b>. После подтверждения войдите в Caterium — компания и пробный период будут созданы автоматически. Название компании вы укажете в настройках.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunAuthGoLoginV1774" type="button">Перейти ко входу</button></div></div>`;gate.querySelector('#sunAuthGoLoginV1774')?.addEventListener('click',()=>location.reload(),{once:true});}
function showInviteConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Приглашение сохранено</div></div></div><p class="hint">Письмо подтверждения отправлено на <b>${esc(email)}</b>. После подтверждения войдите по ссылке приглашения — Caterium автоматически подключит вас к компании.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunInviteReloadV1774" type="button">Я подтвердил email</button></div></div>`;gate.querySelector('#sunInviteReloadV1774')?.addEventListener('click',()=>location.reload(),{once:true});} function showInviteConfirmation(gate,email){if(!gate)return;gate.innerHTML=`<div class="sun-cloud-auth-card"><div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Подтвердите email</h2><div class="hint">Приглашение сохранено</div></div></div><p class="hint">Письмо подтверждения отправлено на <b>${esc(email)}</b>. После подтверждения войдите по ссылке приглашения — Caterium автоматически подключит вас к компании.</p><div class="sun-cloud-auth-actions"><button class="primary" id="sunInviteReloadV1774" type="button">Я подтвердил email</button></div></div>`;gate.querySelector('#sunInviteReloadV1774')?.addEventListener('click',()=>location.reload(),{once:true});}
async function publicLogin(gate){ async function publicLogin(gate){
@ -76,12 +53,11 @@
if(busy)return;const c=client();if(!c){setError(gate,'Облачный сервис не подключён.');return;} if(busy)return;const c=client();if(!c){setError(gate,'Облачный сервис не подключён.');return;}
const email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),password=String($('sunGatePasswordV3')?.value||''),password2=String($('sunGatePassword2V27')?.value||''),promoCode=String($('sunGatePromoV181')?.value||'').trim().toUpperCase(); const email=String($('sunGateEmailV3')?.value||'').trim().toLowerCase(),password=String($('sunGatePasswordV3')?.value||''),password2=String($('sunGatePassword2V27')?.value||''),promoCode=String($('sunGatePromoV181')?.value||'').trim().toUpperCase();
if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;}if(password!==password2){setError(gate,'Пароли не совпадают.');return;} if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;}if(password!==password2){setError(gate,'Пароли не совпадают.');return;}
busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,promoCode?'Проверяю промокод…':'Проверяю Базовый тариф…'); busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Проверяю промокод…');
try{ try{
await validatePromo(c,email,promoCode);savePendingRegistration(email,promoCode);setError(gate,'Создаю аккаунт…'); await validatePromo(c,email,promoCode);savePendingRegistration(email,promoCode);setError(gate,'Создаю аккаунт…');
const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data?.session){location.reload();return;} const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data?.session){location.reload();return;}
const signupData=promoCode?{promo_code:promoCode}:{}; const result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:{promo_code:promoCode}}});if(result.error)throw result.error;
const result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:signupData}});if(result.error)throw result.error;
if(result.data?.session){await signOutUnsafeSession(c);throw new Error('Подтверждение email не включено на сервере. Регистрация остановлена.');} if(result.data?.session){await signOutUnsafeSession(c);throw new Error('Подтверждение email не включено на сервере. Регистрация остановлена.');}
showPublicConfirmation(gate,email); showPublicConfirmation(gate,email);
}catch(error){clearPendingRegistration();setError(gate,friendlySignupError(error));if(button)button.disabled=false;}finally{busy=false;} }catch(error){clearPendingRegistration();setError(gate,friendlySignupError(error));if(button)button.disabled=false;}finally{busy=false;}
@ -97,11 +73,11 @@
} }
async function finishOwnerOnboarding(gate){ async function finishOwnerOnboarding(gate){
if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.email)return false; if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.promoCode)return false;
const session=api.getSession?.();const email=String(session?.user?.email||'').trim().toLowerCase();if(!email||email!==String(pending.email||'').toLowerCase())return false; const session=api.getSession?.();const email=String(session?.user?.email||'').trim().toLowerCase();if(!email||email!==String(pending.email||'').toLowerCase())return false;
busy=true;setError(gate,'Подключаю рабочую базу…'); busy=true;setError(gate,'Подключаю рабочую базу…');
try{ try{
const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode||null}; const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode};
const updated=await c.auth.updateUser({data:metadata});if(updated.error)throw updated.error; const updated=await c.auth.updateUser({data:metadata});if(updated.error)throw updated.error;
const created=await c.rpc('sun_create_workspace',{p_name:'Новая компания'});if(created.error)throw created.error; const created=await c.rpc('sun_create_workspace',{p_name:'Новая компания'});if(created.error)throw created.error;
clearPendingRegistration();await api.reloadMemberships?.();location.reload();return true; clearPendingRegistration();await api.reloadMemberships?.();location.reload();return true;
@ -114,7 +90,7 @@
const recovery=$('sunGateRecoveryCompanyV25'); const recovery=$('sunGateRecoveryCompanyV25');
if(recovery){ if(recovery){
recovery.closest('label')?.remove(); recovery.closest('label')?.remove();
const button=$('sunGateRetryWorkspaceV3');if(button){button.textContent='Проверить доступ';button.onclick=async()=>{setError(gate,'Проверяю доступ…');try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}if(await finishOwnerOnboarding(gate))return;await refreshSignupPolicy();setError(gate,signupPolicy.promoOptional?'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйтесь: без промокода доступен Базовый тариф.':'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании нужен действующий промокод.');}catch(e){setError(gate,e?.message||String(e));}};} const button=$('sunGateRetryWorkspaceV3');if(button){button.textContent='Проверить доступ';button.onclick=async()=>{setError(gate,'Проверяю доступ…');try{const found=await cloud()?.reloadMemberships?.();if(found){location.reload();return;}if(await finishOwnerOnboarding(gate))return;setError(gate,'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйте пробный период по промокоду.');}catch(e){setError(gate,e?.message||String(e));}};}
const title=gate.querySelector('h2');if(title)title.textContent='Нет доступа к компании'; const title=gate.querySelector('h2');if(title)title.textContent='Нет доступа к компании';
const hints=gate.querySelectorAll('.hint');if(hints[1])hints[1].textContent='Этот аккаунт пока не привязан к рабочей компании.'; const hints=gate.querySelectorAll('.hint');if(hints[1])hints[1].textContent='Этот аккаунт пока не привязан к рабочей компании.';
} }

View File

@ -5,12 +5,6 @@
if(window.CateriumBanquetClientMenu)return; if(window.CateriumBanquetClientMenu)return;
const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif'; const W=1000,H=1414,M=64,SANS='"Caterium Menu Sans",Arial,sans-serif',SERIF='"Caterium Menu Serif",Georgia,serif';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
const THEMES={
gold:{label:'Золото',paper:'#fbf8f1',ink:'#243b30',muted:'#657269',accent:'#9b793e',rule:'#dcd5c6',titleAlign:'left'},
noir:{label:'Ночь',paper:'#1c1c20',ink:'#f4f1e9',muted:'#a79f8f',accent:'#c9a24a',rule:'#3c3b3d',titleAlign:'left'},
mono:{label:'Минимал',paper:'#ffffff',ink:'#161616',muted:'#6c6c6c',accent:'#161616',rule:'#e6e6e6',titleAlign:'center'}
};
const themeIds=Object.keys(THEMES);
const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim(); const clean=v=>String(v??'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,'').trim();
const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽'; const money=v=>Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;}; const number=v=>{const n=Number(String(v??'').replace(',','.'));return Number.isFinite(n)?n:0;};
@ -79,17 +73,16 @@
} }
function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});} function dateLabel(value){if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return value;const d=new Date(value+'T12:00:00');return Number.isNaN(d.getTime())?value:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'});}
function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;} function guestLabel(n){const a=n%100,b=n%10;return `${n.toLocaleString('ru-RU')} ${a>10&&a<20?'гостей':b===1?'гость':b>=2&&b<=4?'гостя':'гостей'}`;}
function plan(ctx,s,top,bottom){ function plan(ctx,s,showPrices,top,bottom){
const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id)); const ordered=groups.flatMap(([id])=>s.items.filter(i=>i.group===id));
// Keep every dish and every character. Never crop, use ellipses or make a // Keep every dish and every character. Never crop, use ellipses or make a
// second page. A genuinely oversized menu gets a clear, recoverable error. // second page. A genuinely oversized menu gets a clear, recoverable error.
// Per-item prices are never shown to the client — only the per-guest total in the footer.
for(let font=24;font>=17;font--){ for(let font=24;font>=17;font--){
const choices=ordered.length<=8?[1,2]:[2,1]; const choices=ordered.length<=8?[1,2]:[2,1];
for(const columns of choices){ for(const columns of choices){
const width=(W-M*2-(columns-1)*42)/columns; const width=(W-M*2-(columns-1)*42)/columns;
ctx.font=`${font}px ${SANS}`; ctx.font=`${font}px ${SANS}`;
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].filter(Boolean).join(' · ')})); const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight,showPrices?money(item.cents/100):''].filter(Boolean).join(' · ')}));
for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;} for(const row of rows){ctx.font=`${Math.max(13,font-5)}px ${SANS}`;row.metaLines=wrap(ctx,row.meta,width);row.height=row.lines.length*font*1.3+row.metaLines.length*(font-2)+10;}
const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};}; const column=list=>{let group='',height=0;const entries=[];for(const row of list){const newGroup=row.item.group!==group;if(newGroup){height+=40;group=row.item.group;}entries.push({...row,heading:newGroup?groups.find(g=>g[0]===group)[1]:null});height+=row.height;}return {entries,height};};
let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]); let candidates=columns===1?[[column(rows)]]:Array.from({length:rows.length-1},(_,i)=>[column(rows.slice(0,i+1)),column(rows.slice(i+1))]);
@ -99,28 +92,27 @@
} }
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.'); throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
} }
async function render(s,{themeId='gold'}={}){ async function render(s,{showPrices=true}={}){
const theme=THEMES[themeId]||THEMES.gold;
await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]); await fonts();const [pdf,logo]=await Promise.all([engine(),logoImage(s.brand.logo)]);
const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2; const canvas=document.createElement('canvas');canvas.width=W*2;canvas.height=H*2;
const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top'; const c=canvas.getContext('2d',{alpha:false});c.scale(2,2);c.textBaseline='top';
const drawnText=[],{ink,muted,accent:gold,paper,rule}=theme,centered=theme.titleAlign==='center'; const drawnText=[],ink='#243b30',muted='#657269',gold='#9b793e',paper='#fbf8f1',rule='#dcd5c6';
const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],align==='center'?x+width/2:x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;}; const text=(value,x,y,width,font,color=ink,lh=24,align='left')=>{c.font=font;c.fillStyle=color;c.textAlign=align;const lines=wrap(c,value,width);for(let i=0;i<lines.length;i++){c.fillText(lines[i],x,y+i*lh);drawnText.push(lines[i]);}return lines.length*lh;};
const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();}; const line=(x,y,x2,color=rule)=>{c.strokeStyle=color;c.lineWidth=1;c.beginPath();c.moveTo(x,y);c.lineTo(x2,y);c.stroke();};
c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48); c.fillStyle=paper;c.fillRect(0,0,W,H);c.strokeStyle=rule;c.lineWidth=1;c.strokeRect(24,24,W-48,H-48);
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left'); const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27);
let headerBottom=Math.max(112,58+brandHeight); let headerBottom=Math.max(112,58+brandHeight);
if(logo&&!centered){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);} if(logo){const scale=Math.min(210/logo.width,66/logo.height);c.drawImage(logo,W-M-logo.width*scale,54,logo.width*scale,logo.height*scale);headerBottom=Math.max(headerBottom,125);}
line(M,headerBottom+14,W-M,gold); line(M,headerBottom+14,W-M,gold);
let y=headerBottom+42; let y=headerBottom+42;
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14; y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63)+14;
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10; if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27)+10;
const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · '); const facts=[s.date?dateLabel(s.date):'',guestLabel(s.guests)].filter(Boolean).join(' · ');
y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23,centered?'center':'left')+24; y+=text(facts,M,y,W-M*2,`600 16px ${SANS}`,gold,23)+24;
line(M,y,W-M);const top=y+20; line(M,y,W-M);const top=y+20;
c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19; c.font=`13px ${SANS}`;const contacts=wrap(c,s.brand.contacts,W-M*2),contactHeight=contacts.length*19;
const footerHeight=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight; const footerHeight=(showPrices?142:62)+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
const layout=plan(c,s,top,bottom); const layout=plan(c,s,showPrices,top,bottom);
for(let j=0;j<layout.columns.length;j++){ for(let j=0;j<layout.columns.length;j++){
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top; const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
for(const row of col.entries){ for(const row of col.entries){
@ -131,13 +123,13 @@
} }
} }
let fy=bottom+22;line(M,fy,W-M,gold);fy+=20; let fy=bottom+22;line(M,fy,W-M,gold);fy+=20;
// Only the per-guest price is ever shown to the client — never a per-item price if(showPrices){
// and never the grand total, so the menu can't be read as a full price list. text('НА ОДНОГО ГОСТЯ',M,fy,380,`700 12px ${SANS}`,muted,17);
// It is not optional: the client always sees what one guest costs. text(`МЕНЮ НА ${s.guests.toLocaleString('ru-RU')} ГОСТЕЙ`,W/2+20,fy,416,`700 12px ${SANS}`,muted,17);fy+=24;
text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24; const fitMoney=(v,x)=>{let size=30;c.font=`600 ${size}px ${SERIF}`;while(size>14&&c.measureText(v).width>400)c.font=`600 ${--size}px ${SERIF}`;text(v,x,fy,400,c.font,ink,size+4);};
let size=34;c.font=`600 ${size}px ${SERIF}`;const v=money(s.cents/100);while(size>16&&c.measureText(v).width>W-M*2)c.font=`600 ${--size}px ${SERIF}`; fitMoney(money(s.cents/100),M);fitMoney(money(s.totalCents/100),W/2+20);fy+=45;
text(v,M,fy,W-M*2,c.font,ink,size+4,centered?'center':'left');fy+=size+16; text('Только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=24;
text('За гостя, только выбранные блюда. Доставка и услуги не включены.',M,fy,W-M*2,`12px ${SANS}`,muted,18,centered?'center':'left');fy+=24; }else if(s.grams){text(`Выход по указанным порциям: ${s.grams.toLocaleString('ru-RU')} г на гостя`,M,fy,W-M*2,`14px ${SANS}`,muted,20);fy+=26;}
if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;} if(s.estimated){text('≈ Вес и стоимость предварительные — уточняются при согласовании.',M,fy,W-M*2,`12px ${SANS}`,muted,18);fy+=22;}
if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19); if(s.brand.contacts)text(s.brand.contacts,M,fy+5,W-M*2,`13px ${SANS}`,muted,19);
const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96)); const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96));
@ -151,7 +143,7 @@
#ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box} #ctBanquetClientDialog{width:min(980px,calc(100vw - 24px));max-width:none;max-height:94dvh;padding:0;border:1px solid #d5cebd;border-radius:16px;background:#f8f5ed;color:#243b30;overflow:auto;box-sizing:border-box}
#ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4} #ctBanquetClientDialog::backdrop{background:#0009}#ctBanquetClientDialog .ct-bcm-toolbar{position:sticky;top:0;z-index:1;display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;padding:15px 18px;background:#f8f5ed;border-bottom:1px solid #ddd5c4}
#ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0} #ctBanquetClientDialog h2{font-size:18px;margin:0}#ctBanquetClientDialog .ct-bcm-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}#ctBanquetClientDialog .ct-bcm-actions label{display:flex;flex-direction:row;align-items:center;gap:7px;font-size:12px;margin:0}
#ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog select{min-height:34px;padding:5px 8px;border-radius:8px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30} #ctBanquetClientDialog input[type=checkbox]{width:18px!important;height:18px;min-height:0;margin:0}#ctBanquetClientDialog button{min-height:40px;padding:9px 12px;border-radius:9px;border:1px solid #c8c9bb;background:#fff;color:#243b30;font:600 12px Arial;cursor:pointer}#ctBanquetClientDialog [data-bcm-download]{background:#243b30;color:#fff;border-color:#243b30}
#ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002} #ctBanquetClientDialog button:disabled{opacity:.45;cursor:default}#ctBanquetClientDialog [data-bcm-status]{margin:14px 18px;font-size:13px;line-height:1.5}#ctBanquetClientDialog [data-bcm-preview]{padding:0 18px 18px}#ctBanquetClientDialog img{display:block;width:100%;max-width:700px;height:auto;margin:auto;box-shadow:0 3px 20px #0002}
#ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}} #ctBanquetClientDialog [hidden]{display:none!important}#ctBanquetClientDialog :focus-visible{outline:3px solid #ac8a4b;outline-offset:2px}@media(max-width:600px){#ctBanquetClientDialog .ct-bcm-toolbar{padding:12px}#ctBanquetClientDialog .ct-bcm-actions{gap:6px}#ctBanquetClientDialog [data-bcm-preview]{padding:0 8px 10px}}
`;document.head.append(el); `;document.head.append(el);
@ -160,22 +152,21 @@
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.'); if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
const s=snapshot(input),started=scope();close();style(); const s=snapshot(input),started=scope();close();style();
const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle'); const dialog=document.createElement('dialog');dialog.id='ctBanquetClientDialog';dialog.setAttribute('aria-labelledby','ctBanquetClientTitle');
const themeOptions=themeIds.map(id=>`<option value="${id}">${THEMES[id].label}</option>`).join(''); dialog.innerHTML='<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label><input type="checkbox" data-bcm-prices checked>Показывать стоимость</label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>';
dialog.innerHTML=`<div class="ct-bcm-toolbar"><h2 id="ctBanquetClientTitle">Меню для клиента</h2><div class="ct-bcm-actions"><label>Оформление<select data-bcm-theme>${themeOptions}</select></label><button type="button" data-bcm-download disabled>Скачать PDF</button><button type="button" data-bcm-share hidden disabled>Поделиться</button><button type="button" data-bcm-close aria-label="Закрыть меню для клиента">Закрыть</button></div></div><p data-bcm-status role="status" aria-live="polite">Оформляю меню на одной странице…</p><div data-bcm-preview></div>`;
const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state; const state={dialog,snapshot:s,generation:0,url:null,result:null,restore:document.activeElement};panel=state;
const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),themeSelect=get('[data-bcm-theme]'); const get=q=>dialog.querySelector(q),status=get('[data-bcm-status]'),download=get('[data-bcm-download]'),share=get('[data-bcm-share]'),check=get('[data-bcm-prices]');
document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal(); document.body.append(dialog);dialog.addEventListener('cancel',e=>{e.preventDefault();close();});get('[data-bcm-close]').onclick=close;dialog.showModal();
const valid=()=>panel===state&&scope()===started&&allowed(); const valid=()=>panel===state&&scope()===started&&allowed();
const build=async()=>{ const build=async()=>{
const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…'; const ticket=++state.generation;download.disabled=true;share.disabled=true;state.result=null;status.textContent='Оформляю меню на одной странице…';
try{const result=await render(s,{themeId:themeSelect.value});if(!valid()||ticket!==state.generation)return; try{const result=await render(s,{showPrices:check.checked});if(!valid()||ticket!==state.generation)return;
if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result; if(state.url)URL.revokeObjectURL(state.url);state.url=URL.createObjectURL(result.jpeg);state.result=result;
const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img); const img=document.createElement('img');img.src=state.url;img.alt='Банкетное меню: одна страница A4';get('[data-bcm-preview]').replaceChildren(img);
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. Показана только цена за гостя.`;download.disabled=false; status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. ${check.checked?'Стоимость только выбранных блюд.':'Без указания стоимости.'}`;download.disabled=false;
const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false; const file=new File([result.blob],'Банкетное меню.pdf',{type:'application/pdf'});share.hidden=!(navigator.share&&navigator.canShare?.({files:[file]}));share.disabled=false;
}catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}} }catch(error){if(valid()&&ticket===state.generation){status.textContent=error.message;get('[data-bcm-preview]').replaceChildren();}}
}; };
themeSelect.onchange=build; check.onchange=build;
download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}; download.onclick=()=>{if(!valid()||!state.result)return;const url=URL.createObjectURL(state.result.blob),a=document.createElement('a');a.href=url;a.download=`Банкетное меню${s.date?' '+s.date:''}.pdf`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);};
share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}}; share.onclick=async()=>{if(!valid()||!state.result)return;try{await navigator.share({files:[new File([state.result.blob],'Банкетное меню.pdf',{type:'application/pdf'})],title:'Банкетное меню'});}catch(error){if(valid()&&error.name!=='AbortError')status.textContent='Не удалось открыть отправку. Сохраните PDF и отправьте его вручную.';}};
await build();return state.result; await build();return state.result;

View File

@ -48,7 +48,7 @@
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{ if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer; const script=document.createElement('script');let timer;
const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));}; const fail=()=>{clearTimeout(timer);script.remove();reject(new Error('Не удалось загрузить оформление меню. Повторите попытку.'));};
script.src='core/banquet-client-menu.js?v=20260922-guest-price-always';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script); script.src='core/banquet-client-menu.js?v=20260920-onepage';script.onload=()=>{clearTimeout(timer);window.CateriumBanquetClientMenu?resolve():fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
}); });
if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return; if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return;
await window.CateriumBanquetClientMenu.open(input); await window.CateriumBanquetClientMenu.open(input);

View File

@ -10,7 +10,7 @@ function comments(){let rows=lineRows(),ls=activeLines();rows.forEach((row,i)=>{
function searchBox(){let catalog=$('#new .catalog');if(!catalog||$('#sunCatalogSearchV1775'))return;let i=document.createElement('input');i.id='sunCatalogSearchV1775';i.type='search';i.placeholder='Поиск по № бокса или названию';($('.catalog-head',catalog)||catalog.firstElementChild)?.insertAdjacentElement('afterend',i);i.addEventListener('input',()=>{let q=safe(i.value).toLowerCase().replace(/ё/g,'е');$$('#tiles .tile').forEach(t=>{let text=safe(t.textContent).toLowerCase().replace(/ё/g,'е');t.hidden=t.classList.contains('add')?!!q:!!(q&&!text.includes(q))})})} function searchBox(){let catalog=$('#new .catalog');if(!catalog||$('#sunCatalogSearchV1775'))return;let i=document.createElement('input');i.id='sunCatalogSearchV1775';i.type='search';i.placeholder='Поиск по № бокса или названию';($('.catalog-head',catalog)||catalog.firstElementChild)?.insertAdjacentElement('afterend',i);i.addEventListener('input',()=>{let q=safe(i.value).toLowerCase().replace(/ё/g,'е');$$('#tiles .tile').forEach(t=>{let text=safe(t.textContent).toLowerCase().replace(/ё/g,'е');t.hidden=t.classList.contains('add')?!!q:!!(q&&!text.includes(q))})})}
function delivery(){return Math.max(0,Number($('#orderDeliveryCost')?.value||draft?.costs?.delivery||draft?.deliveryCost||0))} function delivery(){return Math.max(0,Number($('#orderDeliveryCost')?.value||draft?.costs?.delivery||draft?.deliveryCost||0))}
function financial(){const p=window.sunOrderPricing?.(draft);let d=p?Number(p.delivery||0):delivery(),positions=p?Number(p.itemsTotal||0):Math.max(0,Number(typeof calcOrderTotal==='function'?calcOrderTotal():0)),paid=Math.max(0,Number($('#prepayment')?.value||draft?.prepayment||0)),total=positions+d;return{positions,d,total,paid,balance:Math.max(0,total-paid)}} function financial(){const p=window.sunOrderPricing?.(draft);let d=p?Number(p.delivery||0):delivery(),positions=p?Number(p.itemsTotal||0):Math.max(0,Number(typeof calcOrderTotal==='function'?calcOrderTotal():0)),paid=Math.max(0,Number($('#prepayment')?.value||draft?.prepayment||0)),total=positions+d;return{positions,d,total,paid,balance:Math.max(0,total-paid)}}
function summary(){let h=$('.order-summary');if(!h)return;let f=financial();if($('#summaryTotal'))$('#summaryTotal').textContent=fmt(f.positions);let richUI=!!$('#summaryDelivery');if(!richUI){let d=$('#sunSummaryDeliveryV1775');if(!d){d=document.createElement('p');d.id='sunSummaryDeliveryV1775';h.appendChild(d)}d.innerHTML=`<span>Доставка</span><b>${fmt(f.d)}</b>`;let t=$('#sunSummaryGrandV1775');if(!t){t=document.createElement('p');t.id='sunSummaryGrandV1775';t.className='sun-summary-grand-v1775';h.appendChild(t)}t.innerHTML=`<span>Итого</span><b>${fmt(f.total)}</b>`}else{$('#sunSummaryDeliveryV1775')?.remove();$('#sunSummaryGrandV1775')?.remove()}if($('#orderTotal'))$('#orderTotal').value=Math.round(f.total);if($('#balance'))$('#balance').value=Math.round(f.balance);if($('#summaryBalance'))$('#summaryBalance').textContent=fmt(f.balance);draft.total=f.total;draft.balance=f.balance} function summary(){let h=$('.order-summary');if(!h)return;let f=financial();if($('#summaryTotal'))$('#summaryTotal').textContent=fmt(f.positions);let d=$('#sunSummaryDeliveryV1775');if(!d){d=document.createElement('p');d.id='sunSummaryDeliveryV1775';h.appendChild(d)}d.innerHTML=`<span>Доставка</span><b>${fmt(f.d)}</b>`;let t=$('#sunSummaryGrandV1775');if(!t){t=document.createElement('p');t.id='sunSummaryGrandV1775';t.className='sun-summary-grand-v1775';h.appendChild(t)}t.innerHTML=`<span>Итого</span><b>${fmt(f.total)}</b>`;if($('#orderTotal'))$('#orderTotal').value=Math.round(f.total);if($('#balance'))$('#balance').value=Math.round(f.balance);if($('#summaryBalance'))$('#summaryBalance').textContent=fmt(f.balance);draft.total=f.total;draft.balance=f.balance}
function post(){styles();searchBox();comments();summary()} function post(){styles();searchBox();comments();summary()}
function wrap(n){let old=window[n];if(typeof old!=='function'||old.__commentsV1775)return;let fn=function(...a){let r=old.apply(this,a);queueMicrotask(post);setTimeout(post,50);return r};fn.__commentsV1775=true;window[n]=fn} function wrap(n){let old=window[n];if(typeof old!=='function'||old.__commentsV1775)return;let fn=function(...a){let r=old.apply(this,a);queueMicrotask(post);setTimeout(post,50);return r};fn.__commentsV1775=true;window[n]=fn}
styles();['render','openOrder','resetDraft','updateOrderSummary','saveOrder'].forEach(wrap);post(); styles();['render','openOrder','resetDraft','updateOrderSummary','saveOrder'].forEach(wrap);post();

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.7.3'; const VERSION='17.7.3';
const RELEASE='20260922-order-summary-fix'; const RELEASE='20260921-client-sync';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){ function installAuthBoot(){

View File

@ -1,233 +0,0 @@
(()=>{
'use strict';
if(window.CateriumSingleItemPdf)return;
const VERSION='20260922-single-item-pdf-v1';
const $=id=>document.getElementById(id);
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const CATEGORY_NAMES={0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум',6:'Банкетное меню'};
let currentItemId='',lastPdfUrl='',wrappedEditBox=null;
function toast(message,type='success'){
try{return window.SunEnterprise?.toast?.(message,type)}catch(_){}
if(type==='warn'||type==='error')console.warn(message);else console.log(message);
}
function catalog(){
try{
const rows=window.CateriumDataV1773?.catalog?.list?.();
if(Array.isArray(rows))return rows;
}catch(_){}
try{
const rows=JSON.parse(localStorage.getItem('sunBoxes')||'[]');
return Array.isArray(rows)?rows:[];
}catch(_){return[]}
}
function itemById(id){return catalog().find(x=>String(x?.id)===String(id))||null}
function categoryName(item){return String(item?.catalogSection||CATEGORY_NAMES[Number(item?.category||0)]||'Каталог').trim()}
function price(item){try{return Number(window.CateriumPricing?.price?.(item)??item?.price??0)||0}catch(_){return Number(item?.price||0)||0}}
function money(value){return `${Number(value||0).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`}
function brand(){
try{
const b=window.CateriumBranding?.identity?.();
if(b)return b;
}catch(_){}
return {name:'Caterium',logo:'',contacts:''};
}
function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()}
function composition(item){
if(Array.isArray(item?.composition)&&item.composition.some(Boolean))return item.composition.map(x=>String(x||'').trim()).filter(Boolean);
if(Array.isArray(item?.ingredients))return item.ingredients.filter(x=>Array.isArray(x)&&x[0]).map(row=>{
const qty=Math.max(0,Number(row[1]||0)),unit=String(row[2]||'').trim(),name=cleanIngredientName(row[0]);
if(!qty||(/^поз\.?$/i.test(unit)&&qty<=1))return name;
const shown=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});
return `${name}${unit?`${shown} ${unit}`:''}`;
}).filter(Boolean);
return [];
}
function safeImageSrc(src){
const raw=String(src||'').trim();if(!raw)return'';
try{return window.SunSafe?.imageAssetSrc?window.SunSafe.imageAssetSrc(raw):raw}catch(_){return raw}
}
function loadImage(src){
return new Promise(resolve=>{
const raw=safeImageSrc(src);if(!raw)return resolve(null);
const img=new Image();let done=false;
const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};
try{const u=new URL(raw,document.baseURI||location.href);if(/^https?:$/i.test(u.protocol)&&u.origin!==location.origin)img.crossOrigin='anonymous'}catch(_){}
img.onload=()=>finish(img);img.onerror=()=>finish(null);
const timer=setTimeout(()=>finish(null),15000);img.src=raw;
});
}
function drawCover(ctx,img,x,y,w,h){
if(!img)return;
const iw=img.naturalWidth||img.width,ih=img.naturalHeight||img.height;if(!iw||!ih)return;
const scale=Math.max(w/iw,h/ih),sw=w/scale,sh=h/scale,sx=(iw-sw)/2,sy=(ih-sh)/2;
ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h);
}
function drawContain(ctx,img,x,y,w,h){
if(!img)return;
const iw=img.naturalWidth||img.width,ih=img.naturalHeight||img.height;if(!iw||!ih)return;
const scale=Math.min(w/iw,h/ih),dw=iw*scale,dh=ih*scale;
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh);
}
function wrap(ctx,text,width,maxLines=20){
const words=String(text||'').replace(/\s+/g,' ').trim().split(' ').filter(Boolean),lines=[];let line='';
for(const word of words){
const test=line?`${line} ${word}`:word;
if(!line||ctx.measureText(test).width<=width)line=test;
else{lines.push(line);line=word;if(lines.length>=maxLines-1)break}
}
if(line&&lines.length<maxLines)lines.push(line);
if(words.length&&lines.length===maxLines){
let last=lines[maxLines-1]||'';
while(last.length>2&&ctx.measureText(last+'…').width>width)last=last.slice(0,-1);
lines[maxLines-1]=last.replace(/[\s,.;:-]+$/,'')+'…';
}
return lines;
}
function filename(name){
const base=String(name||'Бокс').trim().replace(/[\\/:*?"<>|]+/g,' ').replace(/\s+/g,' ').slice(0,80)||'Бокс';
return `${base}.pdf`;
}
function canvasJpeg(canvas){
const data=canvas.toDataURL('image/jpeg',.94),base64=String(data).split(',')[1]||'';
if(!base64)throw new Error('Не удалось подготовить страницу PDF.');
const raw=atob(base64),bytes=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);
return {bytes,width:canvas.width,height:canvas.height};
}
async function renderItemPage(item){
const W=1240,H=1754,heroH=1110,canvas=document.createElement('canvas');canvas.width=W;canvas.height=H;
const ctx=canvas.getContext('2d',{alpha:false});ctx.textBaseline='alphabetic';ctx.textAlign='left';
ctx.fillStyle='#fff';ctx.fillRect(0,0,W,H);
const b=brand(),photo=await loadImage(item.photo||'');
if(photo)drawCover(ctx,photo,0,0,W,heroH);
else{
ctx.fillStyle='#f3f0e9';ctx.fillRect(0,0,W,heroH);
const logo=await loadImage(b.logo||'');if(logo)drawContain(ctx,logo,370,280,500,500);
else{ctx.fillStyle='#d7d1c6';ctx.font='700 72px Arial,sans-serif';ctx.textAlign='center';ctx.fillText(String(b.name||'Caterium'),W/2,heroH/2);ctx.textAlign='left'}
}
// Subtle photo readability veil at the very top, matching the reference's clean label.
const topGrad=ctx.createLinearGradient(0,0,0,170);topGrad.addColorStop(0,'rgba(255,255,255,.72)');topGrad.addColorStop(1,'rgba(255,255,255,0)');
ctx.fillStyle=topGrad;ctx.fillRect(0,0,W,180);
ctx.fillStyle='#c45f73';ctx.font='500 30px Arial,sans-serif';ctx.fillText(categoryName(item).toLowerCase(),42,70);
const panelY=heroH;ctx.fillStyle='#fff';ctx.fillRect(0,panelY,W,H-panelY);
const titleY=1195,priceY=1195;
ctx.fillStyle='#171719';ctx.font='400 52px Arial,sans-serif';
const titleLines=wrap(ctx,String(item.name||'').toUpperCase(),710,2);
titleLines.forEach((line,i)=>ctx.fillText(line,38,titleY+i*58));
ctx.textAlign='right';ctx.fillStyle='#171719';ctx.font='400 50px Arial,sans-serif';ctx.fillText(money(price(item)),1197,priceY);
ctx.strokeStyle='#c94f67';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(970,1212);ctx.lineTo(1197,1212);ctx.stroke();ctx.textAlign='left';
const metaY=1260;
ctx.textAlign='right';ctx.fillStyle='#242426';ctx.font='500 25px Arial,sans-serif';
const pieces=Math.max(0,Math.round(Number(item.pieces||0)));
if(pieces)ctx.fillText(`${pieces} шт.`,1197,metaY);
if(item.weight)ctx.fillText(`Вес: ${String(item.weight)}`,1197,metaY+(pieces?38:0));
ctx.textAlign='left';
const lines=composition(item);
ctx.fillStyle='#55565a';ctx.font='400 24px Arial,sans-serif';
let y=1328,rendered=0;
for(const raw of lines){
const parts=wrap(ctx,raw,760,2);
for(const part of parts){
if(rendered>=8)break;
ctx.fillText(part,38,y);y+=34;rendered++;
}
if(rendered>=8)break;
}
if(!rendered){ctx.fillStyle='#83858a';ctx.fillText('Состав не указан.',38,y)}
// Company mark in the lower-right, similar to the reference watermark.
ctx.textAlign='right';ctx.fillStyle='#76b9b4';ctx.font='500 22px Arial,sans-serif';
ctx.fillText(String(b.name||window.CateriumBranding?.documentName?.()||'Caterium'),1197,1708);
ctx.textAlign='left';
return canvasJpeg(canvas);
}
async function buildItemPdfBlob(id){
const item=itemById(id);if(!item)throw new Error('Позиция не найдена в каталоге.');
if(!window.SunPdfEngine?.fromJpegs)throw new Error('PDF-движок ещё не загружен.');
const page=await renderItemPage(item);
return window.SunPdfEngine.fromJpegs([page]);
}
function loadingPage(win,item){
try{
win.document.open();win.document.write(`<!doctype html><meta charset="utf-8"><title>${esc(item?.name||'PDF бокса')}</title><style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#f5f2eb;font:16px Arial;color:#15364c}.box{text-align:center;background:#fff;padding:28px 34px;border-radius:14px;box-shadow:0 10px 35px #0002}.mark{font-size:32px;color:#c99a32}.p{margin-top:9px;color:#68717a}</style><div class="box"><div class="mark">☼</div><b>Формируем PDF</b><div class="p">Один бокс · одна страница A4</div></div>`);win.document.close();
}catch(_){}
}
async function openItemPdf(id){
const item=itemById(id);if(!item){toast('Позиция не найдена в каталоге.','warn');return}
const viewer=window.open('about:blank','_blank');if(viewer)loadingPage(viewer,item);
try{
const blob=await buildItemPdfBlob(id);
if(lastPdfUrl)URL.revokeObjectURL(lastPdfUrl);lastPdfUrl=URL.createObjectURL(blob);
if(viewer)viewer.location.replace(lastPdfUrl);
else{
const a=document.createElement('a');a.href=lastPdfUrl;a.download=filename(item.name);a.style.display='none';document.body.appendChild(a);a.click();a.remove();
toast('PDF бокса подготовлен.','success');
}
}catch(error){
try{if(viewer)viewer.document.body.innerHTML=`<div style="font:16px Arial;padding:30px;color:#7a2e2e"><b>Не удалось сформировать PDF.</b><p>${esc(error?.message||error)}</p></div>`}catch(_){}
toast(error?.message||'Не удалось сформировать PDF.','warn');
}
}
function ensureEditorButton(){
const actions=document.querySelector('#editor .dialog .actions');if(!actions)return null;
let btn=$('sunSingleItemPdfButton');
if(!btn){
btn=document.createElement('button');btn.id='sunSingleItemPdfButton';btn.type='button';btn.className='outline';btn.textContent='PDF бокса';
btn.title='Открыть одностраничный PDF выбранного бокса';
btn.onclick=()=>{const id=String(btn.dataset.itemId||'');if(id)void openItemPdf(id)};
const danger=actions.querySelector('.danger');if(danger)danger.before(btn);else actions.appendChild(btn);
}
return btn;
}
function syncEditorButton(id){
currentItemId=String(id||'');const btn=ensureEditorButton();if(!btn)return;
const item=currentItemId?itemById(currentItemId):null;
btn.dataset.itemId=currentItemId;
btn.hidden=!item;btn.style.display=item?'inline-flex':'none';
btn.textContent=item&&[0,5].includes(Number(item.category||0))?'PDF бокса':'PDF позиции';
}
function injectReadOnlyButton(){
const host=document.querySelector('#sunMenuDetailV1762 .sun-menu-readonly');if(!host||!currentItemId||host.querySelector('[data-single-item-pdf]'))return;
const item=itemById(currentItemId);if(!item)return;
const btn=document.createElement('button');btn.type='button';btn.className='outline';btn.dataset.singleItemPdf='1';btn.textContent=[0,5].includes(Number(item.category||0))?'PDF бокса':'PDF позиции';
btn.style.margin='0 0 14px';btn.onclick=()=>void openItemPdf(currentItemId);
host.insertBefore(btn,host.firstElementChild?.nextSibling||host.firstChild);
}
function wrapEditor(){
if(typeof window.editBox!=='function'||window.editBox===wrappedEditBox)return;
const previous=window.editBox;
wrappedEditBox=function(id,...args){
currentItemId=String(id||'');
const result=previous.call(this,id,...args);
setTimeout(()=>syncEditorButton(currentItemId),0);
return result;
};
wrappedEditBox.__singlePdfWrapped=true;window.editBox=wrappedEditBox;
}
function boot(){
ensureEditorButton();syncEditorButton('');
wrapEditor();
document.addEventListener('click',event=>{
const row=event.target.closest?.('[data-menu-item-v1762]');if(row){currentItemId=String(row.dataset.menuItemV1762||'');setTimeout(injectReadOnlyButton,0)}
},true);
new MutationObserver(()=>{if(window.editBox!==wrappedEditBox)wrapEditor();if(document.querySelector('#editor .dialog .actions')&&!$('sunSingleItemPdfButton'))syncEditorButton(currentItemId);injectReadOnlyButton();}).observe(document.documentElement,{childList:true,subtree:true});
}
window.CateriumSingleItemPdf=Object.freeze({VERSION,buildItemPdfBlob,openItemPdf,renderItemPage});
window.sunBuildSingleCatalogItemPdfBlob=buildItemPdfBlob;
window.sunOpenSingleCatalogItemPdf=openItemPdf;
window.sunSyncSingleCatalogPdfButton=syncEditorButton;
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -33,7 +33,7 @@
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000); const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),15000);
try{ try{
const [base,banquet,extras]=await Promise.all(['catalog-v1','banquet-v1','extras-v1'].map(async name=>{ 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'}); const r=await fetch(`demo/${name}.json?v=20260920-training`,{signal:controller.signal,credentials:'same-origin'});
if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.'); if(!r.ok)throw new Error('Не удалось загрузить учебный каталог. Проверьте интернет и повторите.');
const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value; const value=await r.json();if(value?.version!==1)throw new Error('Неизвестная версия учебного каталога.');return value;
})); }));
@ -128,8 +128,6 @@
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>'; 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); host.prepend(card);
card.querySelector('input').addEventListener('change',e=>{setEnabled(e.target.checked).catch(()=>{});}); 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; const check=$('ctTrainingEnabled');check.checked=busy?desired:enabled();check.disabled=busy||!canManage()||!repo()?.catalog;
card.setAttribute('aria-busy',String(busy)); card.setAttribute('aria-busy',String(busy));

View File

@ -18,40 +18,16 @@
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;} function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
async function refreshStatus(){status=null;statusWorkspace='';render();} async function refreshStatus(){status=null;statusWorkspace='';render();}
function openTrainingSettings(){
const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки');
if(nav)nav.click();else window.show?.('enterprise-settings');
setTimeout(()=>{
const card=$('ctTrainingSettings');if(!card)return;
const tab=card.dataset.settingsTab;if(tab)document.querySelector(`[data-settings-tab-target="${tab}"]`)?.click();
setTimeout(()=>{card.scrollIntoView({block:'start',behavior:'smooth'});card.querySelector('input')?.focus({preventScroll:true});},120);
},150);
}
function ensureNotice(host){
let notice=$('ctTrainingNotice');
if(!notice){
notice=document.createElement('div');notice.id='ctTrainingNotice';notice.className='ct-training-notice';notice.setAttribute('role','status');
notice.innerHTML='<span>Включён учебный режим ·</span><button type="button" data-training-settings>отключить в настройках</button>';
notice.querySelector('button').addEventListener('click',openTrainingSettings);
}
if(host.firstElementChild!==notice)host.prepend(notice);
}
function render(){ function render(){
const host=$('new');if(!host)return; const host=$('new');if(!host)return;
const clear=()=>{$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();}; if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){$('ctTrialDemo')?.remove();return;}
if(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()){clear();return;}
const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0; const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0;
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){clear();return;} if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
// While training is on, the order screen only carries a one-line notice; the guide lives in Settings. let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);}
if(active)ensureNotice(host);else $('ctTrainingNotice')?.remove();
const target=active?$('ctTrainingSettings'):host;
let card=$('ctTrialDemo');if(card&&card.parentElement!==target){card.remove();card=null;}
if(!target)return;
if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className=active?'ct-demo-guide':'card';if(active)target.append(card);else target.prepend(card);}
const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()}); const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()});
if(card.dataset.signature===signature)return;card.dataset.signature=signature; if(card.dataset.signature===signature)return;card.dataset.signature=signature;
const previousGuide={open:card.querySelector('details')?.open,selected:$('ctDemoBox')?.value,message:$('ctDemoMessage')?.textContent}; const previousGuide={open:card.querySelector('details')?.open,selected:$('ctDemoBox')?.value,message:$('ctDemoMessage')?.textContent};
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Пробный заказ, склад и закупки</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div></div> card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Учебный каталог включён</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div><span class="ct-demo-badge">Учебные примеры</span></div>
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p> <p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div> <div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''} ${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
@ -100,9 +76,9 @@
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)}; dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal(); dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
} }
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();$('ctDemoTtk')?.remove();} function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctDemoTtk')?.remove();}
function boot(){ function boot(){
const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}#ctTrainingNotice{display:flex;align-items:center;flex-wrap:wrap;gap:4px 6px;margin:0 0 12px;padding:7px 12px;border:1px solid #e6dcc0;border-radius:10px;background:#faf6e9;color:#5f4f25;font-size:12px;line-height:1.4}#ctTrainingNotice button{all:unset;box-sizing:border-box;cursor:pointer;color:#7a5c12;text-decoration:underline;font:inherit}#ctTrainingNotice button:focus-visible{outline:2px solid #b88c2e;outline-offset:2px;border-radius:3px}#ctTrainingSettings #ctTrialDemo{margin:16px 0 0;padding:16px 0 0;border:0;border-top:1px solid var(--sun-ui-border,#deded6);border-radius:0;box-shadow:none;background:none}#ctTrainingSettings #ctTrialDemo .ct-demo-heading strong{font-size:15px}@media print{#ctTrialDemo,#ctTrainingNotice{display:none!important}}`; const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}@media print{#ctTrialDemo{display:none!important}}`;
document.head.append(style); document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',reset); window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()}); window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});

View File

@ -9,7 +9,7 @@
"price": 290, "price": 290,
"weight": "120 г", "weight": "120 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-caprese.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Моцарелла мини, Томаты, Базилик, Масло оливковое" "Моцарелла мини, Томаты, Базилик, Масло оливковое"
@ -95,7 +95,7 @@
"price": 390, "price": 390,
"weight": "100 г", "weight": "100 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-roastbeef.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Ростбиф готовый, Корнишоны, Горчица, Сливки" "Ростбиф готовый, Корнишоны, Горчица, Сливки"
@ -182,7 +182,7 @@
"price": 350, "price": 350,
"weight": "90 г", "weight": "90 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-salmon-roll.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп" "Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп"
@ -269,7 +269,7 @@
"price": 230, "price": 230,
"weight": "160 г", "weight": "160 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-hummus.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Морковь, Огурцы, Перец сладкий, Хумус готовый" "Морковь, Огурцы, Перец сладкий, Хумус готовый"
@ -355,7 +355,7 @@
"price": 340, "price": 340,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-caesar.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица" "Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица"
@ -483,7 +483,7 @@
"price": 280, "price": 280,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-olivier.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица" "Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица"
@ -624,7 +624,7 @@
"price": 290, "price": 290,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-greek.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое" "Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое"
@ -736,7 +736,7 @@
"price": 310, "price": 310,
"weight": "130 г", "weight": "130 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-julienne.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое" "Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое"
@ -849,7 +849,7 @@
"price": 270, "price": 270,
"weight": "120 г", "weight": "120 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-stuffed-mushrooms.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое" "Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое"
@ -949,7 +949,7 @@
"price": 490, "price": 490,
"weight": "220 г", "weight": "220 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-chicken.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое" "Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое"
@ -1049,7 +1049,7 @@
"price": 590, "price": 590,
"weight": "200 г", "weight": "200 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-cod.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое" "Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое"
@ -1148,7 +1148,7 @@
"price": 690, "price": 690,
"weight": "220 г", "weight": "220 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-beef-hot.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое" "Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое"
@ -1247,7 +1247,7 @@
"price": 160, "price": 160,
"weight": "150 г", "weight": "150 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-mash.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Картофель, Молоко, Масло сливочное" "Картофель, Молоко, Масло сливочное"
@ -1320,7 +1320,7 @@
"price": 170, "price": 170,
"weight": "150 г", "weight": "150 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-rice.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое" "Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое"
@ -1417,7 +1417,7 @@
"price": 240, "price": 240,
"weight": "110 г", "weight": "110 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-berry-cream.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная" "Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная"
@ -1531,7 +1531,7 @@
"price": 310, "price": 310,
"weight": "100 г", "weight": "100 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-cheese-honey.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Сыр бри, Виноград, Орех грецкий очищенный, Мёд" "Сыр бри, Виноград, Орех грецкий очищенный, Мёд"
@ -1618,7 +1618,7 @@
"price": 290, "price": 290,
"weight": "180 г", "weight": "180 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-fruit.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Виноград, Клубника, Голубика" "Виноград, Клубника, Голубика"
@ -1689,7 +1689,7 @@
"price": 120, "price": 120,
"weight": "60 г", "weight": "60 г",
"pieces": 1, "pieces": 1,
"photo": "demo/images/banquet-bread.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Багет, Хлеб ржаной, Масло сливочное, Укроп" "Багет, Хлеб ржаной, Масло сливочное, Укроп"

View File

@ -9,7 +9,7 @@
"price": 3500, "price": 3500,
"pieces": 18, "pieces": 18,
"weight": "710 г", "weight": "710 г",
"photo": "demo/images/extras-salmon-caprese.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Канапе с лососем и сливочным сыром", "Половина стандартного бокса: Канапе с лососем и сливочным сыром",
@ -194,7 +194,7 @@
"price": 3900, "price": 3900,
"pieces": 0, "pieces": 0,
"weight": "750 г", "weight": "750 г",
"photo": "demo/images/extras-meat-cheese.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Мясное ассорти с корнишонами", "Половина стандартного бокса: Мясное ассорти с корнишонами",
@ -432,7 +432,7 @@
"price": 2900, "price": 2900,
"pieces": 22, "pieces": 22,
"weight": "820 г", "weight": "820 г",
"photo": "demo/images/extras-mini-buffet.webp", "photo": "",
"demo": true, "demo": true,
"composition": [ "composition": [
"Половина стандартного бокса: Брускетты с томатами и базиликом", "Половина стандартного бокса: Брускетты с томатами и базиликом",
@ -603,7 +603,7 @@
"price": 90, "price": 90,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-water.webp", "photo": "",
"composition": [ "composition": [
"Одна бутылка 0,5 л" "Одна бутылка 0,5 л"
], ],
@ -624,7 +624,7 @@
"price": 100, "price": 100,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-sparkling.webp", "photo": "",
"composition": [ "composition": [
"Одна бутылка 0,5 л" "Одна бутылка 0,5 л"
], ],
@ -645,7 +645,7 @@
"price": 220, "price": 220,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-juice.webp", "photo": "",
"composition": [ "composition": [
"Одна упаковка 1 л" "Одна упаковка 1 л"
], ],
@ -666,7 +666,7 @@
"price": 280, "price": 280,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-mors.webp", "photo": "",
"composition": [ "composition": [
"Одна бутылка готового морса 1 л" "Одна бутылка готового морса 1 л"
], ],
@ -687,7 +687,7 @@
"price": 25, "price": 25,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-plate.webp", "photo": "",
"composition": [ "composition": [
"Одна сервировочная тарелка" "Одна сервировочная тарелка"
], ],
@ -708,7 +708,7 @@
"price": 10, "price": 10,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-fork.webp", "photo": "",
"composition": [ "composition": [
"Одна вилка" "Одна вилка"
], ],
@ -729,7 +729,7 @@
"price": 15, "price": 15,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-glass.webp", "photo": "",
"composition": [ "composition": [
"Один стакан" "Один стакан"
], ],
@ -750,7 +750,7 @@
"price": 10, "price": 10,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-napkin.webp", "photo": "",
"composition": [ "composition": [
"Одна бумажная салфетка" "Одна бумажная салфетка"
], ],
@ -771,7 +771,7 @@
"price": 180, "price": 180,
"pieces": 1, "pieces": 1,
"weight": "1 кг", "weight": "1 кг",
"photo": "demo/images/extras-ice.webp", "photo": "",
"composition": [ "composition": [
"Один пакет пищевого льда, 1 кг" "Один пакет пищевого льда, 1 кг"
], ],
@ -792,7 +792,7 @@
"price": 250, "price": 250,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-tablecloth.webp", "photo": "",
"composition": [ "composition": [
"Одна одноразовая скатерть 120 × 180 см" "Одна одноразовая скатерть 120 × 180 см"
], ],
@ -813,7 +813,7 @@
"price": 190, "price": 190,
"pieces": 1, "pieces": 1,
"weight": "", "weight": "",
"photo": "demo/images/extras-serving-kit.webp", "photo": "",
"composition": [ "composition": [
"Один комплект одноразовых приборов для подачи" "Один комплект одноразовых приборов для подачи"
], ],
@ -834,7 +834,7 @@
"price": 600, "price": 600,
"pieces": 0, "pieces": 0,
"weight": "", "weight": "",
"photo": "demo/images/extras-delivery-city.webp", "photo": "",
"composition": [ "composition": [
"Учебный тариф за один адрес в пределах города." "Учебный тариф за один адрес в пределах города."
], ],
@ -849,7 +849,7 @@
"price": 1200, "price": 1200,
"pieces": 0, "pieces": 0,
"weight": "", "weight": "",
"photo": "demo/images/extras-delivery-outer.webp", "photo": "",
"composition": [ "composition": [
"Учебный тариф за один адрес в ближайшем пригороде." "Учебный тариф за один адрес в ближайшем пригороде."
], ],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,15 @@
const CACHE='sun-catering-pwa-v111-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail-correct-recipient-order-summary-fix'; const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail-correct-recipient';
const VERSION='20260918-ui-stability'; const VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/support-form.js?v=20260921-support-recipient', './core/support-form.js?v=20260921-support-recipient',
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry', './core/trial-promo-developer-v181.js?v=20260921-promo-entry',
'./core/single-item-pdf.js?v=20260922-single-item-pdf-v1', './core/banquet-client-menu.js?v=20260920-onepage',
'./core/banquet-client-menu.js?v=20260922-guest-price-always', './core/training-catalog.js?v=20260920-training',
'./core/training-catalog.js?v=20260922-training-photos',
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
'./vendor/supabase-2.112.4.min.js', './vendor/supabase-2.112.4.min.js',
`./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, `./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`,
'./','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./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}`, './','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./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=20260922-order-summary-fix',`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?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=20260922-order-summary-fix',`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.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/login-signature-v1776.css?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', './offer-gallery/001.jpg','./offer-gallery/002.jpg',
'./catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg',
'./sun-logo.png','./caterium-login-logo.png','./caterium-mark-light.svg','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest', './sun-logo.png','./caterium-login-logo.png','./caterium-mark-light.svg','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest',
@ -18,7 +17,7 @@ const CORE=[
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/support-form.js', '/core/support-form.js',
'/core/trial-promo-developer-v181.js','/core/single-item-pdf.js','/core/hotfix-v1763.js', '/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
'/core/banquet-client-menu.js', '/core/banquet-client-menu.js',
'/core/training-catalog.js', '/core/training-catalog.js',
'/core/catalog-pricing.js','/core/client-menu.css', '/core/catalog-pricing.js','/core/client-menu.css',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,169 +0,0 @@
-- Caterium v17.9.1
-- Public owners may register without a promo code. No-promo workspaces are
-- permanently limited to the Basic plan. A valid promo keeps the existing
-- server-enforced trial plan and duration.
begin;
create or replace function public.caterium_public_signup_policy()
returns jsonb
language sql
stable
security definer
set search_path = public
as $function$
select jsonb_build_object(
'promo_optional', true,
'default_plan', 'basic'
);
$function$;
create or replace function public.sun_create_workspace(p_name text default 'Новая компания'::text)
returns uuid
language plpgsql
security definer
set search_path = public, auth, pg_temp
as $function$
declare
v_user uuid:=auth.uid();
v_workspace uuid;
v_name text:='Новая компания';
v_email text;
v_profile jsonb;
v_storage jsonb;
v_code text;
v_has_promo boolean:=false;
v_promo public.caterium_trial_promos%rowtype;
v_trial_end timestamptz;
begin
if v_user is null then raise exception 'Authentication required'; end if;
if not exists(select 1 from auth.users where id=v_user and email_confirmed_at is not null) then
raise exception 'Подтвердите email';
end if;
if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
raise exception 'Аккаунт уже относится к компании';
end if;
select lower(email),public.caterium_normalize_trial_code(raw_user_meta_data->>'promo_code')
into v_email,v_code
from auth.users
where id=v_user;
v_has_promo:=coalesce(v_code,'')<>'';
if v_has_promo then
if exists(select 1 from public.caterium_trial_redemptions where user_id=v_user) then
raise exception 'Пробный период для этого аккаунта уже использован';
end if;
select * into v_promo
from public.caterium_trial_promos
where code=v_code
for update;
if not found then raise exception 'Промокод не найден'; end if;
if not v_promo.is_active then raise exception 'Промокод отключён'; end if;
if v_promo.valid_until is not null and v_promo.valid_until<=now() then raise exception 'Срок действия промокода истёк'; end if;
if v_promo.use_count>=v_promo.max_uses then raise exception 'Промокод уже использован'; end if;
if v_promo.client_email is not null and lower(v_promo.client_email)<>v_email then raise exception 'Промокод предназначен для другого email'; end if;
v_trial_end:=now()+make_interval(days=>v_promo.trial_days);
end if;
insert into public.sun_workspaces(name,created_by)
values(v_name,v_user)
returning id into v_workspace;
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions)
values(
v_workspace,
v_user,
'admin',
coalesce((select raw_user_meta_data->>'name' from auth.users where id=v_user),split_part(coalesce(v_email,'Администратор'),'@',1)),
true,
public.sun_role_default_permissions('admin')
);
v_profile:=jsonb_build_object(
'name','','shortName','','logo','','tagline','','city','','phone','','email',coalesce(v_email,''),
'website','','address','','legalName','','inn','','kpp','','ogrn','','legalAddress','',
'bank','','bik','','account','','corrAccount','','legacyLocked',false
);
v_storage:=jsonb_build_object('sunCompanyProfileV1',v_profile::text);
insert into public.sun_app_state(workspace_id,payload,client_id)
values(v_workspace,jsonb_build_object('format','sun-cloud-v2','version',2,'storage',v_storage),'bootstrap')
on conflict(workspace_id) do nothing;
if v_has_promo then
insert into public.sun_workspace_subscriptions(
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
)
values(
v_workspace,v_promo.plan_id,'trialing',now(),v_trial_end,null,null,v_trial_end+interval '7 days','promo_trial','Trial by promo '||v_promo.code
)
on conflict(workspace_id) do update
set plan_id=excluded.plan_id,
status=excluded.status,
trial_started_at=excluded.trial_started_at,
trial_ends_at=excluded.trial_ends_at,
current_period_start=null,
current_period_end=null,
grace_until=excluded.grace_until,
source=excluded.source,
note=excluded.note,
updated_at=now();
insert into public.caterium_trial_redemptions(promo_id,workspace_id,user_id,email,trial_ends_at)
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end);
update public.caterium_trial_promos
set use_count=use_count+1,updated_at=now()
where id=v_promo.id;
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
values(
v_user,'trial_promo.redeem',v_workspace,v_user,
jsonb_build_object('promo_id',v_promo.id,'code',v_promo.code,'trial_days',v_promo.trial_days,'plan',v_promo.plan_id)
);
else
insert into public.sun_workspace_subscriptions(
workspace_id,plan_id,status,trial_started_at,trial_ends_at,current_period_start,current_period_end,grace_until,source,note
)
values(
v_workspace,'basic','active',null,null,now(),null,null,'public_signup_basic','Basic access without promo'
)
on conflict(workspace_id) do update
set plan_id='basic',
status='active',
trial_started_at=null,
trial_ends_at=null,
current_period_start=now(),
current_period_end=null,
grace_until=null,
source='public_signup_basic',
note='Basic access without promo',
updated_at=now();
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details)
values(
v_user,'public_signup.basic',v_workspace,v_user,
jsonb_build_object('plan','basic','promo',false)
);
end if;
update auth.users
set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name'
where id=v_user;
return v_workspace;
end;
$function$;
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;
revoke all on function public.sun_create_workspace(text) from public,anon;
grant execute on function public.sun_create_workspace(text) to authenticated,service_role;
commit;

View File

@ -1,18 +0,0 @@
-- Caterium v17.9.1
-- The public signup policy returns constants only and does not need elevated privileges.
create or replace function public.caterium_public_signup_policy()
returns jsonb
language sql
stable
security invoker
set search_path = public
as $function$
select jsonb_build_object(
'promo_optional', true,
'default_plan', 'basic'
);
$function$;
revoke all on function public.caterium_public_signup_policy() from public,anon,authenticated;
grant execute on function public.caterium_public_signup_policy() to anon,authenticated;

View File

@ -1,177 +0,0 @@
-- Server-side, tamper-proof audit trail for order changes.
-- The owner needs to see when an employee with orders.* permissions
-- created, edited or deleted an order, on any device, at any time —
-- not just in a local per-browser history that an employee could
-- clear themselves. This writes from inside sun_save_app_state,
-- which already detects created/deleted/edited orders server-side
-- for permission checks, so the log can't be spoofed or skipped by
-- the client and needs no extra client-side call.
create table if not exists public.sun_order_audit_log (
id bigint generated always as identity primary key,
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
order_id text not null,
action text not null check (action in ('create','update','delete')),
actor_id uuid references auth.users(id) on delete set null,
actor_name text not null default '',
actor_role text not null default '',
summary text not null default '',
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists sun_order_audit_log_workspace_idx
on public.sun_order_audit_log(workspace_id, created_at desc);
-- Only the security-definer functions below (running as their owner,
-- which bypasses RLS) ever write here. No insert/update/delete policy
-- is granted to end users, so an employee cannot edit or clear their
-- own trail the way the old local-only history could be cleared.
alter table public.sun_order_audit_log enable row level security;
revoke all on public.sun_order_audit_log from public, anon, authenticated;
create or replace function public.sun_list_order_audit(p_workspace uuid, p_limit integer default 200)
returns table(
id bigint,
order_id text,
action text,
actor_name text,
actor_role text,
summary text,
details jsonb,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if coalesce(public.sun_member_role(p_workspace),'') <> 'admin' then
raise exception 'Administrator required';
end if;
return query
select l.id, l.order_id, l.action, l.actor_name, l.actor_role, l.summary, l.details, l.created_at
from public.sun_order_audit_log l
where l.workspace_id = p_workspace
order by l.created_at desc
limit least(greatest(coalesce(p_limit,200),1),1000);
end;
$$;
revoke all on function public.sun_list_order_audit(uuid,integer) from public, anon;
grant execute on function public.sun_list_order_audit(uuid,integer) to authenticated;
create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text)
returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text)
language plpgsql
security definer
set search_path='public'
as $$
declare
v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb;
v_old_storage jsonb;
v_new_storage jsonb;
v_final_storage jsonb;
v_final_payload jsonb;
v_key text;
v_perm text;
v_feature text;
v_old_orders jsonb;
v_new_orders jsonb;
v_create boolean;
v_delete boolean;
v_edit boolean;
v_saved public.sun_app_state%rowtype;
v_actor_name text;
v_actor_role text;
v_row jsonb;
begin
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if;
if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if;
select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace;
if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if;
v_old_storage := coalesce(v_old->'storage','{}'::jsonb);
v_new_storage := coalesce(p_payload->'storage','{}'::jsonb);
v_final_storage := v_old_storage;
for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop
if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if;
if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if;
if v_key='sunOrders' then
if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if;
v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb);
v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb);
if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if;
select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create;
select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete;
select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit;
if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if;
if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if;
if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if;
if v_create or v_edit or v_delete then
select m.display_name, m.role into v_actor_name, v_actor_role
from public.sun_workspace_members m where m.workspace_id=p_workspace and m.user_id=auth.uid();
if coalesce(trim(v_actor_name),'')='' then
select u.email into v_actor_name from auth.users u where u.id=auth.uid();
end if;
v_actor_name := coalesce(nullif(trim(v_actor_name),''),'Сотрудник');
v_actor_role := coalesce(v_actor_role,'');
for v_row in select n from jsonb_array_elements(v_new_orders) n
where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id')
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'create', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
end loop;
for v_row in select o from jsonb_array_elements(v_old_orders) o
where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=(o)->>'id')
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'delete', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total'));
end loop;
for v_row in select n from jsonb_array_elements(v_new_orders) n
join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=(n)->>'id' limit 1) x on true
where x.o<>n
loop
insert into public.sun_order_audit_log(workspace_id,order_id,action,actor_id,actor_name,actor_role,summary,details)
values (p_workspace, coalesce(v_row->>'id',''), 'update', auth.uid(), v_actor_name, v_actor_role,
format('№%s · %s', coalesce(v_row->>'id','?'), coalesce(nullif(v_row->>'event',''),'Заказ')),
jsonb_build_object('event',v_row->'event','date',v_row->'date','status',v_row->'status','total',v_row->'total','prepayment',v_row->'prepayment'));
end loop;
end if;
else
v_feature := public.sun_feature_for_storage_key(v_key);
if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if;
v_perm := public.sun_required_write_permission(v_key);
if v_perm='_member' then null;
elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key;
end if;
end if;
if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key);
else v_final_storage := v_final_storage - v_key; end if;
end loop;
v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage);
insert into public.sun_app_state as app_state(workspace_id,payload,client_id)
values (p_workspace,v_final_payload,p_client_id)
on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id
returning app_state.* into v_saved;
insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id);
return query select * from public.sun_fetch_app_state(p_workspace);
end;
$$;
revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon;
grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated;
notify pgrst, 'reload schema';

View File

@ -3,7 +3,6 @@ import fs from 'node:fs';
const auth=fs.readFileSync('public/core/auth-security-v1774.js','utf8'); const auth=fs.readFileSync('public/core/auth-security-v1774.js','utf8');
const perf=fs.readFileSync('public/core/performance.js','utf8'); const perf=fs.readFileSync('public/core/performance.js','utf8');
const migration=fs.readFileSync('ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql','utf8'); const migration=fs.readFileSync('ops/sql/SUPABASE-V17.7.4-AUTH-EMAIL-VERIFICATION.sql','utf8');
const basicSignup=fs.readFileSync('ops/sql/SUPABASE-V17.9.1-PUBLIC-BASIC-SIGNUP.sql','utf8');
let bad=0; let bad=0;
const check=(ok,msg)=>{console.log(`${ok?'OK':'FAIL'}: ${msg}`);if(!ok)bad++;}; const check=(ok,msg)=>{console.log(`${ok?'OK':'FAIL'}: ${msg}`);if(!ok)bad++;};
@ -12,13 +11,6 @@ check(auth.includes('showPublicConfirmation')&&auth.includes('Подтверди
check(auth.includes('showInviteConfirmation'),'invite signup waits for email confirmation'); check(auth.includes('showInviteConfirmation'),'invite signup waits for email confirmation');
check(!auth.includes("registration_source:'caterium_public_signup'")&&!auth.includes("registration_source:'caterium_invite_signup'"),'security guard does not trust client-controlled registration_source'); check(!auth.includes("registration_source:'caterium_public_signup'")&&!auth.includes("registration_source:'caterium_invite_signup'"),'security guard does not trust client-controlled registration_source');
check(auth.includes("result.data?.session")&&auth.includes('signOutUnsafeSession'),'guard fails closed if server still auto-confirms'); check(auth.includes("result.data?.session")&&auth.includes('signOutUnsafeSession'),'guard fails closed if server still auto-confirms');
check(auth.includes('Промокод — необязательно')&&auth.includes('caterium_public_signup_policy'),'registration supports an optional promo only after the server policy confirms it');
check(auth.includes("return {valid:true,plan:'basic',withoutPromo:true}"),'no-promo signup resolves to Basic in the client guard');
check(auth.includes('signupPolicyPromise')&&auth.includes('signupPolicy.checked&&!force'),'public signup policy lookup is cached and single-flight');
check(auth.includes("pending?.email")&&!auth.includes("!pending?.promoCode"),'owner onboarding no longer requires a promo code');
check(basicSignup.includes("'basic','active'")&&basicSignup.includes("'public_signup_basic'"),'server assigns permanent active Basic access to no-promo owners');
check(basicSignup.includes("if v_has_promo then")&&basicSignup.includes("'promo_trial'"),'valid promo path still assigns the promo plan and trial period');
check(basicSignup.includes('caterium_public_signup_policy')&&basicSignup.includes("'promo_optional', true"),'server advertises optional promo capability only with the matching migration');
check(perf.includes('loadAuthSecurity')&&perf.includes('auth-security-v1774.js'),'auth security guard is loaded by performance core'); check(perf.includes('loadAuthSecurity')&&perf.includes('auth-security-v1774.js'),'auth security guard is loaded by performance core');
check(migration.includes('drop trigger if exists caterium_autoconfirm_signup_v25 on auth.users'),'autoconfirm users trigger is removed'); check(migration.includes('drop trigger if exists caterium_autoconfirm_signup_v25 on auth.users'),'autoconfirm users trigger is removed');
check(migration.includes('drop trigger if exists caterium_autoverify_identity_v25 on auth.identities'),'autoverify identity trigger is removed'); check(migration.includes('drop trigger if exists caterium_autoverify_identity_v25 on auth.identities'),'autoverify identity trigger is removed');

View File

@ -9,18 +9,12 @@ const pkg=JSON.parse(readRoot('package.json'));
const lock=JSON.parse(readRoot('package-lock.json')); const lock=JSON.parse(readRoot('package-lock.json'));
const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json')); const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json'));
check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded'); check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded');
check(fs.existsSync(path.join(pub,'core/single-item-pdf.js')),'single item PDF module exists');
const singleItemPdf=read('core/single-item-pdf.js');
check(index.includes('core/single-item-pdf.js?v=20260922-single-item-pdf-v1'),'single item PDF module is loaded after the app runtime');
check(singleItemPdf.includes('sunSingleItemPdfButton')&&singleItemPdf.includes('PDF бокса'),'saved catalog item exposes a one-item PDF action');
check(singleItemPdf.includes('SunPdfEngine.fromJpegs([page])')&&singleItemPdf.includes('heroH=1110'),'single item export builds one A4 PDF page with a large photo layout');
check(singleItemPdf.includes('item.weight')&&singleItemPdf.includes('item.pieces')&&singleItemPdf.includes('composition(item)'),'single item PDF includes weight, piece count and composition');
check(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present'); check(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present');
check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent'); check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent');
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); check(sw.includes('v110-20260918-ui-stability')&&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-ui-stability')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(index.includes('20260918-ui-stability')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
@ -45,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production'); check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v111-20260918-ui-stability'),'release manifest points to current PWA cache'); check(String(releaseManifest.pwaCache||'').includes('v110-20260918-ui-stability'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
@ -72,7 +66,7 @@ check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.include
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
check(pkg.version==='17.7.3','package version is v17.7.3'); check(pkg.version==='17.7.3','package version is v17.7.3');
check(index.includes('20260918-ui-stability'),'index cache bust is v17.7.3'); check(index.includes('20260918-ui-stability'),'index cache bust is v17.7.3');
check(sw.includes('20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(sw.includes('v110-20260918-ui-stability')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');

View File

@ -44,11 +44,7 @@ test('training is opt-in in an existing paid profile, adds complete examples, hi
test('TTKs, trial order and preparation keep their complete records after hiding and restore without resetting edits',async({page})=>{ test('TTKs, trial order and preparation keep their complete records after hiding and restore without resetting edits',async({page})=>{
await boot(page);await toggle(page,true); await boot(page);await toggle(page,true);
await expect(page.locator('#ctTrialDemo')).toBeVisible(); await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();await expect(page.locator('#ctTrialDemo')).toBeVisible();
// The order screen only shows a one-line notice; its link leads back to the switch and the guide.
await nav(page).getByRole('button',{name:'Новый заказ',exact:true}).click();
await expect(page.locator('#ctTrainingNotice')).toContainText('Включён учебный режим');await expect(page.locator('#ctTrialDemo')).toBeHidden();
await page.locator('#ctTrainingNotice button').click();await expect(page.locator('#ctTrialDemo')).toBeVisible();await expect(setting(page)).toBeFocused();
await page.evaluate(()=>{window.ctGuideEvents=[];const card=document.getElementById('ctTrialDemo');new MutationObserver(rs=>{ctGuideEvents.push(...rs.map(r=>({type:r.type,attribute:r.attributeName,open:card.querySelector('details')?.open,signature:card.dataset.signature})));}).observe(card,{childList:true,subtree:true,attributes:true,attributeFilter:['open','data-signature']});}); await page.evaluate(()=>{window.ctGuideEvents=[];const card=document.getElementById('ctTrialDemo');new MutationObserver(rs=>{ctGuideEvents.push(...rs.map(r=>({type:r.type,attribute:r.attributeName,open:card.querySelector('details')?.open,signature:card.dataset.signature})));}).observe(card,{childList:true,subtree:true,attributes:true,attributeFilter:['open','data-signature']});});
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click(); await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click();
await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽'); await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');

View File

@ -26,15 +26,10 @@ async function ready(page){
for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario,trainingCatalog:{workspaceId:'test',profiles:{test:true}}}}))localStorage.setItem(key,JSON.stringify(value)); for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario,trainingCatalog:{workspaceId:'test',profiles:{test:true}}}}))localStorage.setItem(key,JSON.stringify(value));
boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render(); boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render();
},completeDemo); },completeDemo);
await expect(page.locator('#ctTrainingNotice')).toBeVisible();
}
// The trial guide (order, stock, TTK) lives in Settings; the order screen only shows a notice.
async function guide(page){
await page.locator('header nav').getByRole('button',{name:'Настройки',exact:true}).click();
await expect(page.locator('#ctTrialDemo')).toBeVisible(); await expect(page.locator('#ctTrialDemo')).toBeVisible();
} }
test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{ test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{
await ready(page);await guide(page); await ready(page);
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click(); await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();
await expect(page.locator('#ctDemoTtk')).toBeVisible();await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await expect(page.locator('#ctDemoTtk')).toContainText('Брутто'); await expect(page.locator('#ctDemoTtk')).toBeVisible();await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');
expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true); expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true);