Compare commits

...

86 Commits

Author SHA1 Message Date
pavlov346346-source
8f5fff421a ci: add Gitea Actions workflow mirroring GitHub qa.yml
Some checks failed
Caterium QA / qa (push) Failing after 8m6s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:33:13 +03:00
pavlov346346-source
ab6d41ba0e feat: server-side order audit log for company owners
Some checks failed
Caterium QA / qa (push) Has been cancelled
Owners/admins can now see, on the Аккаунт settings tab, a
tamper-proof journal of who created, edited or deleted each order
and when — including employees who have orders.* permissions. It is
written from inside sun_save_app_state itself (which already
diffs orders server-side for permission checks), so it can't be
spoofed or wiped by the client, unlike the old per-browser
'История изменений' list which only covered the current device and
had a 'Clear history' button anyone could press.

- New table public.sun_order_audit_log (workspace, order id, action,
  actor, summary, details), locked down to security-definer writes
  only — no client insert/update/delete policy exists.
- New RPC sun_list_order_audit(workspace, limit), admin-only.
- New settings card 'Журнал заказов' reading it, admin-only,
  classified into the existing Аккаунт settings tab.
- Verified end-to-end against a local PGlite instance: create/edit/
  delete each produce one correctly-attributed row, and a non-admin
  member is denied read access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:56:39 +03:00
pavlov346346-source
b25a2e0d0c fix: move payment colors into Оформление tab, unify reference-list cards
- Order payment colors settings now live under the Оформление
  (appearance) settings tab instead of Заказы, matching the request
  to group interface/appearance-related settings together.
- Client sources card is now full-width (wide) like the event types
  card, so both reference-list cards render with the same row width
  instead of one looking noticeably shorter than the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:48:20 +03:00
pavlov346346-source
3aa268907e fix: always show the per-guest price in client banquet menu
Per-item prices next to each dish were already removed; the
per-guest price footer is no longer an optional checkbox — it now
always renders, since it should never be hidden, only the per-dish
prices were meant to be removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:00:52 +03:00
pavlov346346-source
ab2bc5f47e Verify single item PDF release wiring 2026-09-22 22:58:19 +08:00
pavlov346346-source
6561a4e43e Check single item PDF module syntax 2026-09-22 22:58:14 +08:00
pavlov346346-source
41ebe89f8f Verify single item PDF module in production 2026-09-22 22:57:44 +08:00
pavlov346346-source
dee3252dc8 Cache single item PDF module 2026-09-22 22:57:41 +08:00
pavlov346346-source
fb8f7c351a Load single item PDF export 2026-09-22 22:57:37 +08:00
pavlov346346-source
4265a88c34 Add single catalog item PDF export 2026-09-22 22:57:23 +08:00
pavlov346346-source
923ea64650 feat: hide per-item prices in client banquet menu, add 3 layout themes
The one-page banquet menu PDF for clients no longer prints a price
next to each dish, and the footer no longer shows a grand total —
only the price per guest is shown, so the document can't be read
as a per-dish price list. Added a theme selector (Золото/Ночь/
Минимал) with three visually distinct color/typography treatments
for the same one-page layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:49:36 +03:00
pavlov346346-source
a6bd7f911b fix: remove duplicate delivery/total line in order cost summary
order-enhancements-v1775.js appended its own Доставка/Итого
paragraphs to .order-summary even after app-runtime.js's
ensureDiscountUI() already renders a full breakdown (positions,
discount, promo, delivery, total, prepayment, balance) — resulting
in a duplicate Доставка line at the bottom of the order details
cost panel. Now it skips the extra append when the richer summary
UI is present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:32:09 +03:00
pavlov346346-source
40b5c146c2 feat: add photos for the 34 training-catalog items that had none
The banquet menu (18 dishes) and extras/supplies/delivery items (16)
in the training catalog were seeded with photo:'', which the catalog
tiles render as a plain red placeholder square. Sourced a real photo
for each item (mostly Pexels, free license, picked and visually
reviewed one by one to avoid mismatches -- several first search hits
were wrong: a live chicken for "chicken caesar salad", a person in
frame, branded bottles/cups) and two AI-generated (banquet-caprese,
banquet-roastbeef). Converted to square 1024x1024 WebP with sharp.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:00:45 +03:00
pavlov346346-source
72d8ca6c1a Test cached signup policy lookup 2026-09-22 19:25:04 +08:00
pavlov346346-source
8e0da59b09 Cache public signup policy lookup 2026-09-22 19:24:45 +08:00
pavlov346346-source
646edfefde Track public signup policy security migration 2026-09-22 19:06:05 +08:00
pavlov346346-source
403676ab69 Harden public signup policy function 2026-09-22 19:06:02 +08:00
pavlov346346-source
a7252e4065 Remove obsolete database-secret migration workflow 2026-09-22 19:04:39 +08:00
pavlov346346-source
16308a35c4 Remove superseded Basic signup migration timestamp 2026-09-22 19:04:36 +08:00
pavlov346346-source
e6376c80d4 Align Basic signup migration with Supabase history 2026-09-22 19:04:33 +08:00
pavlov346346-source
7c05b63846 Support existing database secret names for Basic signup migration 2026-09-22 18:45:27 +08:00
pavlov346346-source
7ed7fdad79 Apply Basic signup migration safely 2026-09-22 18:43:52 +08:00
pavlov346346-source
941a568f4c Verify auth guard in production deploy 2026-09-22 18:34:19 +08:00
pavlov346346-source
111760f9f2 Load Basic signup auth release 2026-09-22 18:34:15 +08:00
pavlov346346-source
ae44b3eb6c Refresh auth module for Basic signup 2026-09-22 18:34:09 +08:00
pavlov346346-source
ca402418c4 Test Basic signup without promo 2026-09-22 18:33:57 +08:00
pavlov346346-source
b103d4aa24 Add public Basic signup migration 2026-09-22 18:33:24 +08:00
pavlov346346-source
c7aa7371d0 Add server policy for Basic signup without promo 2026-09-22 18:33:21 +08:00
pavlov346346-source
3d712eb19a Allow Basic registration without promo code 2026-09-22 18:32:47 +08:00
pavlov346346-source
10415fd539 Fix order deletion, line removal and Telegram provenance in documents 2026-09-22 16:05:38 +08:00
pavlov346346-source
32d6eb86c8 feat: replace the large training banner with a one-line notice
The big "Учебный каталог включён" card with its buttons sat on top of the
order screen. While training mode is on, the order screen now shows only
"Включён учебный режим · отключить в настройках"; the link opens Settings
at the training switch. The guide (trial order, stock, purchasing, TTK)
moves into the "Обучение и знакомство" settings card, so nothing is lost.
The invitation card for companies with an empty catalog is unchanged.

Specs updated for the new placement, plus a check that the notice link
lands on (and focuses) the switch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 17:36:00 +03:00
pavlov346346-source
2e6c0e3939 fix: stop re-sending every client on each start; drop rejected error-log records
Some checks failed
Caterium QA / qa (push) Failing after 8m8s
Clients: every page load re-sent all clients (72 requests for 18 clients)
because pushAll ran from several startup events at once, never skipped
unchanged clients, and overlapping saves of one client read the same stale
version, so ~40% ended in 409 conflicts. The payload also carried a fresh
updatedAt, so even identical re-sends bumped the server version and wrote a
change event, which made other devices' next save conflict too.

- Remember what the server holds per client (content fingerprint, scoped to
  the workspace) and skip unchanged clients; seed it from the server
  snapshot so a device that is already in sync sends nothing.
- Serialise saves per client and make pushAll single-flight.
- Load the server snapshot before the startup push instead of racing it.
- Drop the volatile updatedAt from the payload (server keeps updated_at).

Error log: a record the server refuses (Access denied for a workspace the
user is not in) stayed in the IndexedDB queue forever, was re-sent on every
flush and could block newer records behind it. Records from another
workspace are now dropped, others after 3 attempts.

Adds tests/client-sync-v1780.mjs (fake server enforcing the SQL conflict
rule; fails on the old module) to test:static, and bumps the cache-busting
versions of performance.js / app-runtime.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 16:48:16 +03:00
pavlov346346-source
5e72b23161
Correct support mailbox to support@caterium.ru (#42)
Apply the user's explicit address correction to the PHP recipient, Help links, contact form and tests. Refresh the form URL and PWA cache. Exact-recipient PHP tests and support/Help browser tests passed in isolated run 35575721587. No real mail sent, delivery not claimed. Keep standard main QA and production publication gates unchanged; no auth, database or unrelated feature changes.
2026-09-21 11:04:06 +03:00
pavlov346346-source
c3cb44419b
Add human support form with fixed email recipient (#40)
Add Help contact form and PHP mail endpoint for support@katerion.ru, with validated Reply-To, explicit diagnostics consent, CSRF/origin checks, hashed rate limits and duplicate protection. Preserve drafts on error and avoid serializing customer data or SDK internals. Full PR QA passed in 35559317001; isolated PHP and 30 browser cases passed in 35559179372. Standard production gates unchanged. Publication checks do not send real mail; inbox receipt remains unverified. No training photo assets or unfinished training lifecycle changes.
2026-09-21 07:12:26 +03:00
pavlov346346-source
298b29cd52
Expose developer promo creation with three subscription plans (#39)
Load the missing promo module, add a prominent creation button and place Promos beside Overview. Use existing AAL2-guarded server RPCs with selected plan, subscription duration and code validity; preserve form input, honest clipboard feedback and scope async responses. Full PR QA succeeded in run 35547013076. No live data, SQL, MFA or unfinished training branch changes. Preserve normal main QA and exact-asset/browser publication verification.
2026-09-21 03:30:21 +03:00
pavlov346346-source
8b25918e30
Fix reopening incomplete developer MFA setup (#38)
Recover only unverified Caterium Developer TOTP factors from the full factor list. Preserve verified MFA, reuse the in-memory QR after closing the dialog, serialize enrollment and retain correct-code/AAL2 checks. Complete pull-request QA passed in run 35518429693. Standard main QA and publication gates remain unchanged.
2026-09-20 20:08:46 +03:00
pavlov346346-source
1e644e6dfb
Fix employee sync and expose mobile profile/logout (#37)
Keep notification-read state personal to user/company, preserve server read-only sections during staff synchronization, and respect separate order-create/edit/delete rights. Add visible mobile header session actions and a sticky logout that survives profile RPC failure; scope asynchronous profile/branding to user and workspace. Targeted browser suites and isolated SQL recovery tests passed in run 35506145405, iPhone screenshots reviewed. Full main QA remains required before production promotion. Workspace branding RPC migration is included but has NOT been applied to production Supabase; older servers retain safe owner-only fallback. No live membership/business-data repair is claimed without identifying the reported employee.
2026-09-20 13:55:56 +03:00
pavlov346346-source
bc3f186992
One-page banquet menu for the client (#36)
Add a single-page A4 preview and PDF for all selected banquet dishes or a ready-menu selection, with company branding and optional costs. Preserve draft/order state and saved line prices, enforce access and tenant scope, and fail clearly rather than clipping oversized menus. Nineteen targeted browser scenarios passed twice and the generated iPhone PDF was visually checked. Full main QA must pass before the existing automatic production promotion; production verification includes the new renderer and real-asset client menu export.
2026-09-20 12:54:42 +03:00
pavlov346346-source
eab678adbe fix: harden inline handlers against id injection, fix stale version label
Some checks failed
Caterium QA / qa (push) Has been cancelled
Inline handlers built as onclick="fn('${esc(id)}')" were injectable:
esc() turns ' into &#39;, which the browser decodes back to ' before
the JS runs, so an id like x');alert(1);// broke out of the string.
Ids can come from a restored backup file or a synced catalog. Add
SunSafe.jsArg (JSON.stringify + HTML escape) and use it in all 23
handlers in app-runtime.js and index.html. Verified in a browser: a
payload id is passed through as a plain string and nothing executes.

Also replace the Settings version label that still showed
v17.6.0 · 2026.09.07, and bump the cache-busting version of the two
changed scripts (sun-safe.js, app-runtime.js).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 12:28:35 +03:00
pavlov346346-source
716a79c58d
Optional per-profile training catalog with reversible example visibility (#35)
Add Settings > Учебный каталог, off by default and available on ordinary writable profiles. Load ready example boxes, photos, TTKs and linked sample inventory additively; preserve own data, saved orders and edited examples when hiding or re-enabling. Respect company/profile scope, read-only permissions, failed downloads and tenant changes. Preserve the opened recipe guide across real catalog refreshes on iPhone. Integrated feature checks and full pull-request QA passed. Standard main QA and exact-asset production UI verification remain in place.
2026-09-20 12:18:40 +03:00
pavlov346346-source
ab32199bd6 Retry transient npm audit service failures without weakening the security gate 2026-09-19 20:18:06 +03:00
pavlov346346-source
5ce7d8d353
Mobile menu editor above boxes with a matching return arrow (#34)
On phones place the existing menu editor above the catalog and add the same return-to-top control used by New Order. Preserve form nodes, unsaved values and active input focus; keep desktop/tablet layout and read-only permissions. Full QA passed, including iPhone WebKit and existing promotion focus regression. Extend real-asset production verification with a backend-blocked mobile-menu scenario.
2026-09-19 20:02:43 +03:00
pavlov346346-source
ee70c66ed8
Fix iPhone offer template flicker (#33)
Avoid rebuilding the offer template picker during the five-second maintenance pass unless the selected template or template list actually changed. Includes a regression test for repeated maintenance cycles.
2026-09-19 19:28:57 +03:00
pavlov346346-source
b99bb30aa1
Merge pull request #32 from pavlov346346-source/feature/support-bot
Add AI support assistant to the Help dialog
2026-09-19 18:42:50 +03:00
pavlo
d7d6f6ff38 Add AI support assistant to the Help dialog
The Поддержка tab opens an assistant that answers how-to questions from
the handbook. The chat is a sandboxed iframe on the assistant's own
origin and is only loaded after an explicit click, so Help and its search
stay local and no third-party script runs inside the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:27:47 +03:00
pavlov346346-source
16400425e4 Capture promotion screenshots without holding a row replaced by the normal save refresh 2026-09-19 09:51:41 +03:00
pavlov346346-source
424bf8ce87
Compact clients and explicit timed menu promotions (#31)
Add compact accessible client summaries and menu discounts in percent or rubles with explicit durations. Preserve ordinary catalog prices and order line snapshots; derive current prices and expire promotions automatically without background writes. Keep fractional prices, legacy sale compatibility, and regression coverage. Full pull-request QA passed. Publication remains gated by full main QA and byte-for-byte production asset and UI verification.
2026-09-19 09:34:50 +03:00
pavlov346346-source
b6e4a1aaad Check square icon mask within the shared 24px sidebar flex slot 2026-09-19 08:14:52 +03:00
pavlov346346-source
9f6ed42b0a Verify changed production CSS and rendered UI after QA-approved promotion 2026-09-19 08:07:02 +03:00
pavlov346346-source
2f7257f823 Verify published Help icon and quiet loading without touching customer accounts 2026-09-19 08:06:27 +03:00
pavlov346346-source
262711daab Complete full-app network fixture and preserve workspace across startup 2026-09-19 08:05:27 +03:00
pavlov346346-source
006ba12f98 Test Help against responsive native sidebar geometry in all browser engines 2026-09-19 08:04:32 +03:00
pavlov346346-source
15ab908836
Match Help icon to native sidebar styling (#30)
Add a question-circle SVG mask to Help using the existing sidebar pseudo-element. Preserve shared icon geometry, theme colors and navigation behavior. Add full-app icon regression and correct the previous loading-recovery fixture. Production promotion remains gated by full main QA.
2026-09-19 07:52:30 +03:00
pavlov346346-source
b2bcb0f4db
Make workspace loading a calm progress-only screen (#29)
* Make workspace loading a calm progress-only screen

* Cover quiet workspace loading and error recovery on desktop and mobile

* Run workspace loading regressions in desktop, mobile and WebKit QA
2026-09-19 06:57:27 +03:00
pavlov346346-source
43219c49a1 Mock both configured proxies in UI integration fixture 2026-09-18 21:23:36 +03:00
pavlov346346-source
4f518b3663 Stabilize settings updates, auth gates and modal lifecycle 2026-09-18 21:15:26 +03:00
pavlov346346-source
47ae8df39c Remove device PIN and fit calendar to mobile screens 2026-09-18 20:43:09 +03:00
pavlov346346-source
45e77958cb Remove Map from the sidebar navigation 2026-09-18 20:27:24 +03:00
pavlov346346-source
be0fd9aee7 Hide completed import catalog entries without losing order history 2026-09-18 20:17:30 +03:00
pavlov346346-source
11e7b4bd56 Refine six client proposal layouts and transparent company logos 2026-09-18 19:55:04 +03:00
pavlov346346-source
cdb43c1653 Preserve JSON content negotiation through both proxy routes 2026-09-18 19:25:00 +03:00
pavlov346346-source
7807bf4f8f Poll workspace revision through the authorized state RPC 2026-09-18 19:17:03 +03:00
pavlov346346-source
71bc56b0cd Route cloud access through Caterium and vendor login SDK 2026-09-18 19:07:47 +03:00
pavlov346346-source
ff7fe90ccf Add searchable user guide and support area with future assistant design 2026-09-18 18:43:27 +03:00
pavlov346346-source
09bfb0fed8 Fix Safari login request bodies and raw storage uploads 2026-09-18 18:13:00 +03:00
pavlov346346-source
4d4cca2e32 Respect confirmed payment for imported orders with unknown totals 2026-09-18 16:29:44 +03:00
pavlov346346-source
f628a4a27e Remove empty mobile order gap above payment fields 2026-09-18 10:43:08 +03:00
pavlov346346-source
66decb03a5 Show mobile order totals above catalog and add return-to-top button 2026-09-18 10:36:06 +03:00
pavlov346346-source
dac1a1de72 Fill empty trial tabs with premium, drinks, tableware, extras and delivery 2026-09-18 08:26:21 +03:00
pavlov346346-source
18ff2b9c30 Add 18 trial banquet dishes with portion TTKs and additive stock seed 2026-09-18 08:11:32 +03:00
pavlov346346-source
4fe984b584 fix: remove legacy login flash during session restoration 2026-09-18 05:48:20 +03:00
pavlov346346-source
6472dba7ee fix: bound proposal loading and preserve per-order preview 2026-09-18 05:31:15 +03:00
pavlov346346-source
44d6ec9e81 Cache optimized trial photos for legacy URLs in the installed app 2026-09-18 04:59:16 +03:00
pavlov346346-source
6067c3f570 Fix transient cloud sync timeouts and optimize trial photos 2026-09-18 04:51:29 +03:00
pavlov346346-source
7e5c7c1908 Restore distinct proposal designs and improve PDF typography and pagination 2026-09-18 04:05:36 +03:00
pavlov346346-source
9539374c88 Add isolated trial demo boxes with TTK, stock and purchasing scenario 2026-09-18 03:24:43 +03:00
pavlov346346-source
ff09be6267 Return HTTP 409 for business conflicts to prevent PostgREST retry loops 2026-09-18 02:52:44 +03:00
pavlov346346-source
d4e02cab8b Recover login from empty API responses with one authenticated session 2026-09-18 02:39:30 +03:00
pavlov346346-source
1ae56fb57b Fit nine calendar orders per day and preserve all printed events 2026-09-17 21:25:25 +03:00
pavlov346346-source
2d49563b9d Add banquet packages, dish sections and guest totals 2026-09-17 21:07:54 +03:00
pavlov346346-source
57f5807953 Require account login and isolate all company client caches 2026-09-17 20:40:24 +03:00
pavlov346346-source
c18c49770d Start new companies with empty private catalogs 2026-09-17 20:27:07 +03:00
pavlov346346-source
48ffa51748 Preserve imported order history and saved order pricing 2026-09-17 20:15:32 +03:00
pavlov346346-source
11b2bf84c9 Separate account sidebar identity from company document branding 2026-09-17 19:51:01 +03:00
pavlov346346-source
c31f868c83 Apply saved theme before first paint and prevent startup flicker 2026-09-17 19:16:19 +03:00
pavlov346346-source
da8ff042f7 Restore Caterium schema and switch production to fresh Supabase 2026-09-17 17:30:57 +03:00
246 changed files with 19238 additions and 982 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Preserve the published SDK bytes, including whitespace inside template strings.
public/vendor/supabase-2.112.4.min.js -text -diff

45
.gitea/workflows/qa.yml Normal file
View File

@ -0,0 +1,45 @@
name: Caterium QA
on:
push:
pull_request:
concurrency:
group: caterium-qa-${{ github.ref }}
cancel-in-progress: true
jobs:
qa:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Audit dependencies (retry service errors only)
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
- 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
- run: npm run test:e2e

View File

@ -2,7 +2,7 @@ name: Verify Caterium on Timeweb
on: on:
workflow_run: workflow_run:
workflows: ["Caterium QA"] workflows: ["Caterium QA", "Caterium Direct Production"]
types: [completed] types: [completed]
workflow_dispatch: workflow_dispatch:
@ -18,69 +18,130 @@ jobs:
if: >- if: >-
github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'production') ((github.event.workflow_run.name == 'Caterium QA' &&
github.event.workflow_run.head_branch == 'production') ||
(github.event.workflow_run.name == 'Caterium Direct Production' &&
github.event.workflow_run.head_branch == 'main')))
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25
env: env:
TIMEWEB_BASE_URL: https://app.caterium.ru TIMEWEB_BASE_URL: https://app.caterium.ru
steps: steps:
# A GITHUB_TOKEN push to production does not start another push workflow.
# Observe successful promotion directly and read its published branch.
- name: Checkout tested production revision - name: Checkout tested production revision
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 'production' }} ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.name == 'Caterium QA' && github.event.workflow_run.head_sha || 'production' }}
- name: Wait for Timeweb cron deployment - name: Wait for the exact published assets
timeout-minutes: 18
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
files=(
login_file="public/core/login-signature-v1776.js" index.html
logo_file="public/caterium-mark-light.svg" app-runtime.js
sw_file="public/service-worker.js" core/hotfix-v1763.js
core/trial-promo-developer-v181.js
test -s "$login_file" core/single-item-pdf.js
test -s "$logo_file" core/account-center-v1780.js
test -s "$sw_file" core/auth-security-v1774.js
core/company-branding.js
local_login="$(sha256sum "$login_file" | awk '{print $1}')" core/sun-safe.js
local_logo="$(sha256sum "$logo_file" | awk '{print $1}')" core/login-signature-v1776.js
local_sw="$(sha256sum "$sw_file" | awk '{print $1}')" core/login-signature-v1776.css
core/help-center.js
echo "Expected login sha256: $local_login" core/help-center.css
echo "Expected logo sha256: $local_logo" core/catalog-pricing.js
echo "Expected SW sha256: $local_sw" core/client-menu.css
core/ops-ux-v1762.js
core/mobile-order.js
core/banquet-menu.js
core/banquet-client-menu.js
core/data-layer-v1773.js
core/trial-demo.js
core/training-catalog.js
legacy/bootstrap.js
caterium-mark-light.svg
service-worker.js
)
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
revision="$(git rev-parse HEAD)"
echo "Verifying tested production revision: $revision"
for file in "${files[@]}"; do test -s "public/$file"; done
for attempt in $(seq 1 18); do for attempt in $(seq 1 18); do
stamp="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${attempt}" stamp="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${attempt}"
echo "Attempt $attempt/18" echo "Attempt $attempt/18"
matched=true
if curl -fsSL --connect-timeout 10 --max-time 30 \ : > "$temp_dir/hashes.txt"
"${TIMEWEB_BASE_URL}/core/login-signature-v1776.js?deploy_check=${stamp}" \ for file in "${files[@]}"; do
-o /tmp/caterium-login.js && \ expected="$(sha256sum "public/$file" | awk '{print $1}')"
curl -fsSL --connect-timeout 10 --max-time 30 \ if ! curl -fsSL --connect-timeout 10 --max-time 20 \
"${TIMEWEB_BASE_URL}/caterium-mark-light.svg?deploy_check=${stamp}" \ -H 'Cache-Control: no-cache' \
-o /tmp/caterium-mark.svg && \ "${TIMEWEB_BASE_URL}/${file}?deploy_check=${stamp}" \
curl -fsSL --connect-timeout 10 --max-time 30 \ -o "$temp_dir/asset"; then
"${TIMEWEB_BASE_URL}/service-worker.js?deploy_check=${stamp}" \ echo "Not reachable yet: $file"
-o /tmp/caterium-sw.js; then matched=false
remote_login="$(sha256sum /tmp/caterium-login.js | awk '{print $1}')" break
remote_logo="$(sha256sum /tmp/caterium-mark.svg | awk '{print $1}')" fi
remote_sw="$(sha256sum /tmp/caterium-sw.js | awk '{print $1}')" actual="$(sha256sum "$temp_dir/asset" | awk '{print $1}')"
echo "$file expected=$expected actual=$actual"
echo "Remote login sha256: $remote_login" if [ "$actual" != "$expected" ]; then
echo "Remote logo sha256: $remote_logo" echo "Waiting for updated asset: $file"
echo "Remote SW sha256: $remote_sw" matched=false
break
if [ "$remote_login" = "$local_login" ] && [ "$remote_logo" = "$local_logo" ] && [ "$remote_sw" = "$local_sw" ]; then fi
echo "Timeweb production is current, including refreshed PWA cache." printf '%s %s\n' "$actual" "$file" >> "$temp_dir/hashes.txt"
done
if [ "$matched" = true ]; then
echo "PASS: all ${#files[@]} production assets match the tested revision."
{
echo '## Timeweb publication verified'
echo "Revision: \`$revision\`"
echo
echo 'All checked production assets match byte-for-byte:'
echo '```text'
cat "$temp_dir/hashes.txt"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 0 exit 0
fi fi
else
echo "Timeweb is not reachable yet."
fi
sleep 30 sleep 30
done done
echo '::error::Timeweb did not publish the tested assets within the allotted retries.'
echo "::error::Timeweb did not reach the tested Caterium revision within 9 minutes."
exit 1 exit 1
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Check the published loading screen, Help, clients and promotions
timeout-minutes: 4
run: node tests/production-ui-smoke.mjs
- name: Check the published mobile menu form and return arrow
timeout-minutes: 4
run: node tests/production-mobile-menu.mjs
- name: Check the published optional training catalog
timeout-minutes: 4
run: node tests/production-training-catalog.mjs
- name: Check the published single-page banquet menu
timeout-minutes: 4
run: node tests/production-banquet-client-menu.mjs
- name: Check published employee session and mobile logout
timeout-minutes: 4
run: node tests/production-employee-session.mjs
- name: Check published developer promo entry and plan selection
timeout-minutes: 4
run: node tests/production-promo-entry.mjs
- name: Save production UI verification
if: always()
uses: actions/upload-artifact@v4
with:
name: production-ui-verification
path: production-ui-results/
if-no-files-found: ignore
retention-days: 7

View File

@ -14,7 +14,32 @@ jobs:
with: with:
node-version: 22 node-version: 22
- run: npm ci - run: npm ci
- run: npm audit --audit-level=high - name: Audit dependencies (retry service errors only)
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 - run: npm run check:deploy
- run: npx playwright install --with-deps chromium - 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
- run: npm run test:e2e - run: npm run test:e2e

View File

@ -0,0 +1,73 @@
name: Support form checks
on:
push:
branches: [feature/support-mail-20260921]
paths: [ops/finalize-support-form.py, '.github/workflows/support-form-check.yml']
workflow_run:
workflows: [Verify Caterium on Timeweb]
types: [completed]
permissions:
contents: read
jobs:
test:
if: github.event_name == 'push'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Finalize feature-only source
run: |
set -euo pipefail
test "$GITHUB_REF_NAME" = 'feature/support-mail-20260921'
if [ -f ops/finalize-support-form.py ]; then
python ops/finalize-support-form.py
rm ops/finalize-support-form.py
fi
git diff --check
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: php -l public/api/support.php && php -l public/api/support-lib.php && php tests/support-mail.php
- run: node --check public/core/support-form.js && node --check public/core/help-center.js
- run: npx playwright install --with-deps chromium webkit
- run: npx playwright test --config=tests/playwright.config.mjs tests/support-form.spec.mjs tests/help-center.spec.mjs --reporter=list
- name: Save verified source to feature branch only
run: |
set -euo pipefail
git config user.name 'Caterium support verification'
git config user.email 'caterium-verification@users.noreply.github.com'
git add public/core/support-form.js public/service-worker.js tests/support-form.spec.mjs
git add -u ops/
if ! git diff --cached --quiet; then
git commit -m 'Finalize verified support mail lifecycle and cache update'
git push origin HEAD:feature/support-mail-20260921
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: support-form-test-results
path: test-results/
retention-days: 3
published:
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v4
with:
ref: production
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: node tests/production-support-form.mjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: published-support-form
path: production-ui-results/
retention-days: 7

View File

@ -0,0 +1,9 @@
# Account access and company isolation
Production requires a signed-in account and a company membership. The login form remains available when the backend is unreachable, and an old `sunLocalOnlyModeV1` preference cannot bypass it. Application sections are hidden from the initial HTML until access is resolved.
The emergency local workflow remains implemented. To restore it during an incident, deliberately change `emergencyLocalEnabled` in `public/core/access-policy.js`, bump the release/cache version, run the release checks and deploy. There is no public browser setting that enables this workflow in the current release. This switch does not change server authentication or RLS.
A developer with their own company opens the normal application. Developer tools are selected separately in the sidebar and continue to require MFA. The production developer test company is provisioned empty, independently of the exclusive company catalog and imported customer history.
Client caches are captured, cleared and restored together with orders, catalog photos and company documents. Changing company cancels queued client writes, suppresses rebuilding caches from old runtime arrays, and ignores in-flight responses for the previous workspace. Tests cover switching to an empty company and back, old anonymous-mode preferences, unavailable authentication endpoints, and normal developer entry.

View File

@ -0,0 +1,9 @@
# Banquet menu
Category 6 now has package filters and dish sections, with an independent selection summary. Filters never remove selected dishes from totals. Alternative main courses are mutually exclusive within a package. Quantities and totals scale by the guest count, and applying a selection replaces only banquet lines in the current order. Existing order prices remain intact when a catalog price changes.
Catalog entries carry optional `banquet` metadata (package identifier/name, original package price/grams, main-course choice group and estimate flag). All menu data belongs to the workspace. The public code contains no company menu seed; new companies remain empty. Package names, sections, weight, price and estimate flag are editable in the existing dish editor.
The first company import uses the user-provided image. The per-dish weights and prices are explicitly estimates allocated from each complete menu. One alternative main is counted per guest; every alternative reproduces its package's advertised weight and price. The source's mushrooms and eggplant in the luxury menu are represented as separate dishes. The importer is restricted to the authorized company, creates a backup, appends unique IDs, preserves unrelated catalog entries/orders/customer state, and checks normalized catalog rows and totals.
Banquet dish weights are included in order documents and client-offer food totals. Tests cover selection across filters, changing the main, guest scaling, repeated application without duplicate lines, safe rendering, editing estimates and saving the resulting order.

View File

@ -0,0 +1,7 @@
# Compact calendar days and printing
Month cells keep the existing detailed cards for one to three orders. With four or more orders, each event becomes one line containing time, order number and event name. Payment colors and click-to-open behavior remain available. The first nine events are shown; the existing overflow dialog opens every order for busier days.
The landscape A4 print view fits nine lines inside each fixed-height date cell. Orders beyond nine appear once on a continuation sheet, grouped by date; the cell count points to that sheet. Printing retains the existing omission of financial amounts. Calendar data is unchanged.
Regression coverage checks three/four/nine/eleven orders, opening an overflow order, printed row bounds, complete and unique order IDs across calendar and continuation, and absence of prices in print. PDF rendering was checked separately: nine orders fit on one A4 landscape page; an eleven-order day creates a second page containing its two remaining events.

View File

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

View File

@ -0,0 +1,21 @@
# Recover login when the API proxy returns no data
The API proxy intermittently returned HTTP 200 with an empty HTML response for authentication and workspace membership requests. The same Supabase backend returned a valid session and the existing workspace directly. The former membership loader treated malformed responses like missing workspaces, while an existing login gate kept its loading message. A second authentication client used by the fallback also shared the same session storage key.
The shared transport now validates JSON responses and uses the same backend directly for failed reads and password login. Authorization and the single SDK session are preserved. Only GET/HEAD and explicitly allowlisted read RPCs can be retried, plus password login. Database writes are not replayed. Both channels have bounded waits; permission and credential errors are returned without fallback.
Membership requests for the same account share one in-flight operation. Late responses from a different account are ignored. Missing RPC compatibility is used only for a missing function, not network errors. A failed membership request produces a retry/exit screen and cannot be mistaken for a developer account with no company. Successful login still opens the normal application.
Regression coverage includes empty/invalid proxy responses, identical request bodies and authorization on fallback, non-replayed writes, credential rejection, concurrent membership loads, recovery UI, and a real Supabase SDK login against controlled endpoint responses. The full flow is checked on desktop and mobile without changing production credentials or authentication requirements.
# Server retry loop discovered during production verification
Supabase logs showed PostgREST 14.5 repeatedly executing
`sun_v17_save_client_v1772` with `SUN_CLIENT_CONFLICT expected=1 actual=2`,
SQLSTATE `40001`, exhausting the REST connection pool. Both direct and proxied
workspace reads then timed out. See the [Supabase incident guidance](https://supabase.com/docs/guides/troubleshooting/high-cpu-and-infinite-transaction-retries-when-using-custom-error-codes-in-rpc-functions-77326b).
Migration `20260918000000_conflict_http_status.sql` changes the two application
version-conflict handlers to `PT409`. It preserves function bodies, privileges,
revision checks and data. Existing looping backends must be identified in this
project's logs and `pg_stat_activity`, then terminated individually. The SQL smoke
test verifies both conflicts return `PT409` and stale writes leave data unchanged.

View File

@ -0,0 +1,7 @@
# Preserving imported order history
Imported orders can carry `autoCompletionDisabled: true`. Both server and local automation leave their status, prepayment and balance unchanged, so old dates cannot manufacture full payment. The import archive is a typed workspace storage value (`sunTelegramImportArchiveV1`); reads require both order and customer viewing permissions. The Orders screen exposes searchable original messages and images using text-only DOM rendering and validated image data URLs. Customer records and source files remain outside this repository.
Opening an order now hydrates its delivery and discount controls before reading them back. The late order-pane patch retains shared pricing, and order enhancements use its item subtotal plus delivery once. Saved historical prices and promotion discounts survive open/save.
Validation: deployment checks and database permission tests; 66 browser checks passed, 2 existing skips. A private rehearsal round-tripped the import, confirmed idempotency and payment preservation, and opened/saved every imported order while comparing names, phone numbers, addresses, notes, prices, payment and line counts.

27
docs/RECOVERY-20260917.md Normal file
View File

@ -0,0 +1,27 @@
# Caterium recovery — 17 September 2026
Target: `caterium-fresh`, Supabase reference `usfjwhztqoopzzfmfbis`, region `eu-central-1`. The retired project was deleted; this recovery creates an empty application. No old users, company data, or passwords are imported. The `ai-staff` project is outside this recovery.
## Database
The supplied production archive and Git history did not contain the v17 normalized-state foundation or the complete promo/developer RPC layer. These were reconstructed from the current frontend contracts and the retained SQL. The v17.8 developer panel and v17.9 developer settings scripts were recovered from the local desktop source.
`supabase/migrations/20260917150000_fresh_caterium.sql` is the complete transaction applied to the empty target database. The migration rejects an already initialized Caterium schema. Its 25 inputs, in execution order, are listed in `tests/recovery/manifest.json`; `npm run build:recovery` reproduces the bundle. Further production changes must use a new migration rather than replaying this baseline.
Deployment verification found 26 public tables, RLS enabled on all 26, two Caterium cron jobs, zero workspaces and zero auth users. The migration is recorded in `supabase_migrations.schema_migrations`.
The recovered layer includes revision conflict detection, permission-filtered snapshots, normalized orders/catalog/clients, backup and automation RPCs, subscription onboarding, promo codes and developer aliases. Recovery also fixes ambiguous client upserts, enforces confirmed owner email, and avoids an administrator-only audit helper during ordinary owner onboarding. Developer operations enforce AAL2.
`npm run test:db` uses a disposable local PGlite database with mocked Supabase auth/storage/realtime/cron infrastructure. It exercises owner onboarding, save/read, optimistic conflicts, backups, employee membership, cross-company denial, viewer write denial, MFA restrictions and developer RPCs. It does not replace a real signed-in production acceptance test or verify Supabase email delivery.
## Application and hosting
The app and PHP proxy now target the new project. Only the public publishable key is present in the frontend. Existing browser configuration for the retired backend is archived locally and reset; old local workspace data remains isolated through the existing workspace-switch mechanism. A new service-worker cache and asset version deliver the change to installed clients.
GitHub `main` runs QA and promotes the tested commit to `production`. Timeweb sync deploys `public/` to `~/caterium-app/public_html`. The separately hosted PHP proxy is deployed to `~/public_html/api-proxy/index.php`; preserve a copy in `~/.caterium-deploy/manual-backups` before replacement.
Both Edge Functions are deployed to the new Supabase project: `caterium-create-employee` and `caterium-platform-auth-admin`. JWT verification remains enabled; the platform auth function additionally checks the MFA-protected developer dashboard RPC before privileged operations. Supabase Site URL is `https://app.caterium.ru`, with `https://app.caterium.ru/**` allowed for redirects. Email confirmation remains enabled.
## First use
All old accounts were deleted with the old database. A new owner account must register, confirm its email and set a new password. The first platform administrator and any onboarding promo must be provisioned for the user-confirmed owner identity; there is no universal recovery password or open administrator bootstrap. A real login, email delivery, and company onboarding must be checked once that identity is available.

View File

@ -0,0 +1,11 @@
# Stable theme at startup
The old page painted a hardcoded purple sidebar before `app-runtime.js` applied the selected theme. Its partial early bootstrap only set some CSS variables; the legacy sidebar still used a hardcoded gradient, defaults were missing when no theme was saved, and the background setting wrote `--sun-ui-background` while CSS read `--sun-ui-bg`.
The existing theme editor and presets now live in `public/core/brand-theme.js`, loaded synchronously in the document head. It reads and normalizes the stored theme once, supplies the same defaults used by the editor, and installs the theme styles before the body is parsed. Explicit CSS precedence keeps later legacy styles from replacing those colors. The background setting now updates the variable actually used by the page. Preset preview, save/cancel and cloud updates are retained.
A brief Caterium loading surface covers intermediate legacy layouts until DOM initialization finishes. Initial sidebar transitions are disabled during this step; normal interactions keep their transitions. No company data or account settings are changed. The service worker release is `v83-20260917-theme-first-paint`.
`tests/theme-startup.spec.mjs` holds the main runtime request and checks that default/custom theme colors remain identical before and after it loads, on desktop and mobile. It also verifies preview isolation, save, reload and cloud updates. The Playwright server now explicitly serves the repository's `public/` directory. Running the existing tests against the actual page exposed unescaped catalog names in an early legacy renderer; those fields now use the existing escaping helper. Tests that inject individual modules with mocked dependencies now use an explicit same-origin fixture, isolated from real auth timers. The theme, XSS and idle integration tests load the complete application.
Validation: `npm run check:deploy` passed; Playwright reported 50 passed and two skipped. The desktop application and its appearance settings were also inspected in a browser.

View File

@ -0,0 +1,7 @@
# Empty company catalogs
Catalog records and photos now come solely from each company's stored data. Removed the bundled starter and legacy catalog arrays, automatic catalog merges, fresh-workspace seeding and demo-data onboarding. Developer-created companies also receive an empty catalog. Existing workspace catalogs and manually added items are preserved.
The Solnce composition refresh is scoped to its existing workspace. The earlier authorized import retained that workspace's catalog in the database; other companies no longer receive its boxes or premium items by default.
Validation covers an empty first launch, preservation of a company's custom catalog after reload, returning to an empty catalog after changing local workspace state, server onboarding, and the existing permissions/branding/pricing suites. Customer data is not part of the repository.

View File

@ -0,0 +1,11 @@
Completed Telegram import cleanup
The working menu, new-order selection, search, category counts and catalog PDFs exclude items with `hidden: true`. Historical order lookup still resolves these items, preserving original names, quantities, prices, payments and proposal snapshots. Adding a retired item through a stale catalog button is ignored.
The one-time Telegram archive viewer is removed. Its script remains a small compatibility cleanup for cached markup; it does not expose a stored archive again after synchronization or account changes.
The separate, owner-authorized production cleanup is limited to the Sun workspace and to catalog entries matching all four markers: category 0, section `Импорт из Telegram`, source `telegram-20260917`, and the `tg-item-` ID prefix. These temporary catalog references are retired rather than physically deleted because existing orders use their IDs. The global archive payload is removed; individual orders and their source details are preserved.
The operation makes a server backup, uses optimistic revision locking and verifies unchanged orders, client records, unrelated storage and regular catalog entries. Private payload backups and customer information are kept outside this repository.
Validation: desktop and mobile tests cover retired catalog visibility, search, printed and generated catalogs, historical order totals, paid state, contact details, proposal contents, reload persistence, archive retirement, company branding and banquet behavior (26 tests). Syntax, security, release checks and database smoke checks pass.

View File

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

View File

@ -11,7 +11,7 @@
"serverReady": true, "serverReady": true,
"workspaceAutoDiscovery": true, "workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false, "invitesTemporarilyDisabled": false,
"pwaCache": "v81-20260912-account-center-loader", "pwaCache": "v111-20260918-ui-stability",
"fullOfferDescriptions": true, "fullOfferDescriptions": true,
"dynamicOfferRows": true, "dynamicOfferRows": true,
"pdfOfferDescriptionFix": true, "pdfOfferDescriptionFix": true,
@ -60,7 +60,15 @@
"solar-experience", "solar-experience",
"midnight-compact", "midnight-compact",
"emerald-gold", "emerald-gold",
"neon-emerald" "neon-emerald",
"cream-elegance",
"neon-menu",
"emerald-circles",
"midnight-checklist",
"gourmet-hero",
"diamond-gold",
"premium-dark",
"premium-emerald"
], ],
"addressCandidateSelection": true, "addressCandidateSelection": true,
"addressMapConfirmation": true, "addressMapConfirmation": true,
@ -315,7 +323,7 @@
"calendarOverflowPanel": true, "calendarOverflowPanel": true,
"routeBaseConfigurable": true, "routeBaseConfigurable": true,
"routeOrderPopup": true, "routeOrderPopup": true,
"developerGateHotfixV1763": true, "developerGateHotfixV1763": false,
"saasClickHotfixV1763": true, "saasClickHotfixV1763": true,
"ordersAutoCompleteDelayMs": 60000, "ordersAutoCompleteDelayMs": 60000,
"ordersAutoPayment": true, "ordersAutoPayment": true,
@ -323,7 +331,7 @@
"clientOfferTemplatePerOrder": true, "clientOfferTemplatePerOrder": true,
"menuStyledSvgIcon": true, "menuStyledSvgIcon": true,
"releaseIntegrityChecks": true, "releaseIntegrityChecks": true,
"cloudRpcTimeoutMs": 12000, "cloudRpcTimeoutMs": 45000,
"cloudConflictMaxRetries": 4, "cloudConflictMaxRetries": 4,
"errorLogDedupMinutes": 5, "errorLogDedupMinutes": 5,
"networkFailureBackoff": true, "networkFailureBackoff": true,
@ -334,7 +342,7 @@
"indexCacheBustCurrent": true, "indexCacheBustCurrent": true,
"backgroundImageMutationBatching": true, "backgroundImageMutationBatching": true,
"opsBootWaitBounded": true, "opsBootWaitBounded": true,
"offerTemplates": 13, "offerTemplates": 6,
"offerTemplatePerOrder": true, "offerTemplatePerOrder": true,
"offerTemplateStructuralPreviews": true, "offerTemplateStructuralPreviews": true,
"offerPdfDistinctLayouts": true, "offerPdfDistinctLayouts": true,
@ -381,5 +389,52 @@
"clientServerSnapshotRpc": "sun_v17_clients_snapshot_v1773", "clientServerSnapshotRpc": "sun_v17_clients_snapshot_v1773",
"clientLegacyFallback": true, "clientLegacyFallback": true,
"clientOrderMetricsSource": "legacy orders verified locally", "clientOrderMetricsSource": "legacy orders verified locally",
"clientServerCache": "cateriumClientsServerV1773" "clientServerCache": "cateriumClientsServerV1773",
"supabaseProjectRef": "usfjwhztqoopzzfmfbis",
"recoveryMigration": "20260917150000_fresh_caterium",
"anonymousAccessDisabled": true,
"singleLoginFirstPaint": true,
"loginWaitsForSessionRestore": true,
"legacyLocalLoginRemoved": true,
"clientCacheTenantIsolation": true,
"trialDemo": {
"version": 1,
"boxes": 10,
"stockProducts": 43,
"suppliers": 5,
"autoNewTrial": true,
"preservesSolnce": true,
"imageFormat": "webp",
"imageMaxSidePx": 1024,
"imageBytes": 1343650,
"legacyImageLinksPreserved": true
},
"proposalLayout": "core/proposal-layout.js",
"proposalLocalCyrillicFonts": true,
"proposalRasterDpi": 290,
"proposalPhotoRequestTimeoutMs": 6000,
"proposalMediaBudgetMs": 10000,
"proposalMediaConcurrent": true,
"proposalMissingPhotoNotice": true,
"proposalLoadCancellation": true,
"cloudReadTimeoutMs": 12000,
"cloudFallbackTimeoutMs": 15000,
"cloudWriteTimeoutMs": 35000,
"cloudSafeNetworkRetries": 3,
"proposalCuratedTemplates": [
"light",
"editorial-grid",
"midnight-glass",
"event-story",
"bento-cards",
"event-ticket"
],
"proposalTransparentLogoTrim": true,
"proposalLogoAspectRatio": true,
"proposalLogoContrastMasthead": true,
"retiredCatalogItemsPreserveOrderHistory": true,
"oneTimeTelegramArchiveUiRemoved": true,
"settingsIdleMutationLoopRemoved": true,
"subscriptionResponseTenantIsolation": true,
"stableBackgroundUiUpdates": true
} }

View File

@ -0,0 +1,11 @@
# Client proposal loading recovery
Release cache: `v95-20260918-proposal-loading`.
The proposal preview could wait indefinitely for a photo response/body or unrelated UI font, leaving the download button disabled. Photos, logo and gallery now load together, with a 6-second limit per request and a 10-second budget for the media batch, including fallback URLs. The PDF renderer waits only for its three local fonts. Unavailable photos no longer block order text, quantities or prices; the preview identifies missing images and offers a refresh.
Closing or refreshing the proposal cancels the previous media load. Rendering failures show a retry message instead of an endless loading indicator. Both preview and download still share the same rendered A4 pages. The order's selected template is copied into newly generated and legacy snapshots before rendering, removing a timing dependency on the template picker.
Repeated cloud notifications no longer rebuild an unchanged proposal template, and a closed proposal does not rerender in the background. Explicit per-order design changes still update the open preview, including older snapshots.
Regression coverage includes a hanging photo, refresh recovery, a stalled response body cancelled on close, unrelated fonts that never finish, render failure/retry, all 21 covers, long Cyrillic content and actual PDF download/preview parity on desktop and mobile.

View File

@ -0,0 +1,19 @@
# Client proposal PDF quality
Classic template selections were routed through the signature fallback and lost their original covers. Global settings only exposed 8 templates; grouping could also remove the modern buttons when classic choices were present. The new renderer supports all 21 existing IDs, retaining the order's selected style.
The 10 covers from commit `a3616b7` were rendered and visually compared with the current application. Their editorial, letter, ticket, photographic and sunny compositions informed the restored designs. Each of the 21 covers now has its own composition. Menu pages use matching fonts and colors, with measured list, compact and two-column card layouts.
Local, licensed Cyrillic fonts (Manrope and Playfair Display, including italic) finish loading before layout. Name, composition, quantity, unit price and line total are retained. Long descriptions flow across pages. Prices share a right edge; page numbers, company identity and contacts have reserved space. Gallery photos fill remaining space rather than adding empty pages. Per-order uploaded gallery images are resolved before conversion, for every template.
Preview and download use the same A4 canvases, at approximately 290 dpi for ordinary offers and 242 dpi for catalogs above 24 lines to bound memory. The existing raster PDF writer is retained. The generic selector thumbnails contain fictional demo data and Caterium branding. Company names/logos in actual exports still come from the workspace.
Validation:
- `npm run check:deploy` passed.
- Full desktop/mobile suite: 104 passed, 2 skipped; 10 focused proposal checks passed after adding the real preview/download test.
- 21 final PDFs, 92 A4 pages, rendered for visual inspection. Earlier/current references are outside the repository under `../caterium-pdf-20260918/tmp/pdfs/`.
- Tests exercise all 21 template routes, local fonts, long Cyrillic text, page boundaries, collisions, privacy switches, custom photos, picker groups and actual PDF downloads.
- Reproducible fictional fixtures: `ops/pdf/audit-proposals.mjs`, `render-audit.py`, `build-showcase.py`.
PWA cache: `v92-20260918-proposal-quality`. No database migration or customer data changes.

View File

@ -0,0 +1,9 @@
# One login screen from the first frame
Release cache: `v96-20260918-single-login`.
The older blue/white email/password card could appear before the asynchronously loaded login decoration script. A saved session also initially looked signed out while the SDK restored it. The current cream login stylesheet is now loaded directly by the document and cached with the application. Its title, company mark and fields are created in their final form, independent of optional decoration JavaScript. The obsolete card styles and old local user/PIN popup have been removed.
An explicit session-restoration state keeps email/password fields absent until the SDK confirms that login is necessary. Startup shows a neutral Caterium loading message instead of imitation input fields. Startup cover cleanup works even if the decorative script fails. SDK loading and session restoration have deadlines, and authentication remains required; the emergency local switch remains disabled.
Regression coverage delays session restoration, blocks the decorative script, seeds old local-role preferences, switches login/registration modes, and verifies the authenticated app opens without ever mounting a password form. Existing account separation, real SDK login and retry checks remain enabled.

View File

@ -0,0 +1,30 @@
# Login, synchronization and trial image loading
Release cache: `v94-20260918-sync-demo-images-2`.
The previous transport aborted every request, including saves, after seven
seconds. A slow response could therefore surface as `AbortError: signal is
aborted without reason`. Reads had only four seconds on the fallback route.
- Reads and password login now allow 12 seconds through the proxy and 15 seconds
through the same Supabase project's direct route. A successful fallback is
preferred for 60 seconds, avoiding another wait on the failing route.
- Writes have a separate 35-second limit, longer than the proxy's 30-second
upstream limit. A write is never replayed by the transport. The outer cloud
operation deadline is 45 seconds so it cannot overtake a normal save.
- A transient sync failure retries after 2, 5 and 15 seconds, at most three
times. Each retry fetches the server revision and merges again, including when
a save committed but its response was lost. After exhaustion the UI shows a
readable connection error and keeps local changes.
- A late sync/pull response is ignored after account/workspace changes or local
sign-out. Caller cancellation is preserved and does not start a fallback.
- Ten trial photos now use 1024px WebP assets: **1,343,650 bytes instead of
22,930,358 bytes** (17.1 times smaller). The original images remain available
for rollback. Existing catalog/offer PNG references resolve to the optimized
files; customer photos and database records are not rewritten.
- The service worker caches same-origin app assets only, excluding API routes.
Validation covers delayed reads/saves, route fallback, cancellation, an uncertain
commit without a duplicate save, stale account responses, the actual SDK login,
all ten image dimensions and the 1.5 MB combined budget. Full application checks
also cover trial TTK/stock/procurement and all 21 proposal designs.

View File

@ -0,0 +1,13 @@
# Trial demo catalog
New full-feature trial workspaces start with 10 fictional catering boxes, generated photos, ingredient quantities, TTK gross/net tables, preparation steps and allergens. The 43 stock products include purchase prices and partial opening balances, linked to 5 fictional suppliers. Ordinary paid companies remain empty; the Solnce company is excluded.
An empty trial or the developer's own empty company can install the set from New Order. Installation requires an active admin membership and a writable subscription, locks the workspace, preserves other data, and does not overwrite a populated catalog/stock/supplier ledger. Repeating installation leaves the existing data unchanged. No customer or order is installed automatically.
The guide creates one clearly marked sample order for tomorrow (initial total 9,700 RUB). It cannot auto-complete/pay and has no invented customer identity. Users can test shortages, receipts and recipe-based write-off. Photos and prices are illustrative; TTK costs cover ingredients and packaging only.
Cloud hydration now refreshes the inventory closure; owner onboarding pulls the server seed before saving the company profile. PWA cache: v91-20260918-trial-demo.
Source: `ops/demo/trial-demo-data.mjs`; rebuild with `npm run build:demo`. Image prompts and built-in generation provenance: `ops/demo/image-prompts.json`. Database migration: `20260918010000_trial_demo_catalog.sql`.
Validation: SQL smoke covers automatic trial creation, all TTK/stock links, idempotency, populated-catalog protection, Solnce exclusion, foreign/viewer/anonymous denial and developer-owned workspace without granting platform access. Desktop/mobile integration covers TTK display, images, one sample order, shortages, stock receipt and repeated write-off protection; owner onboarding preserves the server demo.

View File

@ -0,0 +1,30 @@
# Compact clients and scheduled menu promotions
Client summaries are compact, whole-card keyboard-accessible buttons. Name,
phone, order count, turnover and loyalty discount remain visible; full addresses,
orders and loyalty settings remain in the existing detail dialog. Search is retained.
Menu editing now uses the ordinary price plus an explicit percentage or ruble
discount, not a manually entered previous price. Duration presets (1, 7, 14, 30
days), an exact local date/time, and unlimited duration are supported. Existing
finite promotions keep their original deadline when an unrelated field is edited.
The editor validates values and previews the effective price and return deadline.
`price` stores the ordinary price. `promotion` stores type, value, startsAt and
endsAt (UTC instants). A shared pure price resolver calculates the effective price;
at the deadline it returns the base price and no promotion badge. No background
server write, open tab or cron is required. An open tab schedules the next boundary
and rechecks after focus, visibility and cloud refresh. Saved order line price
snapshots are not recalculated. The expiry event never writes orders or catalog.
The client device clock is used, just like the existing app scheduling.
Legacy price/oldPrice records retain their existing effective price and convert
only when explicitly saved. JSON and spreadsheet catalog round-trips keep promotion
metadata. Full JSON cloud payloads already preserve these fields; no migration,
new permissions, database mutation or cross-project change is required.
Regression coverage: compact client geometry/search/details; percentage and ruble
validation; legacy conversion; fractional prices; exact deadline and reload;
unchanged saved orders; foreground expiry without losing an unsaved editor. The
production smoke uses fetched public assets with a synthetic, network-blocked
workspace; no customer account or database is used for this verification.

View File

@ -0,0 +1,33 @@
# AI support assistant in Help
The Help dialog's "Поддержка" tab can now open an AI assistant that answers
"how do I..." questions from the Caterium handbook (the same articles as the
"Руководство" tab).
Privacy and isolation:
- Nothing is requested from the assistant service until the user presses
"Задать вопрос помощнику". Opening Help, searching the handbook and switching
tabs stay fully local, exactly as before.
- The chat runs in an `<iframe>` served from the assistant's own origin
(`sandbox`, `referrerpolicy="no-referrer"`). It cannot read orders, clients,
local storage or the Supabase session of Caterium, and no third-party script
is added to the app page.
- The assistant page sets `frame-ancestors` to `https://app.caterium.ru`, so no
other site can embed it.
- The assistant has no access to company data and cannot change anything in
Caterium. The tab tells users not to type passwords, confirmation codes or
customer data because their question is processed by an external service.
Assistant configuration (agent, instructions, knowledge base and rate limits)
lives in the assistant service, not in this repository. The client only needs
`BOT.origin` and `BOT.agent` in `public/core/help-center.js`.
If the assistant service is unreachable, only the chat area is affected (it
shows the browser's or the assistant's own error page); the handbook and its
search keep working.
Regression coverage: `tests/help-center.spec.mjs` asserts that no external
request is made before the click, and that the iframe uses the expected
`src`, `referrerpolicy` and a restrictive `sandbox` (no top navigation, forms
or modals). No database, migration or permission change.

View File

@ -0,0 +1,23 @@
# One-page banquet menu for the client
Banquet menu → select individual dishes or choose a ready menu → Меню для клиента.
The preview and downloaded PDF use the same single A4 portrait canvas, with the
company document name/logo/contacts, event date, guests, and selected dishes grouped
by course. Costs can be shown or hidden. Sharing uses the system file share sheet
where supported; downloading a PDF is always available after rendering.
The document reads all selected dishes, not just the current search/group/package
filter. Existing line price snapshots (including free lines) take precedence over
current catalog prices. Otherwise active catalog promotions apply with kopecks.
Pricing covers only selected menu dishes, not the order's delivery, services or
order-wide discount. The calculation is explicitly labelled in the document.
Opening/exporting never applies the selection to an order, writes local storage or
saves a draft. A profile/company switch closes the preview and invalidates an
unfinished render. Existing catalog access and client-offer subscription limits
are respected.
Layout measures Cyrillic text after bounded font loading, balances one/two columns
and adjusts type size down to a legible minimum. Names are never truncated and
no dish is silently omitted. A menu too large for a readable single page produces
a clear error rather than an extra page or tiny/clipped text.

View File

@ -0,0 +1,46 @@
# Company employee sessions and mobile logout
## Application changes
Notification read flags now use a device-local key scoped to the authenticated
user and selected company. They do not enter the company synchronization payload.
Older remote `sunReadNotificationsV1` values are preserved, not overwritten or
removed by an employee. Read-only sections are taken from the remote snapshot
instead of uploading bootstrap defaults or empty caches as staff changes. Order
reconciliation respects the existing create/edit/delete permissions separately.
The server still enforces all permissions; no role or membership is granted.
On mobile, the main header contains labelled Profile and Logout buttons with SVG
icons. Profile also has a sticky top Logout button, including while its detail RPC
is pending or unavailable. Logout uses the existing bounded local-device sign-out
and tenant-cache preservation. Late account/company replies cannot restore stale
profile or sidebar information.
## Database change — must be deployed separately
Apply `supabase/migrations/20260920104500_workspace_sidebar_brand.sql` to the
Caterium database. It adds a membership-checked, selected-workspace sidebar RPC.
An exclusive branding assignment is associated with its owner's company only
when that company is unambiguous. Multiple owned companies need an explicit
assignment by an authorized database administrator, never a guessed name match.
Active confirmed employees of the assigned company receive the Solnce sidebar.
The old owner-only RPC remains compatible with older clients.
Publishing application assets does NOT apply SQL migrations. Until the database
migration is applied, the client safely falls back to the old owner-only RPC;
this release must not be reported as fixing employee branding on that server.
No company catalog, order, client, membership, or production account is changed
by the migration. The separate ai-staff project is outside this change.
## Verification scope
Browser scenarios use synthetic company accounts and do not access production
business data. They cover an empty employee device, pre-existing baselines,
allowed order edits with forbidden UI preferences, record-level permissions,
personal notification isolation, selected-workspace branding, old-server
compatibility, late replies, and visible mobile logout with unavailable details.
The recovery database suite checks the new RPC against actual SQL permissions.
The reported employee's actual company membership is a separate diagnosis and
requires the exact new email and an authorized server/account read. Empty UI alone
is not evidence that company orders were deleted.

View File

@ -0,0 +1,36 @@
# Optional learning catalog
Settings → Обучение и знакомство → Учебный каталог.
Off by default, including previously automatically seeded demo records. Opting in
is not limited to a trial subscription or an empty company. Normal catalog edit
permissions, support read-only and subscription write restrictions still apply.
Visibility is saved per authenticated user inside the current workspace's
sunTrialDemoV1.trainingCatalog metadata. Other profiles and workspaces do not
inherit the selection. A cloud-synced profile uses the same preference on its
other devices. This is an optional catalog, not a separate isolated account.
Enabling loads the existing versioned example bundle: ten photographed boxes,
premium sets, eighteen banquet dishes, drinks, supplies, extras and delivery.
Composition, recipe/TTK, prices, sample suppliers and zero-balance inventory are
included. New sample inventory has distinct names to prevent matching a real
product with the same name during stock lookup. Historical seeded inventory is
not renamed and existing recipes are never overwritten.
Disabling changes only selection visibility; it does not delete records, undo
manual stock operations, remove orders, change saved prices or reset edits.
Catalog/menu/search/stock/supplier browse screens hide the sample records. Lookup
for existing orders, preparation and documents continues using the full data.
Re-enabling is additive and idempotent; user-created products and existing sample
edits, inventory balances and supplier details are preserved.
There is no automatic creation of orders or inventory movements. The existing
explicit trial-order button keeps autoCompletionDisabled. Stock actions still
require an explicit user command and existing permissions. No production database
migration, account mutation or other-project change is required for this release.
Regression coverage includes first opt-in on a populated paid profile, switching
off and back on, reload, old seeded records, names/recipes/stock preservation,
TTKs and trial order, per-profile/workspace scope, read-only/support rejection,
failed downloads and late responses after a tenant change.

View File

@ -0,0 +1,12 @@
# Contact support from Help
Recipient is exactly `support@caterium.ru` (address explicitly corrected by the user on 2026-09-21). The Help dialog offers a human support tab and an unanswered-question call to action. It also works without login. The AI assistant stays separate.
Submission uses same-origin `/api/support.php`, not mailto. Required: name, reply email, topic, subject and message. Diagnostics are opt-in and contain only browser, viewport, current section and release strings. No SDK, session, token, order, customer record or URL query/hash is serialized. Drafts remain in memory on errors/close and are cleared on account/workspace changes. Only confirmed `202 accepted` clears the message. No autoresponder, attachments, arbitrary recipient or database access.
The PHP endpoint uses the existing Timeweb shared-hosting mail agent. From and envelope sender: no-reply@caterium.ru; Reply-To is the validated user email, noted as self-reported in the message. The recipient is hardcoded. Hosting must have PHP mail enabled and the sender domain mail/DNS policy must allow hosting mail. Mail acceptance is NOT proof of inbox delivery.
References: https://timeweb.com/ru/docs/pochta/osnovnye-voprosy-po-rabote-s-pochtoj/rabota-s-php-mail/ and https://www.php.net/manual/en/function.mail.php
Guards: exact host/origin, JSON-only POST, secure HttpOnly SameSite CSRF session, length/type checks, no header injection, honeypot, locked limits (5/IP/hour, 3/reply-email/hour, 60 total/hour), idempotency for 48 hours and pending state before delivery. Failed/uncertain mail never returns success. Rate/idempotency records hold hashes/statuses, NOT bodies or plaintext contacts, outside public_html in .caterium-support (0700/0600), pruned after 48 hours on submissions. Optional server-only CATERIUM_SUPPORT_STATE_DIR must also be outside the web root. Deployment does not delete it. Mail/session server retention is separate.
Automated tests do not send real mail: unit tests capture mail(); UI tests mock the endpoint; publication checks real PHP session/CSRF and cross-origin rejection plus exact public JS and the rendered form on 390/1440 px. Confirm mailbox receipt with one labelled message before announcing end-to-end delivery. No live customer data or MFA altered.

55
docs/support-assistant.md Normal file
View File

@ -0,0 +1,55 @@
# Руководство и будущий помощник Caterium
## Что работает сейчас
Встроенная «Помощь»: руководство, поиск по текстам/ключевым словам, категории, связанные инструкции и вкладка поддержки с частыми вопросами. На форме авторизации есть «Помощь со входом». Статьи находятся в `public/help/knowledge-v1.json`; содержимое не содержит реальных клиентов, цен компаний, телефонов, адресов, паролей или записей базы. Бот и отправка обращений не подключены. Вопросы поиска не сохраняются и не отправляются наружу. Руководство включено в кэш установленного приложения, но первое получение требует соединения.
## Как вести базу знаний
Стабильный id статьи, категория, заголовок, ключевые слова, последовательность шагов, ограничение/примечание и related. Обновлять version при правке текста. Проверять инструкцию в текущем интерфейсе и ролях, не описывать запланированную функцию как работающую. Эти же статьи использовать для поиска ботом с возвратом ссылок на конкретные article_id. Отдельно хранить закрытые инженерные инструкции: не индексировать дампы базы, исходные Telegram-архивы, логи с токенами и весь репозиторий в пользовательский поиск.
## Два режима будущего бота
1. Объяснение функций по опубликованной базе знаний: «Как сделать предложение?». Ответ с шагами и ссылками на статьи; если материала недостаточно — уточнить вопрос, не придумывать кнопки.
2. Объяснение данных своей компании: «Почему в этом заказе остаток?». Только после серверной авторизации и проверки прав. Подключать отдельными разрешёнными инструментами чтения. Не давать модели прямое SQL-соединение, service_role или доступ к произвольным таблицам.
Максимально полезный контекст означает полный и актуальный справочник плюс минимальные данные конкретного вопроса, а не загрузку всей клиентской базы в модель.
## Предлагаемый серверный контракт (ещё не реализован)
`POST /support/chat` с пользовательской сессией. Тело: conversation_id, message, locale, текущий раздел из фиксированного списка, необязательный выбранный order_id, версия клиента. Не доверять workspace_id, роли, суммам и правам из браузера: определить их по сессии, членству и серверным разрешениям. Идентификатор заказа проверять в пределах разрешённой компании. Отсутствие компании допускает только публичные инструкции.
Ответ: answer, citations[{article_id,knowledge_version}], data_as_of, request_id, needs_human. При выключенном помощнике вернуть явный статус unavailable, UI сохраняет доступ к руководству. Ключ провайдера только на сервере. Перед отправкой в модель отделить системные правила, найденные инструкции и недоверенные данные пользователя.
Разрешённые инструменты первой версии:
- search_help(query) — только опубликованные статьи.
- get_order_summary(order_id) — стоимость позиций, доставка, скидка, оплачено, остаток и статус; без телефона и адреса по умолчанию; orders.view плюс проверка строки/компании.
- get_order_recipe_requirements(order_id) — состав и рассчитанная потребность; права на заказ и соответствующий каталог/закупки.
- get_stock_shortages(order_id) — потребность и остатки; дополнительно stock.view/shopping, с учётом фактических имён прав в проекте при реализации.
- get_catalog_item(item_id) — доступные пользователю сведения о позиции; не раскрывать себестоимость роли без такого права.
- get_subscription_help() — разрешённые сведения о тарифе, без платёжных реквизитов.
Начальная версия только читает. Оплата заказа, списание склада, удаление, рассылки и изменения прав не выполняются ботом. Если позже появятся действия, нужен отдельный проект: предпросмотр последствий, конкретное подтверждение пользователя и обычная серверная проверка прав. Фраза внутри клиентского примечания не даёт разрешения выполнить команду.
## Защита и изоляция
- RLS/проверка прав на каждом инструменте, не только в общем chat endpoint. Пользовательский контекст вместо обхода RLS привилегированным ключом. Проверять актуальные права при каждом запросе.
- Ключ истории и кэша включает компанию и пользователя; авторизация на каждом чтении. При смене компании/выходе очищать UI и отменять запросы. Не показывать запоздалый ответ другой компании.
- Текст заказа, вложения, статьи от пользователя и ответы инструментов — данные, не системные инструкции. Не исполнять указания о раскрытии секретов и внешней отправке из этих полей. В первой версии не читать произвольные URL.
- Минимизировать данные до передачи провайдеру. Сначала выбрать провайдера, регион, договор обработки, режим хранения/обучения и правовое основание. Согласовать с политикой данных и фактической инфраструктурой; сейчас база Supabase во Франкфурте, вопрос локализации отмечен в юридическом пакете.
- Ограничить длину сообщения, число обращений, бюджет инструментов, таймаут и стоимость. Не писать пароли, access/refresh tokens и дампы в журналы.
- Аудит: request_id, пользователь/компания, версия базы знаний, названия вызванных инструментов, результат проверки прав; содержимое переписки и сроки хранения определить отдельно. Пользователь должен знать, что подключён AI и кому передаются сообщения.
- Ответы рендерить как безопасный текст/очищенный Markdown, не исполнять HTML и произвольные ссылки. При недоступности данных обозначать это, а не подставлять пример как факт.
## Передача человеку
Когда появится канал поддержки, дать пользователю проверить текст обращения и выбранные вложения до отправки. Историю переписки или данные заказа не пересылать автоматически. Подтверждать отправку только после ответа сервера. Пока контакты/канал не определены — никаких фиктивных «обращение отправлено».
## Проверки перед подключением
Набор типовых вопросов по каждой статье; правильные ссылки; отсутствие вымышленных функций; запрос чужого заказа; смена компании во время ответа; отзыв прав; prompt injection в примечании; просроченная сессия; пустые данные; известная/неизвестная цена; повторы и лимиты; падение провайдера; мобильные Safari/Chrome; удобное закрытие и клавиатура. Не подключать бота к production до прохождения проверок изоляции и согласования передачи данных.
## Решения, оставшиеся владельцу
Провайдер и бюджет, собственный бот или API модели, канал передачи человеку, контакт поддержки, допустимые категории данных, срок хранения диалогов и необходимость режима ответов по данным компании. Текущая реализация не требует этих решений и уже полезна как руководство.

View File

@ -0,0 +1,18 @@
# UI stability audit — 18 September 2026
## Confirmed defects and fixes
- Settings refreshed their own DOM through a MutationObserver every 20 ms. An idle fixture produced 34 child-list changes in 350 ms. Catalog summaries and tab captions now update only when their content changes. New cards receive their tab visibility before the next paint.
- Settings card ordering repeatedly moved the same nodes. Ordering now walks backwards from its anchor and leaves already ordered cards in place.
- Unchanged cloud notifications replaced theme controls and removed input focus. Theme state is compared before applying it; viewport resizing uses existing responsive CSS without replaying the theme.
- The obsolete developer hotfix replaced the unified login while company access was still loading. Removed that duplicate renderer; retained safe opening of the developer console from its explicit button.
- A pending subscription request could apply the previous account's state and restore a blocking window. Requests are scoped to user and workspace; tenant changes invalidate requests and clear subscription windows. Repeated notifications share a request. Temporary failures retain the last confirmed subscription state.
- Subscription refresh rebuilt unchanged controls and overlays. Those nodes now persist. The plans dialog opens above the subscription blocker.
- A queued catalog editor callback could move the editor into an inactive section after navigation. It now checks the active section and skips duplicate docking.
- The common modal handler overwrote higher z-index values and could move focus after its window had closed. It now preserves stacking priority, checks the active window before autofocus, respects a user's newly focused input, and releases the page scroll lock when a window is removed. Plans also open above the dialog that requested them.
## Regression coverage
`tests/ui-stability.spec.mjs` covers idle DOM stability, focus preservation, real theme changes, account response races, dialog stacking, rapid menu navigation and a complete application session with mocked server responses. It runs in desktop Chromium, mobile Chromium and iPhone WebKit. Existing login, account isolation, theme startup and PDF tests remain in the release suite.
No application data or database schema changes are needed for this release. Browser automation checks specific workflows; it does not establish the absence of every possible defect or substitute for testing on a physical iPhone.

View File

@ -0,0 +1,18 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {banquetDemo} from './trial-banquet-data.mjs';
const root=new URL('../../',import.meta.url);
const original=fs.readFileSync(new URL('trial-demo.sql',import.meta.url),'utf8');
const functionBlock=name=>original.slice(original.indexOf(`create or replace function public.${name}(`),original.indexOf('end $fn$;',original.indexOf(`create or replace function public.${name}(`))+10);
let status=functionBlock('caterium_trial_demo_status');
const oldStatus="'canInstall',coalesce(v_allowed,false) and v_empty and not v_installed";
assert.ok(status.includes(oldStatus));
status=status.replace(oldStatus,oldStatus+",'canUpgrade',coalesce(v_allowed,false) and v_installed and coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1'");
let install=functionBlock('caterium_install_trial_demo');
assert.ok(install.includes('then return v_status; end if;'));
install=install.replace('then return v_status; end if;','then return public.caterium_install_trial_banquet(p_workspace); end if;')
.replace('return public.caterium_trial_demo_status(p_workspace);','return public.caterium_install_trial_banquet(p_workspace);');
const template=fs.readFileSync(new URL('trial-banquet.sql',import.meta.url),'utf8');
fs.writeFileSync(new URL('public/demo/banquet-v1.json',root),JSON.stringify(banquetDemo,null,2)+'\n');
fs.writeFileSync(new URL('supabase/migrations/20260918060000_trial_banquet.sql',root),template.replace('__BANQUET_JSON__',JSON.stringify(banquetDemo)).replace('__STATUS_FUNCTION__',status).replace('__INSTALL_FUNCTION__',install));
console.log(`Built ${banquetDemo.banquet.length} banquet dishes and ${banquetDemo.stock.length} additional stock products.`);

View File

@ -0,0 +1,7 @@
import fs from 'node:fs';
import {demo} from './trial-demo-data.mjs';
const root=new URL('../../',import.meta.url);
const template=fs.readFileSync(new URL('trial-demo.sql',import.meta.url),'utf8');
fs.writeFileSync(new URL('public/demo/catalog-v1.json',root),JSON.stringify(demo,null,2)+'\n');
fs.writeFileSync(new URL('supabase/migrations/20260918010000_trial_demo_catalog.sql',root),template.replace('__DEMO_JSON__',JSON.stringify(demo)));
console.log(`Built ${demo.boxes.length} boxes, ${demo.stock.length} products, ${demo.suppliers.length} suppliers.`);

View File

@ -0,0 +1,16 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {extrasDemo} from './trial-extras-data.mjs';
const root=new URL('../../',import.meta.url);
const original=fs.readFileSync(new URL('supabase/migrations/20260918060000_trial_banquet.sql',root),'utf8');
const block=name=>{const start=original.indexOf(`create or replace function public.${name}(`);assert.ok(start>=0);return original.slice(start,original.indexOf('end $fn$;',start)+10)};
let status=block('caterium_trial_demo_status');
const old="and coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1'";
assert.ok(status.includes(old));status=status.replace(old,"and (coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1' or coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')<>'1')");
let banquet=block('caterium_install_trial_banquet');
banquet=banquet.replace("if not (v_status->>'canUpgrade')::boolean then return v_status; end if;","if coalesce((select payload#>>'{storage,sunTrialDemoV1,v,banquetVersion}' from public.sun_app_state where workspace_id=p_workspace),'')='1' then return v_status; end if;");
const install=block('caterium_install_trial_demo').replaceAll('return public.caterium_install_trial_banquet(p_workspace);','perform public.caterium_install_trial_banquet(p_workspace); return public.caterium_install_trial_extras(p_workspace);');
const template=fs.readFileSync(new URL('trial-extras.sql',import.meta.url),'utf8');
fs.writeFileSync(new URL('public/demo/extras-v1.json',root),JSON.stringify(extrasDemo,null,2)+'\n');
fs.writeFileSync(new URL('supabase/migrations/20260918083000_trial_extras.sql',root),template.replace('__EXTRAS_JSON__',JSON.stringify(extrasDemo)).replace('__STATUS_FUNCTION__',status).replace('__BANQUET_FUNCTION__',banquet).replace('__INSTALL_FUNCTION__',install));
console.log(`Built ${extrasDemo.items.length} items across five trial categories and ${extrasDemo.stock.length} stock products.`);

View File

@ -0,0 +1,39 @@
{
"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

@ -0,0 +1,45 @@
{
"tool": "built-in image_gen",
"prompts": [
{
"id": "bruschetta-tomato",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 24 small toasted baguette bruschettas topped with diced red tomatoes, basil and olive oil in four neat rows. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "salmon-cream",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 16 rye bread canapes with folds of lightly salted salmon, cream cheese, cucumber and dill, four by four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "chicken-sandwich",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 12 small triangular chicken sandwiches with lettuce, tomato and cream cheese, neatly lined up. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "caprese",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 20 canape skewers with white mini mozzarella balls, red cherry tomatoes and basil, five rows of four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "mushroom-tartlet",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 20 small golden pastry tartlets filled with mushrooms, cream and melted cheese, four rows of five. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "turkey-wrap",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 24 tortilla pinwheel rolls with turkey, cream cheese, lettuce and red bell pepper, neatly arranged. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "cheese-fruit",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of a curated cheese assortment with brie wedges, gouda cubes, blue cheese, green grapes, walnuts, a tiny honey cup and crackers. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "meat-assortment",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of a meat assortment of folded salami, roast beef and turkey ham, cornichons, black olives and a tiny mustard cup. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "vegetables-hummus",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of colorful fresh carrot sticks, cucumber sticks, bell pepper strips, cherry tomatoes and a central small kraft cup of hummus. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
},
{
"id": "berry-dessert",
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 16 miniature clear dessert cups of cheesecake cream, biscuit crumble, fresh strawberries and blueberries, four by four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
}
]
}

View File

@ -0,0 +1,15 @@
"""Build lightweight display assets while retaining original demo PNGs for rollback."""
from pathlib import Path
from PIL import Image
root = Path(__file__).resolve().parents[2] / "public" / "demo" / "images"
original_bytes = optimized_bytes = 0
for source in sorted(root.glob("*.png")):
with Image.open(source) as image:
image = image.convert("RGB")
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
target = source.with_suffix(".webp")
image.save(target, "WEBP", quality=83, method=6)
original_bytes += source.stat().st_size
optimized_bytes += target.stat().st_size
print(f"Demo images: {original_bytes:,} -> {optimized_bytes:,} bytes")

View File

@ -0,0 +1,77 @@
// Fictional one-portion recipes. Reuse the original demo stock without resetting it.
import {stock as boxStock} from './trial-demo-data.mjs';
const extra=[
['potato','Картофель',80,'fresh'],['butter','Масло сливочное',1100,'fresh'],
['milk','Молоко',110,'fresh'],['egg','Яйцо куриное без скорлупы',260,'fresh'],
['peas','Горошек зелёный консервированный',300,'grocery'],
['chicken-raw','Филе куриное сырое',480,'protein'],['whitefish','Филе трески',950,'protein'],
['lemon','Лимон',300,'fresh'],['rice','Рис сухой',180,'grocery']
].map(([key,name,cost,supplier])=>({id:`demo-banquet-v1-stock-${key}`,name,cost,unit:'кг',qty:0,min:0.1,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0}));
const products=new Map([...boxStock.map(p=>[p.id.replace('demo-v1-stock-',''),p]),...extra.map(p=>[p.id.replace('demo-banquet-v1-stock-',''),p])]);
// Gross / prepared net grams per guest; water absorbed by rice is described separately.
const recipes=[
['caprese','Капрезе с моцареллой и базиликом','Холодные закуски',290,
[['mozzarella',50,50],['tomato',70,60],['basil',6,5],['oil',5,5]],['Молоко'],
['Нарезать томаты и моцареллу.','Выложить слоями, добавить базилик и масло.']],
['roastbeef','Ростбиф с корнишонами и горчичным соусом','Холодные закуски',390,
[['beef',65,65],['pickle',22,20],['mustard',5,5],['cooking-cream',10,10]],['Молоко','Горчица'],
['Нарезать готовый ростбиф тонкими ломтиками.','Смешать сливки с горчицей, подать с корнишонами.']],
['salmon-roll','Рулетики из лосося со сливочным сыром','Холодные закуски',350,
[['salmon',45,45],['cream',25,25],['cucumber',22,18],['dill',3,2]],['Рыба','Молоко'],
['Нарезать лосось и огурец тонкими ломтиками.','Распределить сыр, свернуть рулетики, украсить укропом.']],
['hummus','Овощные палочки с хумусом','Холодные закуски',230,
[['carrot',48,40],['cucumber',44,40],['pepper',40,30],['hummus',50,50]],['Кунжут в составе хумуса'],
['Очистить овощи и нарезать палочками.','Подать с порцией готового хумуса.']],
['caesar','Салат с курицей, сухариками и сыром','Салаты',340,
[['chicken',60,60],['lettuce',42,35],['cherry',35,30],['toast',23,20],['gouda',15,15],['cooking-cream',15,15],['mustard',5,5]],['Глютен','Молоко','Горчица'],
['Подсушить кубики хлеба, подготовить салат и томаты.','Нарезать готовую курицу, соединить с овощами и сыром.','Заправить сливками с горчицей, сверху добавить сухарики.']],
['olivier','Оливье с индейкой','Салаты',280,
[['potato',65,50],['carrot',26,20],['turkey',35,35],['egg',22,20],['peas',20,20],['pickle',22,20],['cooking-cream',12,12],['mustard',3,3]],['Яйцо','Молоко','Горчица'],
['Отварить картофель, морковь и яйцо; остудить.','Нарезать овощи, яйцо, ветчину и корнишоны кубиком.','Добавить горошек и заправить сливками с горчицей.']],
['greek','Овощной салат с моцареллой и маслинами','Салаты',290,
[['tomato',55,50],['cucumber',44,40],['pepper',32,25],['mozzarella',40,40],['olive',17,15],['oil',10,10]],['Молоко'],
['Подготовить и крупно нарезать овощи.','Добавить моцареллу и маслины, заправить маслом.']],
['julienne','Жюльен с курицей и грибами','Горячие закуски',310,
[['chicken',40,40],['mushroom',60,40],['onion',15,10],['cooking-cream',25,20],['gouda',15,15],['oil',5,5]],['Молоко'],
['Обжарить нарезанные грибы и лук на масле.','Добавить готовую курицу и сливки, распределить по формам.','Посыпать сыром и запечь до расплавления.']],
['stuffed-mushrooms','Шампиньоны, запечённые с сыром','Горячие закуски',270,
[['mushroom',125,85],['cream',20,20],['gouda',10,10],['dill',3,2],['oil',3,3]],['Молоко'],
['Подготовить шляпки грибов, ножки мелко нарезать.','Смешать ножки со сливочным сыром и укропом, наполнить шляпки.','Добавить гауду и запечь.']],
['chicken','Куриное филе с овощами и сливочным соусом','Горячие блюда',490,
[['chicken-raw',180,135],['pepper',45,30],['carrot',32,25],['cooking-cream',30,25],['oil',5,5]],['Молоко'],
['Обжарить куриное филе и довести до готовности.','Отдельно приготовить перец и морковь.','Добавить сливочный соус, подать с овощами; учесть потери при приготовлении.']],
['cod','Треска с лимоном и овощами','Горячие блюда',590,
[['whitefish',170,130],['tomato',45,35],['pepper',30,20],['lemon',15,10],['oil',5,5]],['Рыба'],
['Подготовить филе и нарезать овощи.','Запечь рыбу с овощами, лимоном и маслом до готовности.']],
['beef-hot','Ростбиф с грибами в сливочном соусе','Горячие блюда',690,
[['beef',125,120],['mushroom',80,55],['onion',24,15],['cooking-cream',30,25],['oil',5,5]],['Молоко'],
['Обжарить грибы и лук, добавить сливки.','Прогреть готовый ростбиф и подать с грибным соусом.']],
['mash','Картофельное пюре','Гарниры',160,
[['potato',170,120],['milk',25,20],['butter',10,10]],['Молоко'],
['Очистить и отварить картофель, слить воду.','Размять с тёплым молоком и сливочным маслом.']],
['rice','Рис с овощами','Гарниры',170,
[['rice',40,40],['carrot',25,20],['pepper',25,20],['peas',15,15],['oil',5,5]],[],
['Отварить рис; учебный расчёт учитывает 50 г поглощённой воды на порцию.','Приготовить овощи, добавить горошек и смешать с рисом.'],50],
['berry-cream','Ягодный десерт со сливочным кремом','Десерты',240,
[['cream',35,35],['cooking-cream',20,20],['biscuit',15,15],['strawberry',28,25],['blueberry',11,10],['sugar',5,5]],['Молоко','Глютен','Яйцо в составе бисквита'],
['Смешать сыр, сливки и сахарную пудру.','Выложить в креманку бисквит, крем и подготовленные ягоды.']],
['cheese-honey','Бри с виноградом, мёдом и орехами','Десерты',310,
[['brie',45,45],['grape',39,35],['walnut',10,10],['honey',10,10]],['Молоко','Орехи'],
['Нарезать бри и подготовить виноград.','Подать с грецким орехом и мёдом.']],
['fruit','Фруктово-ягодное ассорти','Фрукты и ягоды',290,
[['grape',88,80],['strawberry',77,70],['blueberry',32,30]],[],
['Подготовить ягоды и виноград, удалить плодоножки.','Выложить порционное ассорти.']],
['bread','Хлебная корзинка с зелёным маслом','Хлеб и масло',120,
[['baguette',30,30],['rye',20,20],['butter',8,8],['dill',3,2]],['Глютен','Молоко'],
['Нарезать хлеб, при желании подсушить.','Смешать масло с укропом, подать отдельно.']]
];
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 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,
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,
ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100,
note:'Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены.'}};
});
export const banquetDemo={version:1,banquet,stock:extra};

View File

@ -0,0 +1,47 @@
-- Generated by ops/demo/build-trial-banquet.mjs. Additive demo upgrade only.
begin;
create or replace function public.caterium_trial_banquet_seed_v1()
returns jsonb language sql immutable security definer set search_path=public,pg_temp
as $fn$ select $demo$__BANQUET_JSON__$demo$::jsonb $fn$;
revoke all on function public.caterium_trial_banquet_seed_v1() from public,anon,authenticated;
__STATUS_FUNCTION__
create or replace function public.caterium_install_trial_banquet(p_workspace uuid)
returns jsonb language plpgsql security definer set search_path=public,pg_temp
as $fn$
declare
v_state public.sun_app_state%rowtype; v_status jsonb; v_storage jsonb;
v_seed jsonb; v_key text; v_items jsonb; v_added jsonb; v_marker jsonb;
begin
if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
v_status:=public.caterium_trial_demo_status(p_workspace);
if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if;
if not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if;
if not (v_status->>'canUpgrade')::boolean then return v_status; end if;
select * into v_state from public.sun_app_state where workspace_id=p_workspace for update;
v_storage:=v_state.payload->'storage'; v_seed:=public.caterium_trial_banquet_seed_v1();
foreach v_key in array array['sunBoxes','sunStock'] loop
v_items:=coalesce(v_storage#>array[v_key,'v'],'[]'::jsonb);
if jsonb_typeof(v_items)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if;
-- Keep every existing row byte-for-byte, including edited demo prices and balances.
select coalesce(jsonb_agg(n),'[]'::jsonb) into v_added
from jsonb_array_elements(v_seed->case when v_key='sunBoxes' then 'banquet' else 'stock' end) n
where not exists(select 1 from jsonb_array_elements(v_items) old where old->>'id'=n->>'id');
v_storage:=v_storage||jsonb_build_object(v_key,jsonb_build_object('t','j','v',v_items||v_added));
end loop;
v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('banquetVersion',1,'banquetInstalledAt',now());
v_storage:=jsonb_set(v_storage,'{sunTrialDemoV1,v}',v_marker);
perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-banquet-v1',v_state.revision);
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details)
values(auth.uid(),'trial_demo.banquet_install',p_workspace,jsonb_build_object('version',1,'dishes',18));
return public.caterium_trial_demo_status(p_workspace);
end $fn$;
revoke all on function public.caterium_install_trial_banquet(uuid) from public,anon;
grant execute on function public.caterium_install_trial_banquet(uuid) to authenticated;
-- Both onboarding's existing trigger and the install button use this entry point.
__INSTALL_FUNCTION__
notify pgrst,'reload schema';
commit;

View File

@ -0,0 +1,79 @@
// Fictional training recipes and prices. No customer or Solnce catalog data.
const suppliers=[
['fresh','Демо: овощи и молочные продукты'],['protein','Демо: мясо и рыба'],
['bakery','Демо: пекарня'],['grocery','Демо: бакалея'],['pack','Демо: упаковка']
].map(([key,name])=>({id:`demo-v1-supplier-${key}`,name,active:true,category:'Учебный поставщик',contact:'',phone:'',email:'',address:'',deliveryDays:'По договорённости',minOrder:0,note:'Вымышленный поставщик для знакомства с приложением.'}));
// key, product name, purchase unit price, supplier, unit
const products=[
['baguette','Багет',240,'bakery'],['tomato','Томаты',260,'fresh'],['basil','Базилик',1500,'fresh'],
['oil','Масло оливковое',1100,'grocery'],['rye','Хлеб ржаной',220,'bakery'],
['salmon','Лосось слабосолёный',2400,'protein'],['cream','Сыр сливочный',850,'fresh'],
['cucumber','Огурцы',220,'fresh'],['dill','Укроп',700,'fresh'],['toast','Хлеб тостовый',240,'bakery'],
['chicken','Филе курицы готовое',850,'protein'],['lettuce','Салат листовой',700,'fresh'],
['mozzarella','Моцарелла мини',950,'fresh'],['cherry','Томаты черри',450,'fresh'],
['tartlet','Тарталетки готовые',750,'bakery'],['mushroom','Шампиньоны',340,'fresh'],
['cooking-cream','Сливки',500,'fresh'],['gouda','Сыр гауда',900,'fresh'],['onion','Лук репчатый',80,'fresh'],
['tortilla','Тортилья',450,'bakery'],['turkey','Ветчина из индейки',1000,'protein'],['pepper','Перец сладкий',380,'fresh'],
['brie','Сыр бри',1600,'fresh'],['blue','Сыр с голубой плесенью',1700,'fresh'],['grape','Виноград',420,'fresh'],
['walnut','Орех грецкий очищенный',1000,'grocery'],['honey','Мёд',800,'grocery'],['cracker','Крекер',500,'bakery'],
['salami','Салями',1400,'protein'],['beef','Ростбиф готовый',2000,'protein'],['pickle','Корнишоны',400,'grocery'],
['olive','Маслины без косточек',650,'grocery'],['mustard','Горчица',350,'grocery'],['carrot','Морковь',90,'fresh'],
['hummus','Хумус готовый',600,'grocery'],['biscuit','Крошка бисквитная',500,'bakery'],
['strawberry','Клубника',750,'fresh'],['blueberry','Голубика',1300,'fresh'],['sugar','Пудра сахарная',160,'grocery'],
['box','Коробка для кейтеринга',65,'pack','шт.'],['skewer','Шпажка бамбуковая',2,'pack','шт.'],
['cup','Стаканчик десертный',9,'pack','шт.'],['sauce-cup','Соусник',7,'pack','шт.']
].map(([key,name,cost,supplier,unit='кг'])=>({key,id:`demo-v1-stock-${key}`,name,cost,unit,supplierId:`demo-v1-supplier-${supplier}`}));
const byKey=new Map(products.map(p=>[p.key,p]));
// Product quantities are gross/net in grams; packaging is in pieces.
const recipes=[
{id:'bruschetta-tomato',name:'Брускетты с томатами и базиликом',price:2200,pieces:24,
rows:[['baguette',480,480],['tomato',330,300],['basil',26,24],['oil',36,36]],
composition:['24 брускетты с томатами, базиликом и оливковым маслом'],allergens:['Глютен'],
steps:['Нарезать багет на 24 одинаковых ломтика и подсушить.','Подготовить томаты и базилик, смешать с маслом.','Распределить начинку по ломтикам, уложить в коробку.']},
{id:'salmon-cream',name:'Канапе с лососем и сливочным сыром',price:3200,pieces:16,
rows:[['rye',240,240],['salmon',240,240],['cream',160,160],['cucumber',84,72],['dill',10,8]],
composition:['16 канапе на ржаном хлебе с лососем, сливочным сыром и огурцом'],allergens:['Рыба','Молоко','Глютен'],
steps:['Вырезать из ржаного хлеба 16 основ.','Нанести сливочный сыр, добавить ломтики огурца и лосося.','Украсить укропом и уложить в коробку.']},
{id:'chicken-sandwich',name:'Мини-сэндвичи с курицей',price:2400,pieces:12,
rows:[['toast',360,360],['chicken',240,240],['cream',120,120],['tomato',92,84],['lettuce',45,36]],
composition:['12 мини-сэндвичей с готовым куриным филе, томатами, салатом и сливочным сыром'],allergens:['Глютен','Молоко'],
steps:['Нарезать готовое куриное филе, подготовить овощи и салат.','Собрать сэндвичи из тостового хлеба, сыра, курицы и овощей.','Нарезать на 12 одинаковых мини-сэндвичей и упаковать.']},
{id:'caprese',name:'Канапе «Капрезе»',price:2100,pieces:20,
rows:[['mozzarella',360,360],['cherry',308,280],['basil',22,20],['oil',40,40],['skewer',20,20]],
composition:['20 шпажек с моцареллой, черри и базиликом'],allergens:['Молоко'],
steps:['Подготовить черри и базилик, обсушить моцареллу.','Собрать 20 шпажек, распределив ингредиенты поровну.','Добавить оливковое масло, уложить в коробку.']},
{id:'mushroom-tartlet',name:'Тарталетки с грибами и сыром',price:2300,pieces:20,
rows:[['tartlet',200,200],['mushroom',440,300],['cooking-cream',100,100],['gouda',120,120],['onion',88,60],['oil',20,20]],
composition:['20 тарталеток с шампиньонами, сливками и сыром'],allergens:['Глютен','Молоко'],
steps:['Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.','Добавить сливки и распределить начинку по 20 тарталеткам.','Добавить сыр, запечь до расплавления и упаковать после охлаждения.']},
{id:'turkey-wrap',name:'Роллы из тортильи с индейкой',price:2700,pieces:25,
rows:[['tortilla',320,320],['turkey',320,320],['cream',180,180],['lettuce',75,60],['pepper',150,120]],
composition:['25 мини-роллов с ветчиной из индейки, сливочным сыром, перцем и салатом'],allergens:['Глютен','Молоко'],
steps:['Подготовить салат и перец, нарезать ветчину.','Нанести сыр на тортильи, распределить начинку и свернуть плотные рулеты.','Нарезать на 25 порций и уложить срезом вверх.']},
{id:'cheese-fruit',name:'Сырный бокс с виноградом и орехами',price:2900,pieces:0,
rows:[['brie',200,200],['gouda',180,180],['blue',120,120],['grape',176,160],['walnut',50,50],['honey',40,40],['cracker',50,50],['sauce-cup',1,1]],
composition:['Бри, гауда и сыр с голубой плесенью','Виноград, грецкий орех, мёд и крекер'],allergens:['Молоко','Орехи','Глютен'],
steps:['Нарезать сыры порционными кусочками.','Подготовить виноград, переложить мёд в соусник.','Разложить сыры, виноград, орехи и крекер отдельными секциями.']},
{id:'meat-assortment',name:'Мясное ассорти с корнишонами',price:3200,pieces:0,
rows:[['salami',180,180],['beef',220,220],['turkey',150,150],['pickle',90,80],['olive',45,40],['mustard',30,30],['sauce-cup',1,1]],
composition:['Салями, готовый ростбиф и ветчина из индейки','Корнишоны, маслины и горчица'],allergens:['Горчица'],
steps:['Нарезать готовые мясные продукты тонкими ломтиками.','Обсушить корнишоны и маслины, переложить горчицу в соусник.','Разложить ассорти секциями, добавить гарниры.']},
{id:'vegetables-hummus',name:'Овощной бокс с хумусом',price:2100,pieces:0,
rows:[['carrot',300,250],['cucumber',275,250],['pepper',250,200],['cherry',165,150],['hummus',150,150],['sauce-cup',1,1]],
composition:['Морковь, огурцы, сладкий перец и черри','Хумус в отдельном соуснике'],allergens:['Кунжут в составе хумуса'],
steps:['Вымыть и подготовить овощи; очистить морковь и перец.','Нарезать овощи палочками, черри оставить целыми.','Переложить хумус в соусник и разложить овощи вокруг него.']},
{id:'berry-dessert',name:'Ягодные мини-десерты',price:2800,pieces:16,
rows:[['biscuit',160,160],['cream',320,320],['cooking-cream',160,160],['strawberry',176,160],['blueberry',84,80],['sugar',80,80],['cup',16,16]],
composition:['16 десертов со сливочным кремом, бисквитом, клубникой и голубикой'],allergens:['Молоко','Глютен','Яйцо в составе бисквита'],
steps:['Подготовить ягоды, разделить готовый бисквит на крошку.','Смешать сливочный сыр, сливки и сахарную пудру в крем.','Собрать 16 стаканчиков слоями: бисквит, крем, ягоды.']}
];
const round=n=>Math.round(n*1000)/1000;
const totals=new Map();
export const boxes=recipes.map((r,index)=>{
const rows=[...r.rows,['box',1,1]].map(([key,gross,net])=>{const p=byKey.get(key),scale=p.unit==='кг'?1000:1;const row={productId:p.id,name:p.name,unit:p.unit,gross:round(gross/scale),net:round(net/scale),unitCost:p.cost};totals.set(key,round((totals.get(key)||0)+row.gross));return row});
const outputGrams=Math.round(rows.filter(p=>p.unit==='кг').reduce((s,p)=>s+p.net*1000,0));
return {id:`demo-v1-${r.id}`,name:r.name,price:r.price,category:0,catalogSection:'Демонстрационные боксы',pieces:r.pieces,weight:`${outputGrams} г`,photo:`demo/images/${r.id}.png`,composition:r.composition,ingredients:rows.map(p=>[p.name,p.gross,p.unit]),demo:true,ttk:{number:`ДЕМО-${String(index+1).padStart(2,'0')}`,basis:'На 1 готовый бокс',outputGrams,pieces:r.pieces,rows,steps:r.steps,allergens:r.allergens,ingredientCost:Math.round(rows.reduce((s,p)=>s+p.gross*p.unitCost,0)*100)/100,note:'Учебные нормы и цены для знакомства с приложением.'}};
});
export const stock=products.map((p,index)=>({id:p.id,name:p.name,unit:p.unit,cost:p.cost,qty:(p.unit==='шт.'?Math.floor:round)((totals.get(p.key)||0)*[0,0.45,2,0.7][index%4]),min:p.unit==='кг'?0.1:2,supplierId:p.supplierId,lastPurchaseDate:'',lastPurchasePrice:0}));
export {suppliers};
export const demo={version:1,batch:'trial-demo-v1',boxes,stock,suppliers,scenario:{lines:[{id:'demo-v1-bruschetta-tomato',qty:2},{id:'demo-v1-salmon-cream',qty:1},{id:'demo-v1-caprese',qty:1}]}};

84
ops/demo/trial-demo.sql Normal file
View File

@ -0,0 +1,84 @@
-- Generated by ops/demo/build-trial-demo.mjs. Fictional training catalog only.
begin;
create or replace function public.caterium_trial_demo_seed_v1()
returns jsonb language sql immutable security definer set search_path=public,pg_temp
as $fn$ select $demo$__DEMO_JSON__$demo$::jsonb $fn$;
revoke all on function public.caterium_trial_demo_seed_v1() from public,anon,authenticated;
create or replace function public.caterium_trial_demo_status(p_workspace uuid)
returns jsonb language plpgsql stable security definer set search_path=public,pg_temp
as $fn$
declare
v_storage jsonb; v_empty boolean:=true; v_key text; v_value jsonb;
v_allowed boolean; v_installed boolean;
begin
if auth.uid() is null or public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
select coalesce(payload->'storage','{}'::jsonb) into v_storage from public.sun_app_state where workspace_id=p_workspace;
v_installed:=coalesce(v_storage#>>'{sunTrialDemoV1,v,version}','')='1';
foreach v_key in array array['sunBoxes','sunStock','sunSuppliers','sunStockMoves'] loop
v_value:=v_storage#>array[v_key,'v'];
if v_value is not null and v_value<>'[]'::jsonb then v_empty:=false; end if;
if v_storage ? v_key and (jsonb_typeof(v_storage->v_key)<>'object' or v_value is null) then v_empty:=false; end if;
end loop;
v_allowed:=public.sun_member_role(p_workspace)='admin'
and public.sun_subscription_access_mode(p_workspace)='full'
and (exists(select 1 from public.sun_workspace_subscriptions where workspace_id=p_workspace and status='trialing') or public.sun_is_platform_admin())
and public.sun_workspace_has_feature(p_workspace,'catalog_edit')
and public.sun_workspace_has_feature(p_workspace,'stock')
and public.sun_workspace_has_feature(p_workspace,'suppliers')
and not exists(select 1 from public.sun_workspace_members m join public.caterium_sidebar_brand_assignment a on a.user_id=m.user_id where m.workspace_id=p_workspace);
return jsonb_build_object('eligible',coalesce(v_allowed,false),'installed',v_installed,'empty',v_empty,'canInstall',coalesce(v_allowed,false) and v_empty and not v_installed);
end $fn$;
revoke all on function public.caterium_trial_demo_status(uuid) from public,anon;
grant execute on function public.caterium_trial_demo_status(uuid) to authenticated;
create or replace function public.caterium_install_trial_demo(p_workspace uuid)
returns jsonb language plpgsql security definer set search_path=public,pg_temp
as $fn$
declare
v_state public.sun_app_state%rowtype; v_status jsonb; v_seed jsonb; v_storage jsonb; v_moves jsonb; v_marker jsonb;
begin
if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
v_status:=public.caterium_trial_demo_status(p_workspace);
if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if;
if (v_status->>'installed')::boolean then return v_status; end if;
if not (v_status->>'empty')::boolean then raise exception 'Каталог, склад или поставщики уже заполнены. Данные сохранены без изменений'; end if;
select * into v_state from public.sun_app_state where workspace_id=p_workspace for update;
if not found then raise exception 'Рабочая база не найдена'; end if;
v_seed:=public.caterium_trial_demo_seed_v1();
select coalesce(jsonb_agg(jsonb_build_object('id','demo-v1-opening-'||(p->>'id'),'productId',p->>'id','productName',p->>'name','unit',p->>'unit',
'type','set','qty',p->'qty','setTo',p->'qty','cost',p->'cost','date',current_date::text,'createdAt',now(),
'supplierId',p->>'supplierId','note','Учебный начальный остаток')),'[]'::jsonb) into v_moves
from jsonb_array_elements(v_seed->'stock') p where (p->>'qty')::numeric>0;
v_marker:=jsonb_build_object('version',1,'batch','trial-demo-v1','installedAt',now(),'scenario',v_seed->'scenario');
v_storage:=coalesce(v_state.payload->'storage','{}'::jsonb)||jsonb_build_object(
'sunBoxes',jsonb_build_object('t','j','v',v_seed->'boxes'),
'sunStock',jsonb_build_object('t','j','v',v_seed->'stock'),
'sunSuppliers',jsonb_build_object('t','j','v',v_seed->'suppliers'),
'sunStockMoves',jsonb_build_object('t','j','v',v_moves),
'sunTrialDemoV1',jsonb_build_object('t','j','v',v_marker));
perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-demo-v1',v_state.revision);
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details)
values(auth.uid(),'trial_demo.install',p_workspace,jsonb_build_object('version',1,'boxes',10));
return public.caterium_trial_demo_status(p_workspace);
end $fn$;
revoke all on function public.caterium_install_trial_demo(uuid) from public,anon;
grant execute on function public.caterium_install_trial_demo(uuid) to authenticated;
create or replace function public.caterium_seed_new_trial()
returns trigger language plpgsql security definer set search_path=public,pg_temp
as $fn$
begin
if coalesce((public.caterium_trial_demo_status(new.workspace_id)->>'canInstall')::boolean,false) then
perform public.caterium_install_trial_demo(new.workspace_id);
end if;
return new;
end $fn$;
revoke all on function public.caterium_seed_new_trial() from public,anon,authenticated;
drop trigger if exists caterium_seed_new_trial on public.caterium_trial_redemptions;
create trigger caterium_seed_new_trial after insert on public.caterium_trial_redemptions
for each row execute function public.caterium_seed_new_trial();
notify pgrst,'reload schema';
commit;

View File

@ -0,0 +1,36 @@
// Fictional items for empty trial tabs. Native category IDs must not follow visual order.
import {boxes} from './trial-demo-data.mjs';
const round=n=>Math.round(n*1000)/1000;
const mixes=[
['salmon-caprese','Премиум-сет «Лосось и капрезе»',3500,18,['salmon-cream','caprese']],
['meat-cheese','Премиум-сет «Мясо и сыры»',3900,0,['meat-assortment','cheese-fruit']],
['mini-buffet','Премиум-сет «Мини-фуршет»',2900,22,['bruschetta-tomato','mushroom-tartlet']]
];
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();
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));
return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:`demo/images/extras-${key}.webp`,demo:true,
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,
steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'],
allergens:[...new Set(sources.flatMap(b=>b.ttk.allergens))],ingredientCost:Math.round(rows.reduce((s,r)=>s+r.gross*r.unitCost,0)*100)/100,note:'Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет.'}};
});
const specs=[
// key, category, sale name, sale price, stock name, purchase cost, unit, supplier, description
['water',3,'Вода негазированная, 0,5 л',90,'Демо: вода негазированная 0,5 л',35,'шт.','grocery','Одна бутылка 0,5 л'],
['sparkling',3,'Вода газированная, 0,5 л',100,'Демо: вода газированная 0,5 л',40,'шт.','grocery','Одна бутылка 0,5 л'],
['juice',3,'Яблочный сок, 1 л',220,'Демо: сок яблочный 1 л',110,'шт.','grocery','Одна упаковка 1 л'],
['mors',3,'Клюквенный морс, 1 л',280,'Демо: морс клюквенный 1 л',140,'шт.','grocery','Одна бутылка готового морса 1 л'],
['plate',1,'Тарелка одноразовая, 1 шт.',25,'Демо: тарелка одноразовая',12,'шт.','pack','Одна сервировочная тарелка'],
['fork',1,'Вилка одноразовая, 1 шт.',10,'Демо: вилка одноразовая',4,'шт.','pack','Одна вилка'],
['glass',1,'Стакан одноразовый, 250 мл',15,'Демо: стакан одноразовый 250 мл',6,'шт.','pack','Один стакан'],
['napkin',1,'Салфетка сервировочная, 1 шт.',10,'Демо: салфетка сервировочная',3,'шт.','pack','Одна бумажная салфетка'],
['ice',2,'Лёд пищевой, пакет 1 кг',180,'Демо: лёд пищевой',70,'кг','grocery','Один пакет пищевого льда, 1 кг'],
['tablecloth',2,'Скатерть одноразовая, 1 шт.',250,'Демо: скатерть одноразовая',110,'шт.','pack','Одна одноразовая скатерть 120 × 180 см'],
['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 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 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}));
export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock};

48
ops/demo/trial-extras.sql Normal file
View File

@ -0,0 +1,48 @@
-- Generated by ops/demo/build-trial-extras.mjs. Fill empty trial categories only.
begin;
create or replace function public.caterium_trial_extras_seed_v1()
returns jsonb language sql immutable security definer set search_path=public,pg_temp
as $fn$ select $demo$__EXTRAS_JSON__$demo$::jsonb $fn$;
revoke all on function public.caterium_trial_extras_seed_v1() from public,anon,authenticated;
__STATUS_FUNCTION__
create or replace function public.caterium_install_trial_extras(p_workspace uuid)
returns jsonb language plpgsql security definer set search_path=public,pg_temp
as $fn$
declare
v_state public.sun_app_state%rowtype; v_status jsonb; v_storage jsonb; v_seed jsonb;
v_catalog jsonb; v_stock jsonb; v_added jsonb; v_products jsonb; v_marker jsonb;
begin
if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
v_status:=public.caterium_trial_demo_status(p_workspace);
if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if;
if not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if;
select * into v_state from public.sun_app_state where workspace_id=p_workspace for update;
v_storage:=v_state.payload->'storage';
if coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')='1' then return v_status; end if;
v_catalog:=coalesce(v_storage#>'{sunBoxes,v}','[]'::jsonb);v_stock:=coalesce(v_storage#>'{sunStock,v}','[]'::jsonb);
if jsonb_typeof(v_catalog)<>'array' or jsonb_typeof(v_stock)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if;
v_seed:=public.caterium_trial_extras_seed_v1();
select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_added
from jsonb_array_elements(v_seed->'items') with ordinality as x(n,ord)
where not exists(select 1 from jsonb_array_elements(v_catalog) old where coalesce(old->>'category','0')=n->>'category' or old->>'id'=n->>'id');
select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_products
from jsonb_array_elements(v_seed->'stock') with ordinality as x(n,ord)
where not exists(select 1 from jsonb_array_elements(v_stock) old where old->>'id'=n->>'id' or (old->>'name'=n->>'name' and old->>'unit'=n->>'unit'))
and exists(select 1 from jsonb_array_elements(v_added) i cross join lateral jsonb_array_elements(i->'ingredients') r where r->>0=n->>'name' and r->>2=n->>'unit');
v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('extrasVersion',1,'extrasInstalledAt',now());
v_storage:=v_storage||jsonb_build_object('sunBoxes',jsonb_build_object('t','j','v',v_catalog||v_added),'sunStock',jsonb_build_object('t','j','v',v_stock||v_products),'sunTrialDemoV1',jsonb_build_object('t','j','v',v_marker));
perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-extras-v1',v_state.revision);
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details)
values(auth.uid(),'trial_demo.extras_install',p_workspace,jsonb_build_object('version',1,'items',jsonb_array_length(v_added)));
return public.caterium_trial_demo_status(p_workspace);
end $fn$;
revoke all on function public.caterium_install_trial_extras(uuid) from public,anon;
grant execute on function public.caterium_install_trial_extras(uuid) to authenticated;
__BANQUET_FUNCTION__
__INSTALL_FUNCTION__
notify pgrst,'reload schema';
commit;

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

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

View File

@ -0,0 +1,47 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {execFileSync} from 'node:child_process';
import {chromium} from '@playwright/test';
import {boxes} from '../demo/trial-demo-data.mjs';
// Local, fictional fixtures only. Never authenticates or writes customer orders.
const phase=process.argv[2]||'after';
const root=path.resolve('..','caterium-pdf-20260918','tmp','pdfs',phase);
await fs.mkdir(root,{recursive:true});
const browser=await chromium.launch();
const page=await browser.newPage();
await page.route(/https:\/\/(?!127\.0\.0\.1)/,r=>r.abort());
await page.goto('http://127.0.0.1:4173/index.html',{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>Boolean(window.sunClientOfferDebugPdfPages));
const image=async file=>'data:image/'+(file.endsWith('.png')?'png':'jpeg')+';base64,'+(await fs.readFile(path.resolve('public',file))).toString('base64');
const items=await Promise.all(boxes.map(async(b,i)=>({...b,categoryId:0,categoryName:'Боксы',qty:i===0?2:1,unitPrice:b.price,sum:b.price*(i===0?2:1),photoData:await image(b.photo)})));
const base=items.reduce((n,i)=>n+i.sum,0);
const snapshot={brandName:'Солнце Кейтеринг',brandCity:'Санкт-Петербург',brandContacts:'Ваш менеджер · +7 (900) 000-00-00',logo:await image('sun-logo.png'),client:'Анна и Михаил',event:'Вечер в кругу друзей',date:'2026-10-24',time:'18:00',guests:20,items,pricing:{base,manual:1500,promo:0,discount:1500,itemsTotal:base-1500,delivery:1500,total:base},foodGrams:11000,foodPieces:192,controlLines:['Согласуем время и адрес доставки','Подготовим заказ к вашему мероприятию'],extraServices:['Сервировка стола','Обслуживание мероприятия'],finalGallery:[await image('offer-gallery/001.jpg'),await image('offer-gallery/002.jpg')]};
if(phase==='thumbs'){snapshot.brandName='Caterium';snapshot.brandCity='';snapshot.logo='';snapshot.client='Анна и Михаил'}
await page.evaluate(s=>window.__pdfFixture=s,snapshot);
const ids=await page.evaluate(()=>[...SunClassicOfferPDFV1767.CLASSIC_IDS,...SunClassicOfferPDFV1767.ARCHIVE_IDS,...SunSignatureOfferPDFV18.SIGNATURE_IDS]);
if(phase==='early'){
const source=execFileSync('git',['show','a3616b7:public/core/classic-offer-pdf-v1767.js'],{encoding:'utf8'});
await page.evaluate(()=>{delete window.SunClassicOfferPDFV1767});
await page.addScriptTag({content:source});
}
const report=[];
for(const id of phase==='early'?ids.slice(0,10):ids){
const result=await page.evaluate(async({id,phase})=>{
const s={...window.__pdfFixture,offerTemplateId:id};
const pages=phase==='early'?[await SunClassicOfferPDFV1767.renderCover(s,id)]:await sunClientOfferDebugPdfPages(s);
const jpg=pages.map(c=>({width:c.width,height:c.height,bytes:Uint8Array.from(atob(c.toDataURL('image/jpeg',.94).split(',')[1]),c=>c.charCodeAt(0))}));
const blob=SunPdfEngine.fromJpegs(jpg);
const pdf=await new Promise(resolve=>{const r=new FileReader();r.onload=()=>resolve(r.result.split(',')[1]);r.readAsDataURL(blob)});
const thumb=document.createElement('canvas');thumb.width=320;thumb.height=452;thumb.getContext('2d').drawImage(pages[0],0,0,320,452);
const thumbnail=thumb.toDataURL('image/jpeg',.88).split(',')[1];
const info={id,pages:pages.length,width:pages[0].width,cover:pages[0].dataset.sunProposalTemplate||pages[0].dataset.sunClassicTemplate||pages[0].dataset.sunSignatureTemplate||null,layout:pages.map(p=>p.__proposalLayout||null)};
pages.forEach(c=>{c.width=0;c.height=0});
return {pdf,info,thumbnail};
},{id,phase});
if(phase==='thumbs')await fs.writeFile(path.resolve('public','offer-templates','quality-'+id+'.jpg'),Buffer.from(result.thumbnail,'base64'));
else await fs.writeFile(path.join(root,id+'.pdf'),Buffer.from(result.pdf,'base64'));
report.push(result.info);console.log(id,result.info.pages,result.info.cover);
}
await fs.writeFile(path.join(root,'report.json'),JSON.stringify(report,null,2));
await browser.close();

60
ops/pdf/build-showcase.py Normal file
View File

@ -0,0 +1,60 @@
"""Overview plus unchanged PDFs exported by the application. Fictional order data."""
from pathlib import Path
import json
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from pypdf import PdfReader, PdfWriter
root=Path(__file__).resolve().parents[3]/'caterium-pdf-20260918'
audit=root/'tmp'/'pdfs'/'after'
output=root/'output'/'pdf';output.mkdir(parents=True,exist_ok=True)
pdfmetrics.registerFont(TTFont('Body','C:/Windows/Fonts/arial.ttf'))
pdfmetrics.registerFont(TTFont('Title','C:/Windows/Fonts/georgia.ttf'))
names=['Минимализм','Luxury Dark','Editorial Magazine','Warm Sun','Bento Cards','Event Story','Food First','Personal Letter','Event Ticket','Solar Experience','Ночной минимализм','Изумрудная классика','Изумрудный акцент','Кремовая классика','Неоновое меню','Изумрудные круги','Тёмный чек-лист','Гастро-витрина','Изумруд и золото','Премиум тёмный','Премиум изумрудный']
rows=json.loads((audit/'report.json').read_text(encoding='utf8'))
overview=root/'tmp'/'pdfs'/'overview.pdf'
W,H=595.28,841.89
c=canvas.Canvas(str(overview),pagesize=(W,H))
c.setTitle('Caterium - обновлённые предложения клиенту')
def background():
c.setFillColor(HexColor('#faf7ef'));c.rect(0,0,W,H,fill=1,stroke=0)
c.setFillColor(HexColor('#20362d'))
def text(x,y,value,size=12,font='Body'):
c.setFont(font,size);c.drawString(x,y,value)
background()
text(44,H-66,'Caterium',17)
text(44,H-150,'Предложения,',38,'Title')
text(44,H-198,'которые хочется',38,'Title')
text(44,H-246,'отправить клиенту',38,'Title')
text(44,H-304,'Обзор 21 стиля и три полных образца',15)
c.setStrokeColor(HexColor('#b19464'));c.line(44,H-340,W-44,H-340)
text(44,H-380,'Что изменилось',20,'Title')
for i,line in enumerate(['Вернули композиции первых обложек и развели стили.', 'Выровняли поля, цены и карточки меню.', 'Сохранили полные названия, состав, вес и расчёт стоимости.', 'Шрифты загружаются вместе с приложением.', 'Предпросмотр и скачанный PDF используют одни страницы.']):
text(44,H-418-i*26,line,11)
text(44,190,'В этом файле',20,'Title')
text(44,158,'Стр. 2-4: обзор всех вариантов оформления.',11)
starts={};page_no=5
selected=['light','editorial-grid','premium-dark']
for id in selected:
i=next(i for i,r in enumerate(rows) if r['id']==id);count=rows[i]['pages'];starts[id]=page_no
text(44,130-selected.index(id)*24,f'Стр. {page_no}-{page_no+count-1}: {names[i]}.',11);page_no+=count
text(44,39,'18 сентября 2026 · Учебный заказ, не клиентская заявка',9)
c.showPage()
for start in range(0,len(rows),9):
background();text(34,H-44,'Коллекция оформлений',25,'Title');text(34,H-69,f'{start+1}-{min(start+9,len(rows))} из 21',11)
for i,row in enumerate(rows[start:start+9]):
col=i%3;line=i//3;x=34+col*178;y=H-106-line*235
c.drawImage(str(audit/(row['id']+'-1.png')),x,y-209,width=148,height=209)
text(x,y-224,f'{start+i+1:02d} {names[start+i]}',8.4)
text(34,27,'Полные примеры далее. Любой стиль доступен в приложении.',9)
c.showPage()
c.save()
writer=PdfWriter();writer.append(PdfReader(str(overview)))
for id in selected:writer.append(PdfReader(str(audit/(id+'.pdf'))))
writer.add_metadata({'/Title':'Caterium - коллекция предложений клиенту','/Author':'Caterium'})
target=output/'Caterium-предложения-образцы.pdf'
writer.write(str(target))
print(target, 'pages:',len(writer.pages))

29
ops/pdf/render-audit.py Normal file
View File

@ -0,0 +1,29 @@
import sys, json
from pathlib import Path
import pypdfium2 as pdfium
from PIL import Image, ImageDraw, ImageFont
base=Path(__file__).resolve().parents[3]/'caterium-pdf-20260918'/'tmp'/'pdfs'
for phase in sys.argv[1:] or ['after']:
root=base/phase
rows=json.loads((root/'report.json').read_text(encoding='utf8'))
covers=[]; pages=[]
for row in rows:
doc=pdfium.PdfDocument(str(root/(row['id']+'.pdf')))
for n in range(len(doc)):
im=doc[n].render(scale=1.25).to_pil().convert('RGB')
im.save(root/f"{row['id']}-{n+1}.png")
pages.append((row['id']+f' / {n+1}',im))
if n==0: covers.append(pages[-1])
def sheet(entries,name,cols=4):
tw,th=250,380
out=Image.new('RGB',(tw*cols,th*((len(entries)+cols-1)//cols)),'#dce1e5')
d=ImageDraw.Draw(out)
for i,(label,im) in enumerate(entries):
tile=im.copy(); tile.thumbnail((tw-12,th-32))
x=(i%cols)*tw; y=(i//cols)*th
out.paste(tile,(x+6,y+25));d.text((x+6,y+6),label,fill='black')
out.save(root/name)
sheet(covers,'covers.png')
for start in range(0,len(pages),12):sheet(pages[start:start+12],f'pages-{start//12+1}.png')
print(phase,len(pages),'pages rendered')

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

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

View File

@ -0,0 +1,52 @@
-- Explicit API grants, with RLS on every application table.
do $$ declare f record;begin
for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace
where n.nspname='public' and (p.proname like 'sun_%' or p.proname like 'caterium_%') loop
execute format('revoke execute on function %s from public,anon',f.signature);
end loop;
end $$;
grant execute on function public.caterium_trial_promo_preview(text,text),public.sun_invite_preview_v27(uuid) to anon,authenticated;
grant execute on function public.sun_v17_log_error(uuid,text,text,text,text,text,jsonb) to authenticated;
-- No direct full-state writes/reads: retain the subscription/permission checks.
revoke all on public.sun_app_state from anon,authenticated;
-- Enforce MFA even if a caller uses an older public platform RPC name.
do $$ declare f record;definition text;begin
for f in select p.oid from pg_proc p join pg_namespace n on n.oid=p.pronamespace
where n.nspname='public' and p.proname like 'sun_platform_%' and p.prosrc like '%if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;%' loop
definition:=replace(pg_get_functiondef(f.oid),'if not public.sun_is_platform_admin() then raise exception ''Platform administrator required''; end if;','perform public.sun_require_platform_admin_aal2();');
execute definition;
end loop;
end $$;
create or replace function public.sun_v17_entity_snapshot(p_workspace uuid)
returns jsonb language plpgsql stable security definer set search_path=public as $$
declare is_admin boolean:=public.sun_is_platform_admin();
begin
if is_admin then perform public.sun_require_platform_admin_aal2();
elsif public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
if not is_admin and public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась'; end if;
return jsonb_build_object(
'orders',case when is_admin or (public.sun_has_permission(p_workspace,'orders.view') and public.sun_workspace_has_feature(p_workspace,'orders')) then coalesce((select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id) from public.sun_v17_orders where workspace_id=p_workspace),'[]') else '[]'::jsonb end,
'catalog',case when is_admin or (public.sun_has_permission(p_workspace,'catalog.view') and public.sun_workspace_has_feature(p_workspace,'catalog_view')) then coalesce((select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id) from public.sun_v17_catalog_items where workspace_id=p_workspace),'[]') else '[]'::jsonb end,
'meta',coalesce((select to_jsonb(m) from public.sun_v17_workspace_meta m where workspace_id=p_workspace),'{}'));
end $$;
-- Authenticated roles can invoke public API guards but cannot invoke snapshot internals.
grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated;
revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_require_platform_admin_aal2() from public,anon,authenticated;
-- Keep owner-registration metadata in the same typed format as the sync client.
do $$declare definition text;begin
definition:=pg_get_functiondef('public.sun_create_workspace(text)'::regprocedure);
definition:=replace(definition,'''sunCompanyProfileV1'',v_profile::text','''sunCompanyProfileV1'',jsonb_build_object(''t'',''j'',''v'',v_profile)');
execute definition;
end $$;
-- Match client visibility to the same granular permission as state reads.
do $$declare definition text;begin
definition:=pg_get_functiondef('public.sun_v17_clients_snapshot_v1773(uuid)'::regprocedure);
definition:=replace(definition,'if not public.sun_workspace_has_feature(p_workspace,''clients'') then','if not public.sun_has_permission(p_workspace,''clients.view'') or public.sun_subscription_access_mode(p_workspace)=''blocked'' or not public.sun_workspace_has_feature(p_workspace,''clients'') then');
execute definition;
end $$;

View File

@ -0,0 +1,112 @@
-- Missing production RPC contracts reconstructed for the empty Caterium project.
create table public.caterium_trial_promos (
id uuid primary key default gen_random_uuid(),code text not null unique,
client_email text,trial_days integer not null default 14 check(trial_days between 1 and 365),
plan_id text not null default 'full' references public.sun_plans(id),
max_uses integer not null default 1 check(max_uses between 1 and 10000),use_count integer not null default 0,
is_active boolean not null default true,valid_until timestamptz,note text,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now(),updated_at timestamptz not null default now()
);
create table public.caterium_trial_redemptions (
id uuid primary key default gen_random_uuid(),promo_id uuid not null references public.caterium_trial_promos(id),
workspace_id uuid not null unique references public.sun_workspaces(id) on delete cascade,
user_id uuid not null unique references auth.users(id) on delete cascade,email text,trial_ends_at timestamptz,
created_at timestamptz not null default now()
);
alter table public.caterium_trial_promos enable row level security;
alter table public.caterium_trial_redemptions enable row level security;
revoke all on public.caterium_trial_promos,public.caterium_trial_redemptions from anon,authenticated;
create function public.caterium_normalize_trial_code(p_code text) returns text language sql immutable set search_path=public as $$select upper(regexp_replace(trim(coalesce(p_code,'')),'[[:space:]]','','g'))$$;
create function public.caterium_trial_promo_preview(p_code text,p_email text default null)
returns jsonb language plpgsql stable security definer set search_path=public as $$
declare p public.caterium_trial_promos%rowtype;
begin
select * into p from public.caterium_trial_promos where code=public.caterium_normalize_trial_code(p_code);
if not found or not p.is_active or (p.valid_until is not null and p.valid_until<=now()) or p.use_count>=p.max_uses
or (p.client_email is not null and p.client_email<>lower(trim(coalesce(p_email,'')))) then
return jsonb_build_object('valid',false,'reason','Промокод недействителен для этого email или срок его действия истёк');
end if;
return jsonb_build_object('valid',true,'trial_days',p.trial_days,'plan',p.plan_id);
end $$;
create function public.sun_dev_create_trial_promo(p_code text default null,p_email text default null,p_trial_days integer default 14,p_valid_days integer default 7,p_max_uses integer default 1,p_plan text default 'full',p_note text default null)
returns jsonb language plpgsql security definer set search_path=public as $$
declare p public.caterium_trial_promos%rowtype; c text;
begin
perform public.sun_require_platform_admin_aal2();
c:=coalesce(nullif(public.caterium_normalize_trial_code(p_code),''),'CTM-'||upper(replace(gen_random_uuid()::text,'-',''))::varchar(16));
if c !~ '^[A-Z0-9-]{3,32}$' then raise exception 'Некорректный промокод'; end if;
if p_valid_days not between 1 and 365 then raise exception 'Некорректный срок'; end if;
insert into public.caterium_trial_promos(code,client_email,trial_days,valid_until,max_uses,plan_id,note,created_by)
values(c,nullif(lower(trim(p_email)),''),p_trial_days,now()+make_interval(days=>p_valid_days),p_max_uses,p_plan,p_note,auth.uid()) returning * into p;
perform public.sun_platform_log_event('trial_promo.create',null,null,jsonb_build_object('promo_id',p.id));
return jsonb_build_object('promo_id',p.id,'code',p.code,'trial_days',p.trial_days,'valid_until',p.valid_until);
end $$;
create function public.sun_dev_list_trial_promos(p_limit integer default 300)
returns table(promo_id uuid,code text,client_email text,trial_days integer,valid_until timestamptz,max_uses integer,use_count integer,is_active boolean,last_redeemed_at timestamptz,last_workspace_name text,last_redeemed_email text)
language plpgsql stable security definer set search_path=public as $$
begin
perform public.sun_require_platform_admin_aal2();
return query select p.id,p.code,p.client_email,p.trial_days,p.valid_until,p.max_uses,p.use_count,p.is_active,r.created_at,w.name,r.email
from public.caterium_trial_promos p left join lateral (select x.* from public.caterium_trial_redemptions x where x.promo_id=p.id order by x.created_at desc limit 1) r on true
left join public.sun_workspaces w on w.id=r.workspace_id order by p.created_at desc limit greatest(1,least(coalesce(p_limit,300),1000));
end $$;
create function public.sun_dev_set_trial_promo_active(p_promo uuid,p_active boolean)
returns void language plpgsql security definer set search_path=public as $$
begin
perform public.sun_require_platform_admin_aal2();
update public.caterium_trial_promos set is_active=p_active,updated_at=now() where id=p_promo;
if not found then raise exception 'Промокод не найден'; end if;
perform public.sun_platform_log_event('trial_promo.set_active',null,null,jsonb_build_object('promo_id',p_promo,'active',p_active));
end $$;
create function public.caterium_platform_create_company(p_name text,p_owner_email text default null,p_plan text default 'full',p_days integer default 30,p_mode text default 'empty')
returns jsonb language plpgsql security definer set search_path=public,auth as $$
declare v_ws uuid; v_owner uuid; v_email text:=nullif(lower(trim(p_owner_email)),''); v_token uuid;
begin
perform public.sun_require_platform_admin_aal2();
if p_days not between 1 and 3650 then raise exception 'Invalid subscription duration'; end if;
select id into v_owner from auth.users where lower(email)=v_email and email_confirmed_at is not null;
insert into public.sun_workspaces(name,created_by) values(coalesce(nullif(trim(p_name),''),'Новая компания'),coalesce(v_owner,auth.uid())) returning id into v_ws;
if v_owner is not null then
insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions,display_name)
values(v_ws,v_owner,'admin',true,public.sun_role_default_permissions('admin'),split_part(v_email,'@',1));
elsif v_email is not null then
insert into public.caterium_company_owner_invites(workspace_id,email) values(v_ws,v_email) returning token into v_token;
end if;
insert into public.sun_app_state(workspace_id,client_id) values(v_ws,'platform-bootstrap');
insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source)
values(v_ws,p_plan,'active',now(),now()+make_interval(days=>p_days),now()+make_interval(days=>p_days+7),'platform');
return jsonb_build_object('workspace_id',v_ws,'owner_user_id',v_owner,'owner_email',v_email,'owner_invite_token',v_token);
end $$;
-- Recover the sun_dev_* names used by the client, preserving the retained
-- server implementation and requiring AAL2 at every platform boundary.
do $recovery$
declare entry record; f record; call_args text; command text;
begin
for entry in select * from (values
('sun_dev_dashboard','sun_platform_dashboard'),('sun_dev_list_activity','sun_platform_list_activity'),
('sun_dev_list_companies','sun_platform_list_companies_v22'),('sun_dev_list_users','sun_platform_list_users_v22'),
('sun_dev_list_errors','sun_platform_list_errors_v22'),('sun_dev_support_snapshot','sun_platform_support_snapshot'),
('sun_dev_workspace_diagnostics','sun_platform_workspace_diagnostics'),('sun_dev_list_workspace_features','sun_platform_list_workspace_features'),
('sun_dev_log_event','sun_platform_log_event'),('sun_dev_set_plan_feature','sun_platform_set_plan_feature'),
('sun_dev_set_plan_max_members','sun_platform_set_plan_max_members'),('sun_dev_seed_workspace_catalog','sun_platform_seed_workspace_catalog'),
('sun_dev_reset_feature_override','sun_platform_reset_feature_override'),('sun_dev_create_company','sun_platform_create_company_v22'),
('sun_dev_set_subscription','sun_platform_set_subscription'),('sun_dev_set_feature_override','sun_platform_set_feature_override')
) names(alias_name,source_name) loop
select p.*,pg_get_function_arguments(p.oid) as args,pg_get_function_result(p.oid) as result into strict f
from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname=entry.source_name;
select coalesce(string_agg('$'||i,',' order by i),'') into call_args from generate_series(1,f.pronargs) i;
command:=case when f.proretset then 'return query select * from' when f.prorettype='void'::regtype then 'perform' else 'return' end;
execute format('create function public.%I(%s) returns %s language plpgsql security definer set search_path=public,auth as $body$ begin perform public.sun_require_platform_admin_aal2(); %s public.%I(%s); end $body$',entry.alias_name,f.args,f.result,command,entry.source_name,call_args);
end loop;
end $recovery$;
revoke all on function public.caterium_normalize_trial_code(text),public.caterium_trial_promo_preview(text,text),public.caterium_platform_create_company(text,text,text,integer,text) from public,anon,authenticated;
grant execute on function public.caterium_trial_promo_preview(text,text) to anon,authenticated;
do $$declare f record;begin
for f in select p.oid::regprocedure as signature from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname like 'sun_dev_%' loop
execute format('revoke all on function %s from public,anon',f.signature);
execute format('grant execute on function %s to authenticated',f.signature);
end loop;
end $$;

View File

@ -0,0 +1,136 @@
-- Reconstructed from the production client and retained SQL, 2026-09-17.
-- Empty-project recovery only; original v17 foundation was not in Git history.
create table public.sun_v17_orders (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
order_id text not null, data jsonb not null, version bigint not null default 1,
created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,order_id)
);
create table public.sun_v17_catalog_items (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
item_id text not null, data jsonb not null, version bigint not null default 1,
created_at timestamptz not null default now(), updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id) on delete set null, primary key(workspace_id,item_id)
);
create table public.sun_v17_clients (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
client_key text not null,name text,phone text,latest_address text,data jsonb not null default '{}',
version bigint not null default 1,created_at timestamptz not null default now(),updated_at timestamptz not null default now(),
primary key(workspace_id,client_key)
);
create table public.sun_v17_settings (
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
key text not null,value jsonb,version bigint not null default 1,updated_at timestamptz not null default now(),
primary key(workspace_id,key)
);
create table public.sun_v17_workspace_meta (
workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade,
schema_version integer not null default 17,last_backup_on date,legacy_revision bigint not null default 0,
migrated_at timestamptz not null default now(),updated_at timestamptz not null default now()
);
create table public.sun_v17_change_events (
id bigint generated by default as identity primary key,
workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
entity text not null,entity_key text,operation text not null,version bigint,client_id text,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now()
);
create index sun_v17_changes_workspace_id_idx on public.sun_v17_change_events(workspace_id,id);
create table public.sun_v17_backups (
id uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
kind text not null,label text not null default '',snapshot jsonb not null,
created_by uuid references auth.users(id) on delete set null,created_at timestamptz not null default now()
);
create index sun_v17_backups_workspace_created_idx on public.sun_v17_backups(workspace_id,created_at desc);
create table public.sun_v17_error_events (
id uuid primary key default gen_random_uuid(),workspace_id uuid references public.sun_workspaces(id) on delete cascade,
user_id uuid references auth.users(id) on delete set null,client_id text,app_version text,level text,message text,
stack text,context jsonb not null default '{}',created_at timestamptz not null default now()
);
create index sun_v17_errors_workspace_created_idx on public.sun_v17_error_events(workspace_id,created_at desc);
create table public.caterium_company_owner_invites (
token uuid primary key default gen_random_uuid(),workspace_id uuid not null references public.sun_workspaces(id) on delete cascade,
email text not null,created_at timestamptz not null default now(),expires_at timestamptz not null default now()+interval '7 days',
used_at timestamptz,used_by uuid references auth.users(id) on delete set null
);
do $$ declare t text; begin
foreach t in array array['sun_v17_orders','sun_v17_catalog_items','sun_v17_clients','sun_v17_settings','sun_v17_workspace_meta','sun_v17_change_events','sun_v17_backups','sun_v17_error_events','caterium_company_owner_invites'] loop
execute format('alter table public.%I enable row level security',t);
execute format('revoke all on public.%I from anon,authenticated',t);
end loop;
end $$;
-- Browser data access uses the permission-checked RPCs. Realtime only reveals an event.
grant select on public.sun_v17_change_events to authenticated;
create policy sun_v17_change_read on public.sun_v17_change_events for select to authenticated using(public.sun_member_role(workspace_id) is not null);
alter publication supabase_realtime add table public.sun_v17_change_events;
create function public.sun_my_workspaces()
returns table(id uuid,name text,role text,display_name text,is_active boolean,permissions jsonb)
language sql stable security definer set search_path=public as $$
select w.id,w.name,m.role,m.display_name,m.is_active,public.sun_role_default_permissions(m.role)||coalesce(m.permissions,'{}')
from public.sun_workspace_members m join public.sun_workspaces w on w.id=m.workspace_id
where m.user_id=auth.uid() and m.is_active order by w.created_at,w.id
$$;
create function public.sun_v17_mirror_legacy(p_workspace uuid,p_payload jsonb,p_client_id text default null)
returns void language plpgsql security definer set search_path=public as $$
declare canonical jsonb; r record; v_data jsonb; v_ids text[]; v_key text; v_rev bigint;
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
select payload,revision into canonical,v_rev from public.sun_app_state where workspace_id=p_workspace;
if canonical is null then return; end if;
-- Always mirror the validated server row. The supplied legacy argument is never trusted.
for r in select * from (values ('sunOrders','sun_v17_orders','order_id','order'),('sunBoxes','sun_v17_catalog_items','item_id','catalog')) as x(storage_key,table_name,id_column,entity) loop
v_data:=coalesce(canonical#>array['storage',r.storage_key,'v'],'[]'::jsonb);
if jsonb_typeof(v_data)<>'array' then raise exception 'Invalid entity array: %',r.storage_key; end if;
if exists(select 1 from jsonb_array_elements(v_data) e where nullif(e->>'id','') is null) then raise exception 'Entity ID is required'; end if;
if (select count(*) from jsonb_array_elements(v_data))<>(select count(distinct e->>'id') from jsonb_array_elements(v_data) e) then raise exception 'Duplicate entity ID'; end if;
select coalesce(array_agg(e->>'id'),'{}') into v_ids from jsonb_array_elements(v_data) e;
execute format('with gone as (delete from public.%I where workspace_id=$1 and not (%I=any($2)) returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''delete'',version+1,$4,auth.uid() from gone',r.table_name,r.id_column,r.id_column,r.id_column) using p_workspace,v_ids,r.entity,p_client_id;
execute format('with saved as (insert into public.%I as dest(workspace_id,%I,data,updated_by) select $1,e->>''id'',e,auth.uid() from jsonb_array_elements($2) e on conflict(workspace_id,%I) do update set data=excluded.data,version=dest.version+1,updated_at=now(),updated_by=auth.uid() where dest.data is distinct from excluded.data returning %I,version) insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) select $1,$3,%I,''upsert'',version,$4,auth.uid() from saved',r.table_name,r.id_column,r.id_column,r.id_column,r.id_column) using p_workspace,v_data,r.entity,p_client_id;
end loop;
delete from public.sun_v17_settings where workspace_id=p_workspace and not (canonical->'storage' ? key);
insert into public.sun_v17_settings as dest(workspace_id,key,value)
select p_workspace,key,value from jsonb_each(coalesce(canonical->'storage','{}')) where key not in ('sunOrders','sunBoxes')
on conflict(workspace_id,key) do update set value=excluded.value,version=dest.version+1,updated_at=now() where dest.value is distinct from excluded.value;
insert into public.sun_v17_workspace_meta(workspace_id,legacy_revision) values(p_workspace,v_rev)
on conflict(workspace_id) do update set legacy_revision=excluded.legacy_revision,updated_at=now();
end $$;
create function public.sun_save_app_state_v17(p_workspace uuid,p_payload jsonb,p_client_id text,p_expected_revision bigint default null)
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 current_revision bigint;
begin
if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
perform 1 from public.sun_workspaces w where w.id=p_workspace for update;
select s.revision into current_revision from public.sun_app_state s where s.workspace_id=p_workspace;
if p_expected_revision is not null and coalesce(current_revision,0)<>p_expected_revision then
raise exception using errcode='40001',message=format('SUN_CONFLICT expected=%s actual=%s',p_expected_revision,coalesce(current_revision,0));
end if;
perform public.sun_save_app_state(p_workspace,p_payload,p_client_id);
perform public.sun_v17_mirror_legacy(p_workspace,p_payload,p_client_id);
return query select * from public.sun_fetch_app_state(p_workspace);
end $$;
create function public.sun_v17_build_snapshot(p_workspace uuid)
returns jsonb language sql stable security definer set search_path=public as $$
select jsonb_build_object('version',17,'legacy',payload,'revision',revision,'created_at',now()) from public.sun_app_state where workspace_id=p_workspace
$$;
create function public.sun_v17_prune_backups(p_workspace uuid,p_keep integer default 30)
returns integer language plpgsql security definer set search_path=public as $$
declare n integer;
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
delete from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and id in
(select id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' order by created_at desc offset greatest(30,coalesce(p_keep,30)));
get diagnostics n=row_count; return n;
end $$;
create function public.sun_workspace_access_mode_internal_v28(p_workspace uuid) returns text language sql stable security definer set search_path=public as $$select public.sun_subscription_access_mode(p_workspace)$$;
create function public.sun_workspace_feature_internal_v28(p_workspace uuid,p_feature text) returns boolean language sql stable security definer set search_path=public as $$select public.sun_workspace_has_feature(p_workspace,p_feature)$$;
revoke all on function public.sun_v17_build_snapshot(uuid),public.sun_workspace_access_mode_internal_v28(uuid),public.sun_workspace_feature_internal_v28(uuid,text) from public,anon,authenticated;
revoke all on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) from public,anon;
grant execute on function public.sun_my_workspaces(),public.sun_save_app_state_v17(uuid,jsonb,text,bigint),public.sun_v17_mirror_legacy(uuid,jsonb,text),public.sun_v17_prune_backups(uuid,integer) to authenticated;

View File

@ -52,7 +52,7 @@ begin
insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at) insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at)
values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now()) values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now())
on conflict(workspace_id,client_key) do update set on conflict on constraint sun_v17_clients_pkey do update set
name=coalesce(excluded.name,sun_v17_clients.name), name=coalesce(excluded.name,sun_v17_clients.name),
phone=coalesce(excluded.phone,sun_v17_clients.phone), phone=coalesce(excluded.phone,sun_v17_clients.phone),
latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address), latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address),

View File

@ -0,0 +1,118 @@
-- Sun Catering v17.8 developer-panel additions.
-- Applied to the current project on 2026-09-01.
create or replace function public.sun_platform_list_users()
returns table(
user_id uuid,
email text,
last_sign_in_at timestamptz,
created_at timestamptz,
workspace_id uuid,
workspace_name text,
role text,
display_name text,
is_active boolean,
is_platform_admin boolean
)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
return query
select u.id,u.email::text,u.last_sign_in_at,u.created_at,m.workspace_id,w.name,m.role,m.display_name,m.is_active,
exists(select 1 from public.sun_platform_admins p where p.user_id=u.id)
from auth.users u
left join public.sun_workspace_members m on m.user_id=u.id
left join public.sun_workspaces w on w.id=m.workspace_id
order by coalesce(w.name,''),coalesce(m.display_name,u.email),u.email;
end;
$$;
revoke all on function public.sun_platform_list_users() from public;
grant execute on function public.sun_platform_list_users() to authenticated;
create or replace function public.sun_v17_create_backup(p_workspace uuid, p_kind text default 'manual'::text, p_label text default ''::text)
returns uuid
language plpgsql
security definer
set search_path='public'
as $$
declare v_id uuid; v_kind text:=lower(coalesce(p_kind,'manual'));
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if;
if v_kind not in ('daily','manual','pre_restore','migration') then raise exception 'Invalid backup kind'; end if;
if v_kind<>'daily' and not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
if v_kind='daily' and exists(select 1 from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date) then
select id into v_id from public.sun_v17_backups where workspace_id=p_workspace and kind='daily' and created_at::date=current_date order by created_at desc limit 1;
return v_id;
end if;
insert into public.sun_v17_backups(workspace_id,kind,label,snapshot,created_by)
values(p_workspace,v_kind,left(coalesce(p_label,''),240),public.sun_v17_build_snapshot(p_workspace),auth.uid()) returning id into v_id;
insert into public.sun_v17_workspace_meta(workspace_id,last_backup_on,updated_at) values(p_workspace,current_date,now())
on conflict(workspace_id) do update set last_backup_on=current_date,updated_at=now();
return v_id;
end;
$$;
create or replace function public.sun_v17_restore_backup(p_workspace uuid, p_backup uuid)
returns void
language plpgsql
security definer
set search_path='public'
as $$
declare s jsonb; legacy jsonb;
begin
if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if;
perform public.sun_v17_create_backup(p_workspace,'pre_restore','Автоматически перед восстановлением');
select snapshot into s from public.sun_v17_backups where id=p_backup and workspace_id=p_workspace;
if s is null then raise exception 'Backup not found'; end if;
legacy:=s->'legacy';
if legacy is null or jsonb_typeof(legacy)<>'object' then raise exception 'Backup has no legacy state'; end if;
perform public.sun_save_app_state(p_workspace,legacy,'backup-restore');
perform public.sun_v17_mirror_legacy(p_workspace,legacy,'backup-restore');
insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by)
values(p_workspace,'workspace','backup','restore','backup-restore',auth.uid());
end;
$$;
revoke all on function public.sun_v17_create_backup(uuid,text,text) from public;
revoke all on function public.sun_v17_restore_backup(uuid,uuid) from public;
grant execute on function public.sun_v17_create_backup(uuid,text,text) to authenticated;
grant execute on function public.sun_v17_restore_backup(uuid,uuid) to authenticated;
-- Company owner can attach an already registered account by email without invite codes.
create or replace function public.sun_owner_add_existing_member(p_workspace uuid,p_email text,p_role text default 'manager')
returns uuid
language plpgsql
security definer
set search_path='public'
as $$
declare
v_user uuid;
v_role text:=lower(coalesce(p_role,'manager'));
v_display text;
v_max integer;
v_count integer;
begin
if auth.uid() is null then raise exception 'Authentication required'; end if;
if public.sun_member_role(p_workspace)<>'admin' and not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Нет права управлять сотрудниками'; end if;
if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка не позволяет изменять пользователей'; end if;
if not public.sun_workspace_has_feature(p_workspace,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; end if;
if v_role not in ('manager','kitchen','courier','viewer') then raise exception 'Для сотрудника выберите рабочую роль'; end if;
select id,coalesce(nullif(raw_user_meta_data->>'name',''),split_part(email,'@',1)) into v_user,v_display
from auth.users where lower(email)=lower(trim(p_email)) limit 1;
if v_user is null then raise exception 'Аккаунт с таким email ещё не зарегистрирован'; end if;
select p.max_members into v_max from public.sun_workspace_subscriptions s join public.sun_plans p on p.id=s.plan_id where s.workspace_id=p_workspace;
select count(*)::int into v_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true;
if not exists(select 1 from public.sun_workspace_members where workspace_id=p_workspace and user_id=v_user and is_active=true) then
if v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%)',v_max; end if;
end if;
insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at)
values(p_workspace,v_user,v_role,v_display,true,public.sun_role_default_permissions(v_role),now())
on conflict(workspace_id,user_id) do update set role=excluded.role,display_name=coalesce(public.sun_workspace_members.display_name,excluded.display_name),is_active=true,permissions=excluded.permissions,updated_at=now();
return v_user;
end;
$$;
revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from public;
grant execute on function public.sun_owner_add_existing_member(uuid,text,text) to authenticated;

View File

@ -22,6 +22,7 @@ declare
v_trial_end timestamptz; v_trial_end timestamptz;
begin begin
if v_user is null then raise exception 'Authentication required'; end if; 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 if exists(select 1 from public.sun_workspace_members where user_id=v_user and is_active=true) then
raise exception 'Аккаунт уже относится к компании'; raise exception 'Аккаунт уже относится к компании';
end if; end if;
@ -56,7 +57,8 @@ begin
values(v_promo.id,v_workspace,v_user,v_email,v_trial_end); 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; update public.caterium_trial_promos set use_count=use_count+1,updated_at=now() where id=v_promo.id;
update auth.users set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name' where id=v_user; update auth.users set raw_user_meta_data=coalesce(raw_user_meta_data,'{}'::jsonb)-'promo_code'-'company_name' where id=v_user;
perform public.sun_platform_log_event('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)); 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));
return v_workspace; return v_workspace;
end; end;
$function$; $function$;

View File

@ -0,0 +1,97 @@
-- Sun Catering v17.9 developer settings.
-- Safe, additive migration for the existing v17 schema.
begin;
-- The platform administrator can inspect normalized health for any company.
-- Ordinary users remain limited to companies where they are active members.
create or replace function public.sun_v17_entity_snapshot(p_workspace uuid)
returns jsonb
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then
raise exception 'Access denied';
end if;
return jsonb_build_object(
'orders',coalesce((
select jsonb_agg(jsonb_build_object('id',order_id,'version',version,'data',data,'updated_at',updated_at) order by order_id)
from public.sun_v17_orders where workspace_id=p_workspace
),'[]'::jsonb),
'catalog',coalesce((
select jsonb_agg(jsonb_build_object('id',item_id,'version',version,'data',data,'updated_at',updated_at) order by item_id)
from public.sun_v17_catalog_items where workspace_id=p_workspace
),'[]'::jsonb),
'meta',coalesce((
select to_jsonb(meta) from public.sun_v17_workspace_meta meta where workspace_id=p_workspace
),'{}'::jsonb)
);
end;
$$;
-- The platform administrator can list backups for any company from the
-- protected developer panel. Members can still list their own company's rows.
create or replace function public.sun_v17_list_backups(p_workspace uuid, p_limit integer default 30)
returns table(id uuid, kind text, label text, created_at timestamptz, created_by uuid)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then
raise exception 'Access denied';
end if;
return query
select backup.id,backup.kind,backup.label,backup.created_at,backup.created_by
from public.sun_v17_backups backup
where backup.workspace_id=p_workspace
order by backup.created_at desc
limit greatest(1,least(coalesce(p_limit,30),100));
end;
$$;
-- Global technical error directory. It intentionally excludes stack/context
-- from the browser result; those fields can contain sensitive implementation data.
create or replace function public.sun_platform_list_errors(p_workspace uuid default null, p_limit integer default 80)
returns table(
id uuid,
workspace_id uuid,
workspace_name text,
user_id uuid,
client_id text,
app_version text,
level text,
message text,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path='public'
as $$
begin
if not public.sun_is_platform_admin() then
raise exception 'Platform administrator required';
end if;
return query
select err.id,err.workspace_id,company.name,err.user_id,err.client_id,err.app_version,err.level,err.message,err.created_at
from public.sun_v17_error_events err
left join public.sun_workspaces company on company.id=err.workspace_id
where p_workspace is null or err.workspace_id=p_workspace
order by err.created_at desc
limit greatest(1,least(coalesce(p_limit,80),200));
end;
$$;
revoke all on function public.sun_v17_entity_snapshot(uuid) from public, anon;
revoke all on function public.sun_v17_list_backups(uuid,integer) from public, anon;
revoke all on function public.sun_platform_list_errors(uuid,integer) from public, anon;
grant execute on function public.sun_v17_entity_snapshot(uuid) to authenticated;
grant execute on function public.sun_v17_list_backups(uuid,integer) to authenticated;
grant execute on function public.sun_platform_list_errors(uuid,integer) to authenticated;
commit;

View File

@ -0,0 +1,169 @@
-- 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

@ -1,5 +1,37 @@
# Timeweb fallback auto-deploy # Timeweb fallback auto-deploy
## Backend access without external browser dependencies
The browser loads the pinned Supabase SDK from `public/vendor/`, then sends
Auth, REST, Storage and Functions traffic to the same-origin PHP entry point
`https://app.caterium.ru/api/index.php?__caterium_path=...`.
The query route is deliberate: nginx can serve static extensions before Apache,
so rewriting a storage URL ending in `.jpg` is insufficient on this hosting.
`public/api/index.php` must be identical to `ops/timeweb/api-proxy.php`.
The app copy is deployed with the release; the dedicated API host copy is
installed separately at `/home/c/ci503744/public_html/api-proxy/index.php`.
Only `app.caterium.ru` and `api.caterium.ru` are accepted, with a fixed Supabase
upstream and the existing allowlist of backend paths. Browser authorization is
forwarded unchanged; RLS remains enforced. Responses are private/no-store, and
the service worker never caches `/api/`.
Safe reads and password login may retry on `api.caterium.ru`. Direct Supabase
fallback is disabled. Writes and refresh-token exchanges are not replayed;
uncertain saves retain the existing read/revision recovery path. The Supabase
client still uses the project URL internally, preserving existing auth sessions.
Updates use HTTP: a revision-only request every 20 seconds while visible, and
chat refresh every 10 seconds. Unchanged bases are not downloaded again. Requests
from the previous account are discarded. Typing/online indicators require a
future WebSocket-capable proxy and are not advertised by the HTTP mode.
Revision reads project `revision` from the existing `sun_fetch_app_state` RPC;
direct table access remains revoked. No database grants or schema changes are required.
This covers application data and media. Optional map/geocoding providers remain
external and do not gate login or order loading. If the login page itself cannot
open, diagnose DNS/TLS/operator connectivity separately.
Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel. Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel.
## Production target: app.caterium.ru ## Production target: app.caterium.ru

View File

@ -1,23 +1,26 @@
<?php <?php
/** /**
* api.caterium.ru -> Supabase managed backend reverse proxy. * Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic. * Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Realtime/WebSocket is intentionally NOT proxied here (see ops/timeweb/README.md). * Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk). * Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/ */
const UPSTREAM = 'https://cksuehzcimitsxmeloes.supabase.co'; const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const PUBLIC_BASE = 'https://api.caterium.ru';
const SERVE_HOST = 'api.caterium.ru';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/']; const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// Defense in depth: this script lives inside a document root shared with // The same source is deployed to the dedicated API host and app /api/index.php.
// caterium.ru (2-site plan limit). The root .htaccess only rewrites into // Never accept another Host or derive the upstream host from browser input.
// here for Host: api.caterium.ru, but a bare direct hit on this file's own
// path (under any host) must still refuse to act as a generic proxy.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); $requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
if ($requestHost !== SERVE_HOST) { $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404); http_response_code(404);
exit; exit;
} }
@ -29,12 +32,12 @@ const ALLOWED_ORIGINS = [
]; ];
const FORWARD_REQUEST_HEADERS = [ const FORWARD_REQUEST_HEADERS = [
'authorization', 'apikey', 'content-type', 'prefer', 'range', 'authorization', 'apikey', 'content-type', 'accept', 'prefer', 'range',
'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', 'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', 'cache-control',
]; ];
const STRIP_RESPONSE_HEADERS = [ const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
]; ];
function send_cors_headers(): void function send_cors_headers(): void
@ -72,15 +75,14 @@ function request_headers(): array
} }
send_cors_headers(); send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204); http_response_code(204);
exit; exit;
} }
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
$allowed = false; $allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) { foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) { if (strpos($path, $prefix) === 0) {
@ -89,7 +91,7 @@ foreach (ALLOWED_PREFIXES as $prefix) {
} }
} }
if (!$allowed) { if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404); http_response_code(404);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']); echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
@ -125,6 +127,7 @@ curl_setopt_array($ch, [
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]); ]);
if ($body !== null && $body !== '') { if ($body !== null && $body !== '') {
@ -165,6 +168,9 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) { if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue; continue;
} }
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false); header($line, false);
} }
@ -173,7 +179,7 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
// request (an <img src>, a download link, ...) is proxied too, not sent to // request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads. // *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) { if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, PUBLIC_BASE, $respBody); $respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
} }
echo $respBody; echo $respBody;

8
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "caterium-app", "name": "caterium-app",
"version": "17.7.3", "version": "17.7.3",
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "0.5.8",
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"wrangler": "4.131.0" "wrangler": "4.131.0"
@ -137,6 +138,13 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",

View File

@ -4,15 +4,19 @@
"version": "17.7.3", "version": "17.7.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js", "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",
"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", "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", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
"test:db": "node tests/recovery/validate.mjs --smoke",
"build:recovery": "node tests/recovery/bundle.mjs",
"test:e2e": "playwright test --config=tests/playwright.config.mjs", "test:e2e": "playwright test --config=tests/playwright.config.mjs",
"test": "npm run check:deploy", "test": "npm run check:deploy",
"deploy:cloudflare-backup": "wrangler deploy" "deploy:cloudflare-backup": "wrangler deploy",
"build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs && node ops/demo/build-trial-extras.mjs"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "0.5.8",
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"wrangler": "4.131.0" "wrangler": "4.131.0"

185
public/api/index.php Normal file
View File

@ -0,0 +1,185 @@
<?php
/**
* Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy.
* Forwards only REST/Auth/Storage/Edge Functions HTTP traffic.
* Browser updates use authenticated HTTP polling; this is not a WebSocket proxy.
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
*/
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
// The same source is deployed to the dedicated API host and app /api/index.php.
// Never accept another Host or derive the upstream host from browser input.
$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) {
$path = $_GET['__caterium_path'];
$query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) {
return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path';
}));
} elseif ($requestHost !== 'api.caterium.ru') {
http_response_code(404);
exit;
}
const ALLOWED_ORIGINS = [
'https://app.caterium.ru',
'https://caterium.ru',
'https://www.caterium.ru',
];
const FORWARD_REQUEST_HEADERS = [
'authorization', 'apikey', 'content-type', 'accept', 'prefer', 'range',
'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', 'cache-control',
];
const STRIP_RESPONSE_HEADERS = [
'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie',
];
function send_cors_headers(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, ALLOWED_ORIGINS, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: ' . implode(', ', FORWARD_REQUEST_HEADERS));
header('Access-Control-Max-Age: 86400');
}
function request_headers(): array
{
if (function_exists('getallheaders')) {
$raw = getallheaders();
if (is_array($raw)) {
return $raw;
}
}
// Fallback for environments without getallheaders().
$out = [];
foreach ($_SERVER as $key => $value) {
if (strpos($key, 'HTTP_') === 0) {
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$out[$name] = $value;
}
}
if (isset($_SERVER['CONTENT_TYPE'])) {
$out['Content-Type'] = $_SERVER['CONTENT_TYPE'];
}
return $out;
}
send_cors_headers();
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
$allowed = false;
foreach (ALLOWED_PREFIXES as $prefix) {
if (strpos($path, $prefix) === 0) {
$allowed = true;
break;
}
}
if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
exit;
}
$upstreamUrl = UPSTREAM . $path . ($query !== null && $query !== '' ? '?' . $query : '');
$incoming = request_headers();
$incomingLower = [];
foreach ($incoming as $name => $value) {
$incomingLower[strtolower($name)] = $value;
}
$forwardHeaders = [];
foreach (FORWARD_REQUEST_HEADERS as $name) {
if (isset($incomingLower[$name]) && $incomingLower[$name] !== '') {
$forwardHeaders[] = $name . ': ' . $incomingLower[$name];
}
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$body = ($method === 'GET' || $method === 'HEAD') ? null : file_get_contents('php://input');
$ch = curl_init($upstreamUrl);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $forwardHeaders,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_NOBODY => $method === 'HEAD',
]);
if ($body !== null && $body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
if ($response === false) {
http_response_code(502);
header('Content-Type: application/json');
echo json_encode(['error' => 'upstream_unreachable']);
curl_close($ch);
exit;
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$rawHeaders = substr($response, 0, $headerSize);
$respBody = substr($response, $headerSize);
curl_close($ch);
http_response_code($statusCode);
$responseContentType = '';
foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
if ($line === '' || stripos($line, 'HTTP/') === 0) {
continue;
}
$colon = strpos($line, ':');
if ($colon === false) {
continue;
}
$headerName = strtolower(trim(substr($line, 0, $colon)));
if ($headerName === 'content-type') {
$responseContentType = trim(substr($line, $colon + 1));
}
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
continue;
}
// Backend HTTP endpoints do not require external redirects. Keep any
// upstream redirect on the dedicated API host, including signed downloads.
if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line);
header($line, false);
}
// Supabase returns absolute upstream URLs inside some JSON bodies (e.g. Storage
// createSignedUrl). Rewrite those to our public host so the browser's follow-up
// request (an <img src>, a download link, ...) is proxied too, not sent to
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
$respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody);
}
echo $respBody;

View File

@ -0,0 +1,79 @@
<?php
/** Fixed-recipient contact form. No customer database access and no mail relay. */
declare(strict_types=1);
namespace Caterium\Support;
const RECIPIENT = 'support@caterium.ru';
const SENDER = 'no-reply@caterium.ru';
const TOPICS = ['question'=>'Вопрос по приложению','problem'=>'Ошибка или проблема','access'=>'Вход и подписка','suggestion'=>'Предложение','other'=>'Другое'];
final class Problem extends \RuntimeException {
public $status;
public function __construct(int $status, string $message) { parent::__construct($message); $this->status=$status; }
}
function text(array $input, string $key, int $max, bool $required=false, bool $multiline=false): string {
$value=$input[$key]??'';
if (!is_string($value) || !preg_match('//u',$value)) throw new Problem(422,'Проверьте поля формы.');
$value=trim($value);
if (strlen($value)>$max || ($required && $value==='') || preg_match($multiline?'/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/':'/[\x00-\x1f\x7f]/',$value)) throw new Problem(422,'Поле «'.$key.'» заполнено неверно или слишком длинное.');
return str_replace(["\r\n","\r"],"\n",$value);
}
function validate(array $input): array {
$keys=['name','email','topic','subject','message','device','section','release','website','request_id','csrf'];
if (array_diff(array_keys($input),$keys)) throw new Problem(422,'Неизвестные поля формы.');
$row=['name'=>text($input,'name',240,true),'email'=>text($input,'email',254,true),'topic'=>text($input,'topic',30,true),
'subject'=>text($input,'subject',360,true),'message'=>text($input,'message',16000,true,true),
'device'=>text($input,'device',1000),'section'=>text($input,'section',240),'release'=>text($input,'release',80),
'request_id'=>text($input,'request_id',80,true)];
if (!filter_var($row['email'],FILTER_VALIDATE_EMAIL) || !preg_match('/^[\x21-\x7e]+$/',$row['email']) || !isset(TOPICS[$row['topic']]) || !preg_match('/^[a-f0-9-]{36}$/',$row['request_id'])) throw new Problem(422,'Проверьте email и тему обращения.');
if (text($input,'website',200)!=='') throw new Problem(422,'Не удалось проверить форму. Обновите страницу.');
return $row;
}
function directory(): string {
// Outside public_html and outside the release backup; no message bodies are stored.
$dir=getenv('CATERIUM_SUPPORT_STATE_DIR')?:dirname(__DIR__,2).'/.caterium-support';
if (!is_dir($dir) && !@mkdir($dir,0700,true) && !is_dir($dir)) throw new Problem(503,'Форма временно недоступна. Напишите на '.RECIPIENT.'.');
$real=realpath($dir);$web=realpath(dirname(__DIR__));
if (!$real || ($web && ($real===$web || strpos($real,$web.DIRECTORY_SEPARATOR)===0))) throw new Problem(503,'Не удалось подготовить защищённую отправку.');
return $real;
}
function deliver(array $row, string $id): bool {
if (!function_exists('mail')) return false;
$body="Обращение в поддержку Caterium\nНомер: $id\nUTC: ".gmdate('c')."\n\nИмя: {$row['name']}\nEmail для ответа (указан пользователем): {$row['email']}\nКатегория: ".TOPICS[$row['topic']]."\nТема: {$row['subject']}\n\n{$row['message']}\n";
if ($row['device']!=='') $body.="\nСведения, разрешённые отправителем:\nУстройство: {$row['device']}\nРаздел: {$row['section']}\nВерсия: {$row['release']}\n";
$headers=['From'=>'Caterium <'.SENDER.'>','Reply-To'=>$row['email'],'MIME-Version'=>'1.0','Content-Type'=>'text/plain; charset=UTF-8','Content-Transfer-Encoding'=>'base64','Auto-Submitted'=>'auto-generated','X-Auto-Response-Suppress'=>'All','Message-ID'=>'<'.strtolower($id).'@caterium.ru>'];
// All addresses and the envelope sender are fixed or strictly validated.
return @mail(RECIPIENT,'[Caterium] Support request '.$id,chunk_split(base64_encode($body),76,"\r\n"),$headers,'-f'.SENDER);
}
function submit(array $row, string $ip, string $dir, callable $mailer, ?int $now=null): array {
$now=$now??time();$path=$dir.'/limits.json';
if (is_link($path)) throw new Problem(503,'Форма временно недоступна.');
$handle=@fopen($path,'c+');if (!$handle || !flock($handle,LOCK_EX)) throw new Problem(503,'Форма временно недоступна. Попробуйте позже.');
@chmod($path,0600);
try {
$raw=stream_get_contents($handle);$state=$raw===''?['salt'=>bin2hex(random_bytes(32)),'requests'=>[]]:json_decode($raw,true);
if (!is_array($state) || !is_string($state['salt']??null) || !is_array($state['requests']??null)) throw new Problem(503,'Форма временно недоступна.');
$hash=static function(string $s)use($state):string{return hash_hmac('sha256',$s,$state['salt']);};
$ipHash=$hash('ip:'.$ip);$emailHash=$hash('email:'.strtolower($row['email']));$key=$hash($emailHash.':'.$row['request_id']);
$fingerprint=$hash(json_encode($row,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR));
$state['requests']=array_filter($state['requests'],static function($x)use($now){return ($x['at']??0)>$now-172800;});
if (isset($state['requests'][$key])) {
$previous=$state['requests'][$key];
if (!hash_equals($previous['fingerprint'],$fingerprint)) throw new Problem(409,'Это обращение изменено. Создайте новое сообщение.');
if ($previous['status']==='accepted') return ['ok'=>true,'status'=>'accepted','id'=>$previous['id']];
if ($previous['status']==='pending') throw new Problem(409,'Результат отправки пока не подтверждён. Номер '.$previous['id'].'. Не дублируйте сообщение; при необходимости напишите на '.RECIPIENT.'.');
if ($previous['at']>$now-60) throw new Problem(429,'Подождите минуту перед повторной отправкой.');
}
$hour=array_filter($state['requests'],static function($x)use($now){return $x['at']>$now-3600;});
if (count($hour)>=60 || count(array_filter($hour,static function($x)use($ipHash){return $x['ip']===$ipHash;}))>=5 || count(array_filter($hour,static function($x)use($emailHash){return $x['email']===$emailHash;}))>=3) throw new Problem(429,'Слишком много обращений. Попробуйте через час или напишите на '.RECIPIENT.'.');
$id='SUP-'.gmdate('Ymd',$now).'-'.strtoupper(substr($key,0,12));
$state['requests'][$key]=['at'=>$now,'ip'=>$ipHash,'email'=>$emailHash,'fingerprint'=>$fingerprint,'id'=>$id,'status'=>'pending'];
$save=static function()use(&$state,$handle):void{
$json=json_encode($state,JSON_THROW_ON_ERROR);rewind($handle);
if (!ftruncate($handle,0) || fwrite($handle,$json)!==strlen($json) || !fflush($handle)) throw new Problem(503,'Не удалось подтвердить отправку. Не дублируйте письмо сразу.');
};
$save(); // Record uncertain state BEFORE handing off to the mail server.
$accepted=$mailer($row,$id)===true;
$state['requests'][$key]['status']=$accepted?'accepted':'failed';$save();
if (!$accepted) throw new Problem(503,'Почтовый сервер не принял сообщение. Текст сохранён в форме. Попробуйте позже или напишите на '.RECIPIENT.'.');
return ['ok'=>true,'status'=>'accepted','id'=>$id];
} finally {flock($handle,LOCK_UN);fclose($handle);}
}

43
public/api/support.php Normal file
View File

@ -0,0 +1,43 @@
<?php
/** Same-origin public help endpoint; no credentials or customer data required. */
declare(strict_types=1);
require_once __DIR__.'/support-lib.php';
use Caterium\Support\Problem;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
try {
if (strtolower(explode(':',$_SERVER['HTTP_HOST']??'')[0])!=='app.caterium.ru') throw new Problem(404,'Not found');
$method=$_SERVER['REQUEST_METHOD']??'';
if (!in_array($method,['GET','POST'],true)) {header('Allow: GET, POST');throw new Problem(405,'Метод не поддерживается.');}
$origin=$_SERVER['HTTP_ORIGIN']??'';
if (($method==='POST' && $origin!=='https://app.caterium.ru') || ($origin!=='' && $origin!=='https://app.caterium.ru') || ($_SERVER['HTTP_SEC_FETCH_SITE']??'same-origin')==='cross-site') throw new Problem(403,'Откройте форму в приложении Caterium.');
if (!function_exists('mail')) throw new Problem(503,'Отправка почты временно недоступна. Напишите на '.Caterium\Support\RECIPIENT.'.');
$dir=Caterium\Support\directory();
session_name('ct_support');session_set_cookie_params(['lifetime'=>0,'path'=>'/api/','secure'=>true,'httponly'=>true,'samesite'=>'Strict']);
ini_set('session.use_strict_mode','1');
if (!session_start()) throw new Problem(503,'Не удалось подготовить форму.');
if ($method==='GET') {
if (!isset($_SESSION['csrf']) || ($_SESSION['issued']??0)<time()-7200) {$_SESSION['csrf']=bin2hex(random_bytes(32));$_SESSION['issued']=time();}
$out=['csrf'=>$_SESSION['csrf'],'recipient'=>Caterium\Support\RECIPIENT,'max_message_chars'=>4000];session_write_close();
echo json_encode($out,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);exit;
}
if (strtolower(trim(explode(';',$_SERVER['CONTENT_TYPE']??'')[0]))!=='application/json') throw new Problem(415,'Ожидается форма JSON.');
if ((int)($_SERVER['CONTENT_LENGTH']??0)>24000) throw new Problem(413,'Сообщение слишком длинное.');
$raw=file_get_contents('php://input',false,null,0,24001);
if ($raw===false || strlen($raw)>24000) throw new Problem(413,'Сообщение слишком длинное.');
try {$input=json_decode($raw,true,8,JSON_THROW_ON_ERROR);}catch(\JsonException $e){throw new Problem(400,'Не удалось прочитать форму.');}
if (!is_array($input) || !is_string($input['csrf']??null) || !isset($_SESSION['csrf']) || ($_SESSION['issued']??0)<time()-7200 || !hash_equals($_SESSION['csrf'],$input['csrf'])) throw new Problem(403,'Форма устарела. Откройте её заново; текст не нужно удалять.');
session_write_close();
$row=Caterium\Support\validate($input);
$out=Caterium\Support\submit($row,$_SERVER['REMOTE_ADDR']??'unknown',$dir,'Caterium\\Support\\deliver');
http_response_code(202);echo json_encode($out,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
} catch (Problem $e) {
if(session_status()===PHP_SESSION_ACTIVE)session_write_close();
http_response_code($e->status);if($e->status===429)header('Retry-After: 3600');
echo json_encode(['ok'=>false,'message'=>$e->getMessage()],JSON_UNESCAPED_UNICODE);
} catch (\Throwable $e) {
if(session_status()===PHP_SESSION_ACTIVE)session_write_close();
http_response_code(503);echo json_encode(['ok'=>false,'message'=>'Не удалось подтвердить отправку. Сохраните текст и напишите на '.Caterium\Support\RECIPIENT.'.'],JSON_UNESCAPED_UNICODE);
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
(()=>{
'use strict';
// Recovery switch: enabling this requires a deliberate application deployment.
// A saved browser preference alone can never enable anonymous production use.
const emergencyLocalEnabled=false;
const key='sunLocalOnlyModeV1';
if(!emergencyLocalEnabled){try{localStorage.removeItem(key)}catch(_){}}
window.CateriumAccessPolicy=Object.freeze({
emergencyLocalEnabled,
emergencyLocalActive(){try{return emergencyLocalEnabled&&localStorage.getItem(key)==='1'}catch(_){return false}}
});
})();

View File

@ -1,7 +1,7 @@
(()=>{ (()=>{
'use strict'; 'use strict';
if(window.CateriumAccountCenterV1780)return; if(window.CateriumAccountCenterV1780)return;
const VERSION='17.8.0-account-center-v3'; const VERSION='17.8.0-employee-session-20260920';
const $=(s,r=document)=>r.querySelector(s); const $=(s,r=document)=>r.querySelector(s);
const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??''); const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'');
@ -14,6 +14,10 @@ const ROLE_LABELS={admin:'Владелец',manager:'Менеджер',kitchen:'
let snapshot=null; let snapshot=null;
let modal=null; let modal=null;
let lastUserId=''; let lastUserId='';
let snapshotScope='',loadSequence=0,openSequence=0;
const accountScope=()=>JSON.stringify([session()?.user?.id||'',workspace()?.id||'']);
const profileIcon='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>';
const logoutIcon='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h12"/></svg>';
function installStyle(){ function installStyle(){
if($('#caterium-account-center-style'))return; if($('#caterium-account-center-style'))return;
@ -21,8 +25,9 @@ function installStyle(){
#cateriumAccountCenter{position:fixed;inset:0;z-index:21000;display:none;place-items:center;padding:22px;background:#102c3d99;backdrop-filter:blur(5px)} #cateriumAccountCenter{position:fixed;inset:0;z-index:21000;display:none;place-items:center;padding:22px;background:#102c3d99;backdrop-filter:blur(5px)}
#cateriumAccountCenter.on{display:grid} #cateriumAccountCenter.on{display:grid}
#cateriumAccountCenter .cac-shell{width:min(980px,100%);max-height:min(860px,92vh);overflow:auto;background:#f6f2ea;border-radius:22px;box-shadow:0 28px 90px #0004;color:#2f2b25} #cateriumAccountCenter .cac-shell{width:min(980px,100%);max-height:min(860px,92vh);overflow:auto;background:#f6f2ea;border-radius:22px;box-shadow:0 28px 90px #0004;color:#2f2b25}
#cateriumAccountCenter .cac-head{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5} #cateriumAccountCenter .cac-head{position:sticky;top:0;z-index:2;background:#f6f2ea;display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:28px 30px 20px;border-bottom:1px solid #d9d1c5}
#cateriumAccountCenter h2{margin:0;font:500 38px/1.04 Georgia,'Times New Roman',serif;letter-spacing:-.03em;color:#27231f} #cateriumAccountCenter h2{margin:0;font:500 38px/1.04 Georgia,'Times New Roman',serif;letter-spacing:-.03em;color:#27231f}
#cateriumAccountCenter .cac-head-actions{display:flex;align-items:center;gap:12px;flex-shrink:0}
#cateriumAccountCenter .cac-sub{margin-top:7px;color:#8a8379;font-size:14px} #cateriumAccountCenter .cac-sub{margin-top:7px;color:#8a8379;font-size:14px}
#cateriumAccountCenter .cac-close{border:0;background:transparent;font-size:31px;color:#5c564d;padding:0 4px} #cateriumAccountCenter .cac-close{border:0;background:transparent;font-size:31px;color:#5c564d;padding:0 4px}
#cateriumAccountCenter .cac-body{padding:24px 30px 30px} #cateriumAccountCenter .cac-body{padding:24px 30px 30px}
@ -45,31 +50,44 @@ function installStyle(){
#cateriumAccountButton{display:flex!important} #cateriumAccountButton{display:flex!important}
#sun-cloud-users-modal [data-caterium-owner-row="1"]{background:#fff9e8;border:1px solid #ead39a;border-radius:10px;padding:10px} #sun-cloud-users-modal [data-caterium-owner-row="1"]{background:#fff9e8;border:1px solid #ead39a;border-radius:10px;padding:10px}
#sun-cloud-users-modal .caterium-owner-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f4dfad;color:#6d5118;padding:5px 9px;font-size:11px;font-weight:900} #sun-cloud-users-modal .caterium-owner-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f4dfad;color:#6d5118;padding:5px 9px;font-size:11px;font-weight:900}
#cateriumMobileAccountActions{display:none}
#cateriumMobileAccountActions button svg,#cateriumAccountButton svg{width:20px;height:20px;flex-shrink:0}
@media(max-width:900px){
body.sun-enterprise-sidebar header #cateriumMobileAccountActions{display:flex!important;width:100%;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:6px 0 0}
#cateriumMobileAccountActions button{display:flex!important;align-items:center;justify-content:center;gap:7px;min-height:44px;border:1px solid #b8b4a9;border-radius:10px;padding:8px 12px;background:#faf7ef;color:#333c30;font:700 13px Arial;touch-action:manipulation}
#cateriumAccountCenter h2{font-size:27px}
#cateriumAccountCenter .cac-head-actions{gap:8px}
#cacHeaderLogout{min-height:44px;font-size:12px;padding:8px!important;white-space:nowrap}
}
@media print{#cateriumMobileAccountActions,#cateriumAccountCenter{display:none!important}}
@media(max-width:720px){#cateriumAccountCenter{padding:0;place-items:stretch}#cateriumAccountCenter .cac-shell{width:100%;max-height:none;height:100dvh;border-radius:0}#cateriumAccountCenter .cac-grid{grid-template-columns:1fr}#cateriumAccountCenter .cac-card.wide{grid-column:auto}#cateriumAccountCenter .cac-head,#cateriumAccountCenter .cac-body{padding-left:18px;padding-right:18px}} @media(max-width:720px){#cateriumAccountCenter{padding:0;place-items:stretch}#cateriumAccountCenter .cac-shell{width:100%;max-height:none;height:100dvh;border-radius:0}#cateriumAccountCenter .cac-grid{grid-template-columns:1fr}#cateriumAccountCenter .cac-card.wide{grid-column:auto}#cateriumAccountCenter .cac-head,#cateriumAccountCenter .cac-body{padding-left:18px;padding-right:18px}}
`;document.head.appendChild(s); `;document.head.appendChild(s);
} }
function ensureModal(){ function ensureModal(){
if(modal)return modal; if(modal)return modal;
modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='<div class="cac-shell"><div class="cac-head"><div><h2>Мой аккаунт</h2><div class="cac-sub">Профиль и безопасность Caterium</div></div><button class="cac-close" type="button" aria-label="Закрыть">×</button></div><div class="cac-body" id="cateriumAccountCenterBody"></div></div>'; modal=document.createElement('div');modal.id='cateriumAccountCenter';modal.innerHTML='<div class="cac-shell"><div class="cac-head"><div><h2>Мой аккаунт</h2><div class="cac-sub">Профиль и безопасность Caterium</div></div><div class="cac-head-actions"><button class="outline" id="cacHeaderLogout" type="button">Выйти</button><button class="cac-close" type="button" aria-label="Закрыть">×</button></div></div><div class="cac-body" id="cateriumAccountCenterBody"></div></div>';
document.body.appendChild(modal); document.body.appendChild(modal);
$('#cacHeaderLogout',modal).onclick=logout;
modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();}); modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('.cac-close'))close();});
return modal; return modal;
} }
async function loadSnapshot(){ async function loadSnapshot(){
const c=client(),ws=workspace();if(!c||!ws?.id)return null; const c=client(),ws=workspace();if(!c||!ws?.id)return null;
const scope=accountScope(),request=++loadSequence;
const {data,error}=await c.rpc('caterium_account_snapshot',{p_workspace:ws.id}); const {data,error}=await c.rpc('caterium_account_snapshot',{p_workspace:ws.id});
if(error)throw error;snapshot=data||null;return snapshot; if(scope!==accountScope()||request!==loadSequence)return null;
if(error)throw error;snapshot=data||null;snapshotScope=scope;return snapshot;
} }
function render(){ function render(){
const body=$('#cateriumAccountCenterBody');if(!body)return; const body=$('#cateriumAccountCenterBody');if(!body)return;
const ss=session(),s=snapshot||{},owner=Boolean(s.is_owner); const ss=session(),ws=workspace(),s=snapshotScope===accountScope()?(snapshot||{}):{},owner=Boolean(s.is_owner);
const companyNo=s.company_number?`${s.company_number}`:'—'; const companyNo=s.company_number?`${s.company_number}`:'—';
body.innerHTML=`<div class="cac-grid"> body.innerHTML=`<div class="cac-grid">
<section class="cac-card"> <section class="cac-card">
<h3>Профиль</h3> <h3>Профиль</h3>
${owner?'<div class="cac-owner"><div><b>Главный аккаунт компании</b><span>Только этот аккаунт создаёт сотрудников и распределяет права.</span></div></div>':''} ${owner?'<div class="cac-owner"><div><b>Главный аккаунт компании</b><span>Только этот аккаунт создаёт сотрудников и распределяет права.</span></div></div>':''}
<div class="cac-meta"><div><span>Компания</span><b>${esc(s.company_name||'')} · ${esc(companyNo)}</b></div><div><span>Статус</span><b>${esc(owner?'Владелец':ROLE_LABELS[s.role]||s.role||'Сотрудник')}</b></div></div> <div class="cac-meta"><div><span>Компания</span><b>${esc(s.company_name||ws?.name||'')} · ${esc(companyNo)}</b></div><div><span>Статус</span><b>${esc(owner?'Владелец':ROLE_LABELS[s.role||ws?.role]||s.role||ws?.role||'Сотрудник')}</b></div></div>
<label>Имя<input id="cacName" value="${esc(s.display_name||ss?.user?.user_metadata?.name||'')}"></label> <label>Имя<input id="cacName" value="${esc(s.display_name||ss?.user?.user_metadata?.name||'')}"></label>
<div class="cac-actions"><button class="primary" id="cacSaveName" type="button">Сохранить имя</button></div> <div class="cac-actions"><button class="primary" id="cacSaveName" type="button">Сохранить имя</button></div>
</section> </section>
@ -111,12 +129,15 @@ async function savePassword(){
try{const {error}=await c.auth.updateUser({password:p});if(error)throw error;$('#cacPassword').value='';$('#cacPassword2').value='';toast('Пароль изменён.','success')} try{const {error}=await c.auth.updateUser({password:p});if(error)throw error;$('#cacPassword').value='';$('#cacPassword2').value='';toast('Пароль изменён.','success')}
catch(e){toast(e?.message||String(e),'error',6500)}finally{btn.disabled=false} catch(e){toast(e?.message||String(e),'error',6500)}finally{btn.disabled=false}
} }
async function logout(){try{await cloud()?.signOut?.()}catch(_){try{await client()?.auth.signOut()}catch(__){}location.reload()}} async function logout(){if(typeof cloud()?.signOut==='function')return cloud().signOut();try{await client()?.auth.signOut({scope:'local'})}finally{location.reload()}}
async function open(){ async function open(){
ensureModal();modal.classList.add('on');const body=$('#cateriumAccountCenterBody');body.innerHTML='<p class="cac-note">Загружаю аккаунт…</p>'; ensureModal();const request=++openSequence,scope=accountScope();modal.classList.add('on');render();
try{await loadSnapshot();render()}catch(e){body.innerHTML=`<p class="cac-note">${esc(e?.message||e||'Не удалось загрузить аккаунт.')}</p>`} const body=$('#cateriumAccountCenterBody');body.setAttribute('aria-busy','true');
try{await loadSnapshot();if(request===openSequence&&scope===accountScope())render()}
catch(e){if(request===openSequence&&scope===accountScope()){render();const note=document.createElement('p');note.className='cac-note';note.textContent='Не удалось обновить данные профиля. Выход из аккаунта доступен.';body.prepend(note)}}
finally{if(request===openSequence)body.removeAttribute('aria-busy')}
} }
function close(){modal?.classList.remove('on')} function close(){openSequence++;modal?.classList.remove('on')}
function sanitizeRbac(){ function sanitizeRbac(){
const body=$('#sunRbacBody');if(!body)return; const body=$('#sunRbacBody');if(!body)return;
qa('select option[value="admin"]',body).forEach(o=>o.remove()); qa('select option[value="admin"]',body).forEach(o=>o.remove());
@ -133,18 +154,25 @@ function ensureSidebarEntry(){
if(!button){ if(!button){
button=document.createElement('button'); button=document.createElement('button');
button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль'; button.id='cateriumAccountButton';button.type='button';button.className='sun-side-action';button.title='Профиль, email и пароль';
button.innerHTML='<span>◎</span><span>Мой аккаунт</span>'; button.innerHTML=profileIcon+'<span>Мой аккаунт</span>';
footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild); footer.insertBefore(button,footer.querySelector('#sunLogoutBtn')||footer.firstChild);
} }
if(!button.dataset.cateriumAccountBound){button.dataset.cateriumAccountBound='1';button.addEventListener('click',open)} if(!button.dataset.cateriumAccountBound){button.dataset.cateriumAccountBound='1';button.addEventListener('click',open)}
} }
function ensureMobileEntry(){
const header=$('body > header');if(!header)return;
let bar=$('#cateriumMobileAccountActions');
if(!session()?.user){bar?.remove();return}
if(!bar){bar=document.createElement('div');bar.id='cateriumMobileAccountActions';bar.innerHTML='<button id="cateriumMobileAccountButton" type="button" aria-label="Мой аккаунт">'+profileIcon+'<span>Профиль</span></button><button id="cateriumMobileLogout" type="button" aria-label="Выйти из аккаунта">'+logoutIcon+'<span>Выйти</span></button>';header.appendChild(bar);$('#cateriumMobileAccountButton',bar).onclick=open;$('#cateriumMobileLogout',bar).onclick=logout;}
}
function bindEntry(){ function bindEntry(){
const label=$('#sunCurrentUserLabel');if(label&&!label.dataset.cateriumAccountBound){label.dataset.cateriumAccountBound='1';label.title='Открыть личный кабинет';label.addEventListener('click',open)} const label=$('#sunCurrentUserLabel');if(label&&!label.dataset.cateriumAccountBound){label.dataset.cateriumAccountBound='1';label.title='Открыть личный кабинет';label.addEventListener('click',open)}
const ss=session();const uid=ss?.user?.id||'';if(uid&&uid!==lastUserId){lastUserId=uid;snapshot=null;} const uid=accountScope();if(uid!==lastUserId){lastUserId=uid;snapshot=null;snapshotScope='';loadSequence++;close();}
ensureSidebarEntry();sanitizeRbac(); ensureSidebarEntry();ensureMobileEntry();sanitizeRbac();
} }
installStyle();ensureModal();bindEntry(); installStyle();ensureModal();bindEntry();
const obs=new MutationObserver(bindEntry);obs.observe(document.documentElement,{childList:true,subtree:true}); const obs=new MutationObserver(bindEntry);obs.observe(document.documentElement,{childList:true,subtree:true});
window.addEventListener('sun:cloud-tenant-changing',()=>{snapshot=null;snapshotScope='';loadSequence++;close()});
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(bindEntry,0)); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(bindEntry,0));
setInterval(()=>{if(!document.hidden)bindEntry()},3000); setInterval(()=>{if(!document.hidden)bindEntry()},3000);
window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}}); window.CateriumAccountCenterV1780=Object.freeze({VERSION,open,close,reload:async()=>{await loadSnapshot();render();return snapshot}});

View File

@ -1,20 +1,34 @@
(()=>{ (()=>{
'use strict'; 'use strict';
const VERSION='17.8.4-auth-proxy-fallback'; const VERSION='17.9.1-public-basic-signup';
const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23';
const DIRECT_SUPABASE_URL='https://cksuehzcimitsxmeloes.supabase.co'; let busy=false;
const DIRECT_SUPABASE_KEY='sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM';
let busy=false,directClient=null;
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
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;
function directAuthClient(){if(directClient)return directClient;try{if(!window.supabase?.createClient)return null;directClient=window.supabase.createClient(DIRECT_SUPABASE_URL,DIRECT_SUPABASE_KEY,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true}});return directClient}catch(_){return null}} async function refreshSignupPolicy(c=client(),force=false){
function isProxyServerError(error){const text=`${error?.message||error||''} ${error?.status||''} ${error?.statusCode||''}`;return /(?:http\s*)?500|internal server error|failed to fetch|networkerror|load failed/i.test(text)} if(signupPolicy.checked&&!force)return signupPolicy;
async function signInWithFallback(c,email,password){let first=null;try{first=await c.auth.signInWithPassword({email,password})}catch(error){if(!isProxyServerError(error))throw error;first={error}}if(first?.data?.session)return{authClient:c,result:first};if(first?.error&&!isProxyServerError(first.error))throw first.error;const fallback=directAuthClient();if(!fallback)throw first?.error||new Error('Сервис входа временно недоступен.');const second=await fallback.auth.signInWithPassword({email,password});if(second.error)throw second.error;return{authClient:fallback,result:second}} 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(_){}}
@ -28,16 +42,20 @@
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>Промокод пробной версии</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 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>';
fields.appendChild(label); fields.appendChild(label);
const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');}); const input=$('sunGatePromoV181');input?.addEventListener('input',()=>{input.value=input.value.toUpperCase().replace(/\s+/g,'');if(!input.value)renderPromoPolicy();});
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='Не удалось проверить промокод.';}}); 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='Не удалось проверить промокод.';}});
} }
void refreshSignupPolicy().then(renderPromoPolicy);
} }
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;} async function validatePromo(c,email,code){
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){
@ -46,23 +64,24 @@
if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;} if(!email||password.length<6){setError(gate,'Введите email и пароль минимум из 6 символов.');return;}
busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Выполняю вход…'); busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,'Выполняю вход…');
try{ try{
const signed=await signInWithFallback(c,email,password),result=signed.result,authClient=signed.authClient; const result=await c.auth.signInWithPassword({email,password});if(result.error)throw result.error;
const authUser=result.data?.user||result.data?.session?.user||null;const userId=authUser?.id||'';if(userId)try{sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1')}catch(_){}
const persisted=await authClient.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.'); const persisted=await c.auth.getSession();if(persisted.error)throw persisted.error;if(!persisted.data?.session?.user)throw new Error('Сессия входа не сохранилась. Повторите вход.');
setError(gate,authClient===c?'Вход выполнен. Открываю Caterium…':'Вход выполнен через резервный канал. Открываю Caterium…'); setError(gate,'Вход выполнен. Открываю Caterium…');
setTimeout(()=>location.reload(),120); setTimeout(()=>location.reload(),120);
}catch(error){setError(gate,String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;} }catch(error){setError(gate,window.CateriumCloudTransport?.errorMessage(error)||String(error?.message||error||'Не удалось войти.'));if(button)button.disabled=false;busy=false;}
} }
async function publicSignup(gate){ async function publicSignup(gate){
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,'Проверяю промокод…'); busy=true;const button=$('sunGateSubmitV3');if(button)button.disabled=true;setError(gate,promoCode?'Проверяю промокод…':'Проверяю Базовый тариф…');
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 result=await c.auth.signUp({email,password,options:{emailRedirectTo:redirectUrl(),data:{promo_code:promoCode}}});if(result.error)throw result.error; const signupData=promoCode?{promo_code:promoCode}:{};
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;}
@ -78,11 +97,11 @@
} }
async function finishOwnerOnboarding(gate){ async function finishOwnerOnboarding(gate){
if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.promoCode)return false; if(busy)return false;const c=client(),api=cloud(),pending=pendingRegistration();if(!c||!api||!pending?.email)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}; const user=session.user;const metadata={...(user.user_metadata||{}),promo_code:pending.promoCode||null};
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;
@ -95,7 +114,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;setError(gate,'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйте пробный период по промокоду.');}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;await refreshSignupPolicy();setError(gate,signupPolicy.promoOptional?'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании зарегистрируйтесь: без промокода доступен Базовый тариф.':'Аккаунт не привязан к компании. Если вы сотрудник — попросите владельца добавить вас в «Пользователи и права». Для новой компании нужен действующий промокод.');}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

@ -0,0 +1,187 @@
/* One-page banquet menus. This module only reads an explicit selection snapshot;
opening, changing display options and exporting never save or apply an order. */
(()=>{
'use strict';
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 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 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 copy=v=>JSON.parse(JSON.stringify(v));
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']);
let fontsPromise,enginePromise,panel=null;
function allowed(){
const c=window.SunCloudV2,s=window.SunSaaSV16;
if(c?.isSupportMode?.()||c?.getSupportMode?.())return false;
if(s?.hasFeature?.('client_offers')===false||s?.isWritable?.()===false)return false;
return !c?.getSession?.()?.user||c.hasPermission?.('catalog.view')!==false;
}
function section(item){
if(window.CateriumBanquet?.group)return window.CateriumBanquet.group(item);
const s=clean(item.catalogSection).toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло/.test(s)?'bread':/закуск|рыб|мясн|сыр|овощ/.test(s)?'cold':'other';
}
function snapshot(input){
const catalog=Array.isArray(input?.catalog)?input.catalog:[],draft=input?.draft||{};
const ids=new Set((Array.isArray(draft.banquetSelection)?draft.banquetSelection:(draft.lines||[]).filter(l=>catalog.some(i=>String(i.id)===String(l.id)&&Number(i.category)===6)).map(l=>l.id)).map(String));
const rows=catalog.filter(i=>Number(i.category)===6&&i.hidden!==true&&ids.has(String(i.id)));
if(!rows.length)throw new Error('Сначала выберите блюда или готовое меню.');
if(rows.length>100)throw new Error('Для одной страницы выбрано слишком много блюд. Сократите меню.');
const items=rows.map(item=>{
const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));
const raw=line?.price,hasPrice=raw!==undefined&&raw!==null&&String(raw).trim()!==''&&Number.isFinite(Number(raw))&&Number(raw)>=0;
const price=hasPrice?Number(raw):(window.CateriumPricing?.price(item)??number(item.price));
const grams=window.CateriumBanquet?.grams?.(item)??Math.round((parseFloat(clean(item.weight).replace(',','.'))||0)*(/кг/i.test(clean(item.weight))?1000:1));
return {id:String(item.id),name:clean(item.name)||'Без названия',group:section(item),weight:clean(item.weight),grams:Math.max(0,number(grams)),cents:Math.round(Math.max(0,number(price))*100),estimated:item.banquet?.estimated===true};
});
const guests=Math.min(10000,Math.max(1,Math.round(number(draft.guestsCount)||1))),cents=items.reduce((n,i)=>n+i.cents,0);
if(!Number.isSafeInteger(cents*guests))throw new Error('Стоимость меню слишком велика для точного расчёта. Проверьте цены.');
const sourceBrand=input?.brand||window.CateriumBranding?.identity?.()||{};
return {items,guests,cents,totalCents:cents*guests,grams:items.reduce((n,i)=>n+i.grams,0),event:clean(input?.event??draft.event),date:clean(input?.date??draft.date),brand:{name:clean(sourceBrand.name)||'Моя компания',logo:clean(sourceBrand.logo),contacts:clean(sourceBrand.contacts)},estimated:items.some(i=>i.estimated)};
}
async function fonts(){
if(!fontsPromise)fontsPromise=Promise.all([['Caterium Menu Sans','Manrope.ttf','200 800'],['Caterium Menu Serif','PlayfairDisplay.ttf','400 900']].map(async([name,file,weight])=>{
if(!window.FontFace||!document.fonts)return;
let timer;try{const f=new FontFace(name,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});await Promise.race([f.load(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('font timeout')),6000);})]);document.fonts.add(f);}catch(_){/* Measured system-font fallback; no late font swap. */}finally{clearTimeout(timer);}
}));return fontsPromise;
}
function engine(){
if(window.SunPdfEngine)return Promise.resolve(window.SunPdfEngine);
if(enginePromise)return enginePromise;
enginePromise=new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer;
const fail=()=>{clearTimeout(timer);script.remove();enginePromise=null;reject(new Error('Не удалось загрузить экспорт PDF. Проверьте интернет и повторите.'));};
script.src=new URL('core/pdf-engine.js?v=20260920-onepage',document.baseURI).href;
script.onload=()=>{clearTimeout(timer);window.SunPdfEngine?resolve(window.SunPdfEngine):fail();};script.onerror=fail;timer=setTimeout(fail,12000);document.head.append(script);
});return enginePromise;
}
async function logoImage(src){
if(!/^data:image\/(png|jpe?g|webp);base64,/i.test(src)||src.length>2000000)return null;
return new Promise(resolve=>{const i=new Image();let done=false;const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v);};const timer=setTimeout(()=>finish(null),3500);i.onload=()=>{try{resolvePrepared();}catch(_){finish(null);}};function resolvePrepared(){finish(window.CateriumBranding?.prepareLogoImage?.(i)||i);}i.onerror=()=>finish(null);i.src=src;});
}
function wrap(ctx,value,width){
const lines=[];let line='';
for(const para of clean(value).split(/\r?\n/)){
for(const word of para.split(/\s+/).filter(Boolean)){
if(ctx.measureText((line?line+' ':'')+word).width<=width){line+=(line?' ':'')+word;continue;}
if(line){lines.push(line);line='';}
for(const ch of word){if(line&&ctx.measureText(line+ch).width>width){lines.push(line);line='';}line+=ch;}
}
if(line){lines.push(line);line='';}
}
return lines;
}
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 plan(ctx,s,top,bottom){
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
// 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--){
const choices=ordered.length<=8?[1,2]:[2,1];
for(const columns of choices){
const width=(W-M*2-(columns-1)*42)/columns;
ctx.font=`${font}px ${SANS}`;
const rows=ordered.map(item=>({item,lines:wrap(ctx,item.name,width),meta:[item.weight].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;}
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))]);
candidates=candidates.filter(cols=>cols.every(c=>c.height<=bottom-top));
if(candidates.length){candidates.sort((a,b)=>Math.max(...a.map(c=>c.height))-Math.max(...b.map(c=>c.height)));return {font,width,columns:candidates[0],top,bottom};}
}
}
throw new Error('Меню не помещается на одну страницу без слишком мелкого текста. Сократите названия или количество выбранных блюд. Ни одно блюдо не было обрезано.');
}
async function render(s,{themeId='gold'}={}){
const theme=THEMES[themeId]||THEMES.gold;
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 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 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 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);
const brandHeight=text(s.brand.name,M,58,logo?590:W-M*2,`600 21px ${SANS}`,ink,27,centered?'center':'left');
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);}
line(M,headerBottom+14,W-M,gold);
let y=headerBottom+42;
y+=text('Банкетное меню',M,y,W-M*2,`500 52px ${SERIF}`,ink,63,centered?'center':'left')+14;
if(s.event)y+=text(s.event,M,y,W-M*2,`20px ${SANS}`,muted,27,centered?'center':'left')+10;
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;
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;
const footerHeight=108+contactHeight+(s.estimated?22:0),bottom=H-64-footerHeight;
const layout=plan(c,s,top,bottom);
for(let j=0;j<layout.columns.length;j++){
const col=layout.columns[j],x=M+j*(layout.width+42);let yy=top;
for(const row of col.entries){
if(row.heading){text(row.heading.toLocaleUpperCase('ru-RU'),x,yy+5,layout.width,`700 14px ${SANS}`,gold,18);yy+=40;}
yy+=text(row.item.name,x,yy,layout.width,`${layout.font}px ${SANS}`,ink,layout.font*1.3);
if(row.meta)yy+=text(row.meta,x,yy+4,layout.width,`${Math.max(13,layout.font-5)}px ${SANS}`,muted,layout.font-2);
yy+=10;
}
}
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
// and never the grand total, so the menu can't be read as a full price list.
// It is not optional: the client always sees what one guest costs.
text('ЦЕНА НА ОДНОГО ГОСТЯ',M,fy,W-M*2,`700 12px ${SANS}`,muted,17,centered?'center':'left');fy+=24;
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}`;
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,centered?'center':'left');fy+=24;
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);
const jpeg=await new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('Не удалось сформировать страницу меню.')),'image/jpeg',.96));
const blob=pdf.fromJpegs([{width:canvas.width,height:canvas.height,bytes:new Uint8Array(await jpeg.arrayBuffer())}]);
return {canvas,jpeg,blob,layout,drawnText};
}
function close(){if(!panel)return;const old=panel;panel=null;old.generation++;if(old.url)URL.revokeObjectURL(old.url);old.dialog.remove();if(old.restore?.isConnected)old.restore.focus({preventScroll:true});}
function style(){
if(document.getElementById('ctBanquetClientStyle'))return;
const el=document.createElement('style');el.id='ctBanquetClientStyle';el.textContent=`
#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 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 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}}
`;document.head.append(el);
}
async function open(input){
if(!allowed())throw new Error('Предложения клиенту недоступны для текущих прав или тарифа.');
const s=snapshot(input),started=scope();close();style();
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>Оформление<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 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]');
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 build=async()=>{
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;
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);
status.textContent=`Готово: одна страница A4 · ${s.items.length} позиций. Показана только цена за гостя.`;download.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();}}
};
themeSelect.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);};
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;
}
window.addEventListener('sun:cloud-tenant-changing',close);
window.addEventListener('sun:cloud-permissions-changed',()=>{if(panel&&!allowed())close();});
window.addEventListener('sun:subscription-changed',()=>{if(panel&&!allowed())close();});
window.CateriumBanquetClientMenu=Object.freeze({snapshot,render,open,close});
})();

View File

@ -0,0 +1,86 @@
(()=>{
'use strict';
const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']];
let packageId='all',groupId='all';
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const money=v=>Number(v||0).toLocaleString('ru-RU',{maximumFractionDigits:2})+' ₽';
const grams=item=>{const value=String(item?.weight||'').replace(',','.');return Math.round((parseFloat(value)||0)*(/кг/i.test(value)?1000:1))};
const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'};
function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]}
function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))}
const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1));
const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):(window.CateriumPricing?.price(item)??Number(item?.price||0))};
function totals(items,draft){return {price:items.reduce((n,i)=>n+unitPrice(i,draft),0),grams:items.reduce((n,i)=>n+grams(i),0)}}
function chooseMenu(draft,catalog,id){const rows=catalog.filter(i=>i.banquet?.packageId===id),used=new Set();draft.banquetSelection=rows.filter(i=>{const choice=i.banquet?.choiceGroup;if(!choice)return true;if(used.has(choice))return false;const defaults=rows.some(r=>r.banquet?.choiceGroup===choice&&r.banquet.defaultChoice);if(defaults&&!i.banquet.defaultChoice)return false;used.add(choice);return true}).map(i=>String(i.id));packageId=id;groupId='all'}
function toggle(draft,catalog,id){const ids=selection(draft,catalog),item=catalog.find(i=>String(i.id)===id);if(!item)return;if(ids.has(id))ids.delete(id);else{const choice=item.banquet?.choiceGroup;if(choice)for(const old of catalog)if(old.banquet?.packageId===item.banquet.packageId&&old.banquet?.choiceGroup===choice)ids.delete(String(old.id));ids.add(id)}draft.banquetSelection=[...ids]}
function apply(draft,catalog){const selected=selection(draft,catalog),rows=catalog.filter(i=>selected.has(String(i.id))),ids=new Set(catalog.map(i=>String(i.id))),count=guestCount(draft);if(!rows.length)return;const lines=rows.map(i=>({id:i.id,name:i.name,category:6,qty:count,price:unitPrice(i,draft)}));draft.lines=[...(draft.lines||[]).filter(l=>!ids.has(String(l.id))&&Number(l.category)!==6),...lines];draft.guestsCount=count;return lines}
function render({items,catalog,draft}){
const menus=packages(catalog);if(packageId!=='all'&&!menus.some(m=>m.id===packageId))packageId='all';
const candidates=items.filter(i=>packageId==='all'||i.banquet?.packageId===packageId),available=groups.filter(([id])=>candidates.some(i=>group(i)===id));
if(groupId!=='all'&&!available.some(([id])=>id===groupId))groupId='all';
const visible=candidates.filter(i=>groupId==='all'||group(i)===groupId),ids=selection(draft,catalog),selected=catalog.filter(i=>ids.has(String(i.id))),sum=totals(selected,draft),count=guestCount(draft);
const menu=menus.find(m=>m.id===packageId),sections=new Map();
for(const [id] of groups)for(const item of visible.filter(i=>group(i)===id)){const section=item.catalogSection||'Без раздела';if(!sections.has(section))sections.set(section,[]);sections.get(section).push(item)}
const estimates=catalog.some(i=>i.banquet?.estimated),hasExisting=(draft.lines||[]).some(l=>catalog.some(i=>String(i.id)===String(l.id))||Number(l.category)===6);
return `<div class="ct-banquet" aria-label="Конструктор банкетного меню">
<div class="ct-banquet-intro"><div><span class="ct-eyebrow">Банкетное меню</span><h2>Соберите меню для гостей</h2><p>Выберите готовое меню или отметьте отдельные блюда.</p></div><button class="outline" type="button" data-banquet-add> Добавить блюдо</button></div>
${menus.length?`<div class="ct-banquet-packages" aria-label="Пакеты меню"><button type="button" data-banquet-package="all" aria-pressed="${packageId==='all'}"><b>Все блюда</b><small>${catalog.length} позиций</small></button>${menus.map(m=>`<button type="button" data-banquet-package="${esc(m.id)}" aria-pressed="${packageId===m.id}"><b>${esc(m.name)}</b><span>${m.price?money(m.price):'Своя подборка'}</span><small>${m.grams?`${Number(m.grams).toLocaleString('ru-RU')} г · `:''}на 1 гостя</small></button>`).join('')}</div>`:''}
${catalog.some(i=>i.demo&&i.ttk)?'<p class="ct-banquet-estimate">Учебные блюда: вес и цена указаны на одну порцию. Откройте ТТК, чтобы посмотреть состав и расход продуктов для склада и закупки.</p>':''}
${estimates?'<p class="ct-banquet-estimate">≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.</p>':''}
${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''}
<div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div>
<div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}${window.CateriumPricing?.badge(i)||''}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label>${i.demo&&i.ttk?`<button type="button" data-banquet-ttk="${esc(i.id)}" aria-label="ТТК: ${esc(i.name)}" title="Открыть технологическую карту">ТТК</button>`:''}<button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div>
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button><button class="outline" type="button" data-banquet-client-menu ${selected.length?'':'disabled'}>Меню для клиента</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
}
function bind(root,context){
const {catalog,draft,rerender}=context;
root.querySelectorAll('[data-banquet-package]').forEach(b=>b.onclick=()=>{packageId=b.dataset.banquetPackage;groupId='all';rerender()});
root.querySelectorAll('[data-banquet-group]').forEach(b=>b.onclick=()=>{groupId=b.dataset.banquetGroup;rerender()});
root.querySelectorAll('[data-banquet-item]').forEach(b=>b.onchange=()=>{toggle(draft,catalog,b.dataset.banquetItem);rerender()});
root.querySelectorAll('[data-banquet-ttk]').forEach(b=>b.onclick=()=>window.CateriumTrialDemo?.showTTK(b.dataset.banquetTtk));
root.querySelectorAll('[data-banquet-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit));
const clientMenu=root.querySelector('[data-banquet-client-menu]');
clientMenu.onclick=async()=>{
const who=JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]);
const input=JSON.parse(JSON.stringify({catalog,draft,event:document.getElementById('event')?.value??draft.event,date:document.getElementById('date')?.value??draft.date}));
clientMenu.disabled=true;
try{
if(!window.CateriumBanquetClientMenu)await new Promise((resolve,reject)=>{
const script=document.createElement('script');let timer;
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);
});
if(!root.isConnected||who!==JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]))return;
await window.CateriumBanquetClientMenu.open(input);
}catch(error){if(root.isConnected)window.alert(error.message);}
finally{if(clientMenu.isConnected)clientMenu.disabled=false;}
};
const preset=root.querySelector('[data-banquet-preset]');if(preset)preset.onclick=()=>{chooseMenu(draft,catalog,preset.dataset.banquetPreset);rerender()};
const clear=root.querySelector('[data-banquet-clear]');if(clear)clear.onclick=()=>{draft.banquetSelection=[];rerender()};
root.querySelector('[data-banquet-add]').onclick=()=>window.editBox?.(null);
const guests=root.querySelector('[data-banquet-guests]');guests.oninput=()=>{draft.guestsCount=Math.min(10000,Math.max(1,Math.round(Number(guests.value)||1)));const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;const ids=selection(draft,catalog),sum=totals(catalog.filter(i=>ids.has(String(i.id))),draft);root.querySelector('[data-banquet-total]').textContent=money(sum.price*draft.guestsCount);root.querySelector('[data-banquet-total-weight]').textContent=(sum.grams*draft.guestsCount/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})+' кг'};guests.onchange=()=>{guests.value=guestCount(draft)};
root.querySelector('[data-banquet-apply]').onclick=()=>{if(window.SunAdminRBACV3&&!window.SunAdminRBACV3.hasPermission(draft.id?'orders.edit':'orders.create'))return;apply(draft,catalog);const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;rerender();window.SunEnterprise?.toast?.('Банкетное меню добавлено в заказ.','success')};
}
function editor(category,item,catalog){
const section=document.getElementById('sunBanquetSectionLabel');if(!section)return;
let extra=document.getElementById('ctBanquetEditor');
if(!extra){extra=document.createElement('div');extra.id='ctBanquetEditor';extra.innerHTML='<label>Пакет меню<input id="ctBanquetPackage" list="ctBanquetPackageNames" placeholder="Например, Стандарт"><datalist id="ctBanquetPackageNames"></datalist></label><label class="ct-banquet-estimate-check"><input type="checkbox" id="ctBanquetEstimated"> Вес и цена предварительные</label>';section.insertAdjacentElement('afterend',extra)}
extra.hidden=Number(category)!==6;if(extra.hidden)return;
document.getElementById('ctBanquetPackage').value=item?.banquet?.packageName||'';
document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true;
document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>`<option value="${esc(m.name)}"></option>`).join('');
let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Гарниры','Десерты','Фрукты и ягоды','Хлеб и масло'].map(s=>`<option value="${s}"></option>`).join('');extra.appendChild(list)}
document.getElementById('banquetSection')?.setAttribute('list',list.id);
}
function saveEditor(item,catalog){if(Number(item.category)!==6)return;const input=document.getElementById('ctBanquetPackage');if(!input)return;const name=input.value.trim(),known=packages(catalog).find(m=>m.name===name),same=item.banquet?.packageName===name;item.banquet={...(same?item.banquet:{}),packageId:name?(known?.id||'custom-'+name.toLowerCase().replace(/\s+/g,'-')):'',packageName:name,estimated:document.getElementById('ctBanquetEstimated')?.checked===true};if(known){item.banquet.packagePrice=known.price;item.banquet.packageGrams=known.grams}}
const style=document.createElement('style');style.textContent=`
.ct-banquet-summary [data-banquet-client-menu]{width:100%;margin-top:10px;font-size:12px;min-height:42px}
#tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-dish>[data-banquet-ttk]{font-size:10px;font-weight:700}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none}
@container (min-width:760px){.ct-banquet-layout{grid-template-columns:minmax(0,1fr) 230px}.ct-banquet-summary{position:sticky;top:12px}}
#new.sun-banquet-active .catalog{container-type:inline-size;width:100%;min-width:0}
#new.sun-banquet-active #sunCatalogSearchV1775,#new.sun-banquet-active .sun-catalog-view-switch{display:none!important}
@media(max-width:600px){.ct-banquet-intro{align-items:flex-start;flex-direction:column}.ct-banquet h2{font-size:21px}.ct-banquet-packages{grid-template-columns:repeat(2,minmax(0,1fr))}.ct-banquet-dish label{grid-template-columns:16px minmax(0,1fr) 67px;gap:7px}.ct-banquet-name{grid-column:2/4}.ct-banquet-dish input{grid-row:1/3}.ct-banquet-weight{grid-column:2;text-align:left}.ct-banquet-price{grid-column:3}.ct-banquet-preset{align-items:flex-start;flex-direction:column}}
`;document.head.appendChild(style);
window.addEventListener('sun:cloud-tenant-changing',()=>{packageId='all';groupId='all'});
window.CateriumBanquet={render,bind,editor,saveEditor,packages,group,grams,totals,chooseMenu,toggle,apply};
})();

110
public/core/brand-theme.js Normal file
View File

@ -0,0 +1,110 @@
/* ===== MODULE: brand-theme-v1.js ===== */
/* Sun Catering theme editor v4: simple color presets, staged preview, reliable sidebar apply */
(()=>{
'use strict';
if(window.__sunBrandThemeV4)return;window.__sunBrandThemeV4=true;
// Keep the intermediate legacy layouts out of the first visible frame.
if(document.readyState==='loading'){
document.documentElement.classList.add('sun-app-starting');
document.addEventListener('DOMContentLoaded',()=>requestAnimationFrame(()=>document.documentElement.classList.remove('sun-app-starting')),{once:true});
}
const KEY='sunBrandThemeV1';
const BRAND={sun:'#FFC107',accent:'#FF8A00',text:'#1A1A1A',background:'#FFF7E6',surface:'#F3E8D3',fresh:'#A8C783',success:'#6D8F6A',trust:'#6C7A89',warm:'#C7744D',border:'#E7E7E7'};
const UI_FIRM={background:'#FFF7E6',card:'#FFFFFF',primary:'#1A1A1A',accent:'#FFC107',text:'#1A1A1A',muted:'#6C7A89',border:'#E7E7E7',input:'#FFFFFF',buttonText:'#FFFFFF'};
const UI_STANDARD={background:'#F5F6FA',card:'#FFFFFF',primary:'#15364C',accent:'#C99A32',text:'#203040',muted:'#68717A',border:'#D9DDE0',input:'#FFFFFF',buttonText:'#FFFFFF'};
const SIDEBAR_STANDARD={background:'#32255D',background2:'#171D38',glow:'#9574FF',border:'#4B3C77',icons:'#E9E8F6',iconBackground:'#3A2F68',text:'#E9E8F6',muted:'#A9A8C6',activeBackground:'#463A74',activeText:'#FFFFFF',activeIcon:'#FFE48D',indicator:'#E4B844',toolsBackground:'#3A315F',toolsBorder:'#544A7B',collapseBackground:'#FFFFFF',collapseText:'#433D68',badgeBackground:'#FF6C7C',badgeText:'#FFFFFF'};
const SIDEBAR_FIRM={background:'#1A1A1A',background2:'#2A2A2A',glow:'#FFC107',border:'#3A372F',icons:'#FFC107',iconBackground:'#2A281D',text:'#FFF7E6',muted:'#C9B98E',activeBackground:'#34301F',activeText:'#FFC107',activeIcon:'#FFC107',indicator:'#FF8A00',toolsBackground:'#242424',toolsBorder:'#414141',collapseBackground:'#FFFFFF',collapseText:'#1A1A1A',badgeBackground:'#C7744D',badgeText:'#FFFFFF'};
const SIDEBAR_LIGHT={background:'#FFF7E6',background2:'#F3E8D3',glow:'#FFC107',border:'#E2D5BC',icons:'#C7744D',iconBackground:'#FFFDF9',text:'#1A1A1A',muted:'#6C7A89',activeBackground:'#FFFFFF',activeText:'#C7744D',activeIcon:'#C7744D',indicator:'#FF8A00',toolsBackground:'#FFFFFF',toolsBorder:'#E2D5BC',collapseBackground:'#1A1A1A',collapseText:'#FFFFFF',badgeBackground:'#C7744D',badgeText:'#FFFFFF'};
const SIDEBAR_EMERALD={background:'#123C30',background2:'#071F19',glow:'#FFC107',border:'#285849',icons:'#FFC107',iconBackground:'#173E33',text:'#FFF7E6',muted:'#A8C783',activeBackground:'#1C4C3D',activeText:'#FFF7E6',activeIcon:'#FFC107',indicator:'#FF8A00',toolsBackground:'#16382F',toolsBorder:'#2B5A4B',collapseBackground:'#FFF7E6',collapseText:'#123C30',badgeBackground:'#C7744D',badgeText:'#FFFFFF'};
const SIDEBAR_SUNSET={background:'#49301B',background2:'#1A1A1A',glow:'#FF8A00',border:'#69462A',icons:'#FFC107',iconBackground:'#3A2C1E',text:'#FFF7E6',muted:'#D6BD91',activeBackground:'#62401D',activeText:'#FFF7E6',activeIcon:'#FFC107',indicator:'#FF8A00',toolsBackground:'#34281F',toolsBorder:'#5A4632',collapseBackground:'#FFF7E6',collapseText:'#49301B',badgeBackground:'#C7744D',badgeText:'#FFFFFF'};
const UI_LABELS={background:'Фон приложения',card:'Карточки и окна',primary:'Основные кнопки',accent:'Акцент / обводка',text:'Основной текст',muted:'Вторичный текст',border:'Границы',input:'Поля ввода',buttonText:'Текст основных кнопок'};
const SIDE_LABELS={background:'Основной цвет панели',background2:'Второй цвет / низ панели',glow:'Свечение панели',border:'Граница панели',icons:'Цвет значков',iconBackground:'Фон значков',text:'Основной текст панели',muted:'Подписи групп',activeBackground:'Фон активного пункта',activeText:'Текст активного пункта',activeIcon:'Активный значок',indicator:'Полоса активного пункта',toolsBackground:'Поиск / уведомления',toolsBorder:'Граница служебных кнопок',collapseBackground:'Кнопка свернуть',collapseText:'Стрелка сворачивания',badgeBackground:'Уведомление',badgeText:'Текст уведомления'};
const BRAND_LABELS={sun:'Солнечный',accent:'Оранжевый',text:'Графит',background:'Кремовый фон',surface:'Бежевый',fresh:'Свежий зелёный',success:'Фирменный зелёный',trust:'Серо-синий',warm:'Терракота',border:'Светлая граница'};
const esc=value=>window.SunSafe.escapeHTML(value);
const clone=o=>JSON.parse(JSON.stringify(o));
const isHex=v=>/^#[0-9a-f]{6}$/i.test(String(v||'').trim());
const norm=(v,f)=>isHex(v)?String(v).toUpperCase():f;
const hexRgba=(hex,a=.25)=>{const h=norm(hex,'#000000').slice(1);return `rgba(${parseInt(h.slice(0,2),16)},${parseInt(h.slice(2,4),16)},${parseInt(h.slice(4,6),16)},${a})`};
const defaults=()=>({version:4,palette:{...BRAND},ui:{...UI_FIRM},sidebar:{...SIDEBAR_FIRM}});
const standardState=()=>({version:4,palette:{...BRAND},ui:{...UI_STANDARD},sidebar:{...SIDEBAR_STANDARD}});
const presetState=name=>{
if(name==='standard')return standardState();
const sidebar=name==='light'?SIDEBAR_LIGHT:name==='emerald'?SIDEBAR_EMERALD:name==='sunset'?SIDEBAR_SUNSET:SIDEBAR_FIRM;
return{version:4,palette:{...BRAND},ui:{...UI_FIRM},sidebar:{...sidebar}};
};
function read(){
let raw={};try{raw=JSON.parse(localStorage.getItem(KEY)||'{}')||{}}catch(_){raw={}}
const base=defaults(),palette={...base.palette,...(raw.palette||{})},ui={...base.ui,...(raw.ui||{})},sidebar={...base.sidebar,...(raw.sidebar||{})};
Object.keys(BRAND).forEach(k=>palette[k]=norm(palette[k],BRAND[k]));
Object.keys(UI_FIRM).forEach(k=>ui[k]=norm(ui[k],UI_FIRM[k]));
Object.keys(SIDEBAR_FIRM).forEach(k=>sidebar[k]=norm(sidebar[k],SIDEBAR_FIRM[k]));
if(String(ui.buttonText).toUpperCase()===String(ui.primary).toUpperCase())ui.buttonText='#FFFFFF';
return{version:4,palette,ui,sidebar};
}
let savedState=read(),previewState=clone(savedState),dirty=false;
const persist=()=>localStorage.setItem(KEY,JSON.stringify(savedState));
const style=document.createElement('style');style.id='sun-brand-theme-style-v4';style.textContent=`
:root{--sun-ui-bg:#FFF7E6;--sun-ui-card:#fff;--sun-ui-primary:#1A1A1A;--sun-ui-accent:#FFC107;--sun-ui-text:#1A1A1A;--sun-ui-muted:#6C7A89;--sun-ui-border:#E7E7E7;--sun-ui-input:#fff;--sun-ui-button-text:#fff;--sun-sidebar-bg:#32255D;--sun-sidebar-bg2:#171D38;--sun-sidebar-glow:#9574FF;--sun-sidebar-border:#4B3C77;--sun-sidebar-icons:#E9E8F6;--sun-sidebar-icon-bg:#3A2F68;--sun-sidebar-text:#E9E8F6;--sun-sidebar-muted:#A9A8C6;--sun-sidebar-active-bg:#463A74;--sun-sidebar-active-text:#fff;--sun-sidebar-active-icon:#FFE48D;--sun-sidebar-indicator:#E4B844;--sun-sidebar-tools-bg:#3A315F;--sun-sidebar-tools-border:#544A7B;--sun-sidebar-collapse-bg:#fff;--sun-sidebar-collapse-text:#433D68;--sun-sidebar-badge-bg:#FF6C7C;--sun-sidebar-badge-text:#fff}:root body{background:var(--sun-ui-bg)!important;color:var(--sun-ui-text)!important}:root{--n:var(--sun-ui-primary)!important;--g:var(--sun-ui-accent)!important;--p:var(--sun-ui-bg)!important;--l:var(--sun-ui-border)!important}:root .card,:root .page,:root .enterprise-card,:root .dialog,:root .sun-order-lines-table,:root .dash3-panel{background:var(--sun-ui-card)!important;color:var(--sun-ui-text)!important;border-color:var(--sun-ui-border)!important}:root .page h1,:root .catalog h1,:root .enterprise-card h2,:root .enterprise-card h3{color:var(--sun-ui-primary)!important}:root .hint,:root label,:root .enterprise-card .hint{color:var(--sun-ui-muted)!important}:root input,:root select,:root textarea{background:var(--sun-ui-input)!important;color:var(--sun-ui-text)!important;border-color:var(--sun-ui-border)!important}:root button.primary,:root .primary{background:var(--sun-ui-primary)!important;border-color:var(--sun-ui-primary)!important;color:var(--sun-ui-button-text)!important}:root .primary svg,:root .primary *{color:inherit!important}:root .outline{border-color:var(--sun-ui-accent)!important;color:var(--sun-ui-primary)!important;background:var(--sun-ui-card)!important}
@media(min-width:901px){:root .sun-enterprise-sidebar header .brand{color:var(--sun-sidebar-text)!important;border-bottom-color:color-mix(in srgb,var(--sun-sidebar-text) 12%,transparent)!important}:root .sun-enterprise-sidebar .sun-nav-group{border-bottom-color:color-mix(in srgb,var(--sun-sidebar-text) 10%,transparent)!important}:root .sun-enterprise-sidebar .sun-nav-group::before{color:var(--sun-sidebar-muted)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button{color:var(--sun-sidebar-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button::before{background:var(--sun-sidebar-icons)!important;color:var(--sun-sidebar-icons)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button:hover{background:color-mix(in srgb,var(--sun-sidebar-active-bg) 72%,transparent)!important;color:var(--sun-sidebar-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button.on{background:var(--sun-sidebar-active-bg)!important;color:var(--sun-sidebar-active-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button.on::before{background:var(--sun-sidebar-active-icon)!important;color:var(--sun-sidebar-active-icon)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button.on::after{background:var(--sun-sidebar-indicator)!important;box-shadow:0 0 10px color-mix(in srgb,var(--sun-sidebar-indicator) 42%,transparent)!important}:root .sun-side-tools .sun-side-action{color:var(--sun-sidebar-text)!important;border-color:var(--sun-sidebar-tools-border)!important;background:var(--sun-sidebar-tools-bg)!important}:root .sun-current-user,:root #sunLogoutBtn{color:var(--sun-sidebar-muted)!important}:root .sun-sidebar-collapse{background:var(--sun-sidebar-collapse-bg)!important;color:var(--sun-sidebar-collapse-text)!important;border-color:var(--sun-sidebar-border)!important}:root #sunNotificationsBtn .badge{background:var(--sun-sidebar-badge-bg)!important;color:var(--sun-sidebar-badge-text)!important;border-color:var(--sun-sidebar-bg)!important}}
@media(max-width:900px){:root .sun-enterprise-sidebar header,:root .sun-enterprise-sidebar header nav{background:linear-gradient(165deg,var(--sun-sidebar-bg),var(--sun-sidebar-bg2))!important;color:var(--sun-sidebar-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button{color:var(--sun-sidebar-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button::before{background:var(--sun-sidebar-icons)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button.on{background:var(--sun-sidebar-active-bg)!important;color:var(--sun-sidebar-active-text)!important}:root .sun-enterprise-sidebar header nav .sun-nav-button.on::before{background:var(--sun-sidebar-active-icon)!important}}:root .sun-brand-settings-card{overflow:hidden}:root .sun-theme-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:11px 0 14px}:root .sun-theme-sections{display:grid;gap:13px}:root .sun-theme-box{border:1px solid var(--sun-ui-border);border-radius:12px;padding:13px;background:color-mix(in srgb,var(--sun-ui-card) 96%,var(--sun-ui-bg))}:root .sun-theme-box h3{margin:0 0 9px}:root .sun-theme-panel-main{display:grid;grid-template-columns:minmax(250px,.8fr) minmax(340px,1.2fr);gap:14px;align-items:start}:root .sun-theme-preview{min-height:190px;border-radius:16px;background:radial-gradient(circle at 10% 0,color-mix(in srgb,var(--sun-sidebar-glow) 35%,transparent),transparent 35%),linear-gradient(150deg,var(--sun-sidebar-bg),var(--sun-sidebar-bg2));padding:15px;color:var(--sun-sidebar-text);border:1px solid var(--sun-sidebar-border)}:root .sun-theme-preview-head{display:flex;align-items:center;gap:10px;padding-bottom:12px;border-bottom:1px solid color-mix(in srgb,var(--sun-sidebar-text) 13%,transparent)}:root .sun-theme-preview-logo{width:35px;height:35px;border-radius:50%;background:var(--sun-sidebar-icons);box-shadow:0 0 18px color-mix(in srgb,var(--sun-sidebar-glow) 45%,transparent)}:root .sun-theme-preview-menu{display:grid;gap:7px;margin-top:13px}:root .sun-theme-preview-row{height:38px;border-radius:10px;display:flex;align-items:center;gap:10px;padding:0 10px;color:var(--sun-sidebar-text)}:root .sun-theme-preview-row.on{background:var(--sun-sidebar-active-bg);color:var(--sun-sidebar-active-text);box-shadow:inset 3px 0 0 var(--sun-sidebar-indicator)}:root .sun-theme-preview-row i{width:25px;height:25px;display:grid;place-items:center;color:var(--sun-sidebar-icons);font-style:normal}:root .sun-theme-preview-row.on i{color:var(--sun-sidebar-active-icon)}:root .sun-theme-preview small{color:var(--sun-sidebar-muted)}:root .sun-sidebar-core-colors{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:12px}:root .sun-brand-setting-grid{display:grid;grid-template-columns:repeat(3,minmax(185px,1fr));gap:8px}:root .sun-brand-color-field{display:grid;grid-template-columns:34px minmax(0,1fr) 82px;gap:7px;align-items:center;padding:7px 8px;border:1px solid var(--sun-ui-border);border-radius:9px;background:var(--sun-ui-card)}:root .sun-brand-color-field label{font-size:10.5px;line-height:1.2}:root .sun-brand-color-field input[type=color]{width:32px!important;height:30px!important;padding:2px!important;border:1px solid var(--sun-ui-border)!important;border-radius:7px!important}:root .sun-brand-color-field input[type=text]{width:82px!important;height:30px!important;padding:4px 6px!important;border-radius:7px!important;font:700 10px ui-monospace,monospace!important;text-transform:uppercase}:root .sun-brand-palette-toggle{margin-top:10px}:root .sun-brand-palette-toggle summary{cursor:pointer;font-weight:750;color:var(--sun-ui-primary)}:root .sun-brand-palette{display:grid;grid-template-columns:repeat(5,minmax(90px,1fr));gap:8px;margin-top:10px}:root .sun-brand-swatch{border:1px solid var(--sun-ui-border);border-radius:10px;background:var(--sun-ui-card);overflow:hidden}:root .sun-brand-swatch-top{height:38px;background:var(--swatch)}:root .sun-brand-swatch-body{padding:6px 7px}:root .sun-brand-swatch-body b{display:block;font-size:10px}:root .sun-brand-swatch-body small{font:700 9px ui-monospace,monospace;color:var(--sun-ui-muted)}:root .sun-theme-savebar{position:sticky;bottom:0;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:10px;margin:15px -13px -13px;padding:12px 13px;background:color-mix(in srgb,var(--sun-ui-card) 94%,transparent);border-top:1px solid var(--sun-ui-border);backdrop-filter:blur(10px)}:root .sun-theme-savebar .actions{display:flex;gap:8px;flex-wrap:wrap}:root .sun-theme-dirty{font-size:11px;color:var(--sun-ui-muted)}:root .sun-theme-dirty strong{color:var(--sun-ui-accent)}:root header .brand.sun-brand-home-link{cursor:pointer!important;border-radius:10px;transition:background .15s ease}:root .sun-enterprise-sidebar header .brand.sun-brand-home-link:hover{background:rgba(255,255,255,.06)!important}:root .sun-brand-home-link:focus-visible{outline:2px solid var(--sun-sidebar-indicator);outline-offset:2px}
@media(max-width:1100px){:root .sun-brand-setting-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:760px){:root .sun-theme-panel-main,:root .sun-sidebar-core-colors,:root .sun-brand-setting-grid{grid-template-columns:1fr}:root .sun-brand-palette{grid-template-columns:repeat(2,1fr)}:root .sun-theme-savebar{align-items:flex-start;flex-direction:column}}
:root{background:var(--sun-ui-bg)}
:root body.sun-enterprise-sidebar{background:var(--sun-ui-bg)!important}
:root body>header,:root body.sun-enterprise-sidebar>header{
background:radial-gradient(circle at 12% 0%,var(--sun-sidebar-glow-rgba),transparent 31%),linear-gradient(165deg,var(--sun-sidebar-bg) 0%,var(--sun-sidebar-bg2) 100%)!important;
border-right:1px solid var(--sun-sidebar-border)!important;
box-shadow:9px 0 34px var(--sun-sidebar-shadow)!important;
}
:root.sun-app-starting body{visibility:hidden!important}
:root.sun-app-starting .sun-enterprise-sidebar,:root.sun-app-starting .sun-enterprise-sidebar *,:root.sun-app-starting .sun-enterprise-sidebar *::before,:root.sun-app-starting .sun-enterprise-sidebar *::after{transition:none!important;animation:none!important}
:root.sun-app-starting::after{content:'Caterium';position:fixed;inset:0;display:grid;place-items:center;background:var(--sun-ui-bg);color:var(--sun-ui-text);font:500 32px Georgia,serif;z-index:40000}
`;document.head.appendChild(style);
function applyActual(s=savedState){
document.body?.classList.remove('sun-custom-sidebar-icons');
const root=document.documentElement.style;
Object.entries(s.palette).forEach(([k,v])=>root.setProperty(`--sun-brand-${k}`,v));
Object.entries(s.ui).forEach(([k,v])=>root.setProperty(`--sun-ui-${k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase())}`,v));
const map={background:'--sun-sidebar-bg',background2:'--sun-sidebar-bg2',glow:'--sun-sidebar-glow',border:'--sun-sidebar-border',icons:'--sun-sidebar-icons',iconBackground:'--sun-sidebar-icon-bg',text:'--sun-sidebar-text',muted:'--sun-sidebar-muted',activeBackground:'--sun-sidebar-active-bg',activeText:'--sun-sidebar-active-text',activeIcon:'--sun-sidebar-active-icon',indicator:'--sun-sidebar-indicator',toolsBackground:'--sun-sidebar-tools-bg',toolsBorder:'--sun-sidebar-tools-border',collapseBackground:'--sun-sidebar-collapse-bg',collapseText:'--sun-sidebar-collapse-text',badgeBackground:'--sun-sidebar-badge-bg',badgeText:'--sun-sidebar-badge-text'};
Object.entries(map).forEach(([k,v])=>root.setProperty(v,s.sidebar[k]));
root.setProperty('--sun-ui-bg',s.ui.background);
root.setProperty('--sun-sidebar-glow-rgba',hexRgba(s.sidebar.glow,.30));
root.setProperty('--sun-sidebar-shadow',hexRgba(s.sidebar.background,.22));
window.dispatchEvent(new CustomEvent('sunbrandthemechange',{detail:clone(s)}));
}
function markDirty(){dirty=JSON.stringify(previewState)!==JSON.stringify(savedState);const el=document.querySelector('[data-theme-dirty]');if(el)el.innerHTML=dirty?'<strong>Есть несохранённые изменения</strong> · пока меняется только предпросмотр':'Все изменения сохранены'}
function field(scope,key,label,value){return`<div class="sun-brand-color-field" data-color-scope="${scope}" data-color-key="${key}"><input type="color" value="${esc(value)}" aria-label="${esc(label)}"><label>${esc(label)}</label><input type="text" value="${esc(value)}" maxlength="7" spellcheck="false" aria-label="HEX ${esc(label)}"></div>`}
function paletteHtml(){return Object.keys(BRAND).map(k=>`<div class="sun-brand-swatch" style="--swatch:${esc(BRAND[k])}"><div class="sun-brand-swatch-top"></div><div class="sun-brand-swatch-body"><b>${esc(BRAND_LABELS[k])}</b><small>${esc(BRAND[k])}</small></div></div>`).join('')}
function updatePreviewNode(card){
const p=card.querySelector('.sun-theme-preview');if(!p)return;
const s=previewState.sidebar;
const vars={background:'--sun-sidebar-bg',background2:'--sun-sidebar-bg2',glow:'--sun-sidebar-glow',border:'--sun-sidebar-border',icons:'--sun-sidebar-icons',iconBackground:'--sun-sidebar-icon-bg',text:'--sun-sidebar-text',muted:'--sun-sidebar-muted',activeBackground:'--sun-sidebar-active-bg',activeText:'--sun-sidebar-active-text',activeIcon:'--sun-sidebar-active-icon',indicator:'--sun-sidebar-indicator'};
Object.entries(vars).forEach(([k,v])=>p.style.setProperty(v,s[k]));
}
function cardHtml(){
return`<h2>Цвета интерфейса</h2><p class="hint">Выберите готовый вариант или настройте цвета вручную. Изменения сначала видны в примере. Реальная левая панель меняется только после «Сохранить настройки».</p>
<div class="sun-theme-toolbar"><button class="outline" type="button" data-theme-preset="standard">Стандартная фиолетовая</button><button class="outline" type="button" data-theme-preset="firm">Фирменная тёмная</button><button class="outline" type="button" data-theme-preset="light">Фирменная светлая</button><button class="outline" type="button" data-theme-preset="emerald">Фирменная зелёная</button><button class="outline" type="button" data-theme-preset="sunset">Солнечная графитовая</button></div>
<div class="sun-theme-sections">
<section class="sun-theme-box"><h3>Левая панель</h3><div class="sun-theme-panel-main"><div class="sun-theme-preview"><div class="sun-theme-preview-head"><div class="sun-theme-preview-logo"></div><div><b>Caterium</b><small>Предпросмотр боковой панели</small></div></div><div class="sun-theme-preview-menu"><div class="sun-theme-preview-row"><i></i>Главная</div><div class="sun-theme-preview-row on"><i></i>Заказы</div><div class="sun-theme-preview-row"><i></i>Настройки</div></div></div><div><h3 style="margin-top:0">Главные цвета панели</h3><div class="sun-sidebar-core-colors">${field('sidebar','background','Основной цвет панели',previewState.sidebar.background)}${field('sidebar','background2','Низ / второй цвет',previewState.sidebar.background2)}${field('sidebar','icons','Цвет значков',previewState.sidebar.icons)}</div><p class="hint">Основной цвет это фон всей большой левой панели. Для однотонной панели поставьте одинаковый цвет в первых двух полях.</p></div></div><details style="margin-top:12px"><summary><b>Дополнительные цвета панели</b></summary><div class="sun-brand-setting-grid" style="margin-top:10px">${Object.keys(SIDEBAR_FIRM).filter(k=>!['background','background2','icons'].includes(k)).map(k=>field('sidebar',k,SIDE_LABELS[k],previewState.sidebar[k])).join('')}</div></details></section>
<section class="sun-theme-box"><h3>Основной интерфейс</h3><div class="sun-brand-setting-grid">${Object.keys(UI_FIRM).map(k=>field('ui',k,UI_LABELS[k],previewState.ui[k])).join('')}</div><details class="sun-brand-palette-toggle"><summary>Показать фирменные цвета «Солнца»</summary><div class="sun-brand-palette">${paletteHtml()}</div></details></section>
</div><div class="sun-theme-savebar"><div class="sun-theme-dirty" data-theme-dirty>${dirty?'<strong>Есть несохранённые изменения</strong> · пока меняется только предпросмотр':'Все изменения сохранены'}</div><div class="actions"><button class="outline" type="button" data-theme-cancel>Отменить изменения</button><button class="danger" type="button" data-reset-standard>Вернуть стандартную версию</button><button class="primary" type="button" data-theme-save>Сохранить настройки</button></div></div>`;
}
function rerender(card){card.innerHTML=cardHtml();bind(card);updatePreviewNode(card)}
function bind(card){
card.querySelectorAll('[data-color-scope]').forEach(row=>{const scope=row.dataset.colorScope,key=row.dataset.colorKey,c=row.querySelector('input[type=color]'),t=row.querySelector('input[type=text]');const applyValue=v=>{const fallback=scope==='ui'?UI_FIRM[key]:SIDEBAR_FIRM[key];const val=norm(v,fallback);previewState[scope][key]=val;if(c)c.value=val;if(t)t.value=val;updatePreviewNode(card);markDirty()};c?.addEventListener('input',()=>applyValue(c.value));t?.addEventListener('input',()=>{if(isHex(t.value))applyValue(t.value)});t?.addEventListener('blur',()=>{if(!isHex(t.value))t.value=previewState[scope][key]})});
card.querySelectorAll('[data-theme-preset]').forEach(b=>b.addEventListener('click',()=>{previewState=presetState(b.dataset.themePreset);markDirty();rerender(card)}));
card.querySelector('[data-theme-save]')?.addEventListener('click',()=>{savedState=clone(previewState);savedState.version=4;persist();dirty=false;applyActual(savedState);rerender(card);window.SunEnterprise?.toast?.('Настройки цветов сохранены.','success')});
card.querySelector('[data-theme-cancel]')?.addEventListener('click',()=>{previewState=clone(savedState);dirty=false;rerender(card)});
card.querySelector('[data-reset-standard]')?.addEventListener('click',()=>{if(!confirm('Вернуть стандартную фиолетовую панель и стандартные цвета интерфейса?'))return;savedState=standardState();previewState=clone(savedState);dirty=false;persist();applyActual(savedState);rerender(card);window.SunEnterprise?.toast?.('Стандартная версия восстановлена.','success')});
}
function ensureCard(){const view=document.getElementById('enterprise-settings'),grid=view?.querySelector('.enterprise-grid');if(!grid)return;let card=document.getElementById('sunBrandThemeSettingsCard');if(card&&grid.contains(card)){if(!card.dataset.bound){rerender(card);card.dataset.bound='1'}return}card=document.createElement('section');card.id='sunBrandThemeSettingsCard';card.className='enterprise-card wide sun-brand-settings-card';card.innerHTML=cardHtml();const history=[...grid.children].find(el=>(el.querySelector(':scope > h2')?.textContent||'').trim()==='История изменений');if(history)window.SunSafe.insertBefore(grid,card,history);else grid.appendChild(card);bind(card);updatePreviewNode(card);card.dataset.bound='1'}
function bindBrandHome(){const brand=document.querySelector('header .brand');if(!brand||brand.dataset.sunHomeBound)return;brand.dataset.sunHomeBound='1';brand.classList.add('sun-brand-home-link');brand.setAttribute('role','link');brand.setAttribute('tabindex','0');brand.setAttribute('title','Новый заказ');const go=()=>{const btn=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Новый заказ')||document.querySelector('header nav .nav-new');btn?.click()};brand.addEventListener('click',go);brand.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go()}})}
function syncFromStorage(){const next=read();if(JSON.stringify(next)===JSON.stringify(savedState))return;savedState=next;if(!dirty)previewState=clone(savedState);applyActual(savedState);const card=document.getElementById('sunBrandThemeSettingsCard');if(card&&!dirty)rerender(card)}
applyActual(savedState);previewState=clone(savedState);
const boot=()=>{applyActual(savedState);ensureCard();bindBrandHome();const settings=document.getElementById('enterprise-settings');if(settings)new MutationObserver(()=>queueMicrotask(()=>{if(settings.classList.contains('on')&&!document.getElementById('sunBrandThemeSettingsCard'))ensureCard();bindBrandHome()})).observe(settings,{childList:true,subtree:true});document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const name=String(b.dataset.navLabel||b.textContent||'').trim();if(name!=='Настройки'&&dirty){previewState=clone(savedState);dirty=false;const card=document.getElementById('sunBrandThemeSettingsCard');if(card)rerender(card)}},true);window.addEventListener('storage',e=>{if(e.key===KEY)syncFromStorage()});window.addEventListener('suncloudsync',syncFromStorage);window.addEventListener('sun:cloud-state-applied',syncFromStorage)};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
window.SunBrandTheme={get:()=>clone(savedState),preview:()=>clone(previewState),save:()=>{savedState=clone(previewState);persist();dirty=false;applyActual(savedState)},resetStandard:()=>{savedState=standardState();previewState=clone(savedState);persist();dirty=false;applyActual(savedState);ensureCard()},applyPreset:name=>{previewState=presetState(name);dirty=true;ensureCard()}};
})();
;

View File

@ -0,0 +1,112 @@
/* Catalog prices are derived, never overwritten by a timer. Stored order lines
keep their own price snapshots. Promotion dates are absolute UTC instants. */
(()=>{
'use strict';
const DAY=86400000,MAX_TIMER=2147480000;
const number=v=>typeof v==='number'?v:Number(String(v??'').trim().replace(',','.'));
const cents=v=>Math.round((v+Number.EPSILON)*100)/100;
const validPrice=v=>Number.isFinite(number(v))&&number(v)>=0;
const money=v=>`${Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`;
const stamp=v=>typeof v==='string'&&v.trim()?Date.parse(v):NaN;
const $=id=>document.getElementById(id);
function basePrice(item){
const current=validPrice(item?.price)?cents(number(item.price)):0;
// Compatibility only: old records stored the discounted price in price.
return !item?.promotion&&validPrice(item?.oldPrice)&&number(item.oldPrice)>current?cents(number(item.oldPrice)):current;
}
function promotion(item){
if(item?.promotion&&typeof item.promotion==='object')return item.promotion;
const base=basePrice(item),current=validPrice(item?.price)?cents(number(item.price)):0;
return base>current?{type:'amount',value:cents(base-current),startsAt:null,endsAt:null}:null;
}
function quote(item,now=Date.now()){
const base=basePrice(item),p=promotion(item),value=number(p?.value);
const start=p?.startsAt==null?-Infinity:stamp(p.startsAt),end=p?.endsAt==null?Infinity:stamp(p.endsAt);
const valid=Boolean(p&&['percent','amount'].includes(p.type)&&Number.isFinite(value)&&value>0&&base>0&&value<=(p.type==='percent'?100:base)&&!Number.isNaN(start)&&!Number.isNaN(end)&&end>start);
const active=valid&&now>=start&&now<end;
return {base,price:active?cents(Math.max(0,base-(p.type==='percent'?base*value/100:value))):base,active,scheduled:valid&&now<start,expired:valid&&now>=end};
}
const price=(item,now)=>quote(item,now).price;
function linePrice(line,item,now){return line?.price!==undefined&&line.price!==null&&String(line.price).trim()!==''&&validPrice(line.price)?cents(number(line.price)):price(item,now)}
const badge=(item)=>quote(item).active?'<span class="ct-promotion-badge">Акция</span>':'';
const localTime=iso=>{const d=new Date(iso);if(!Number.isFinite(d.getTime()))return '';return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`};
let editorOriginal=null,getter=null,timer=0,lastSignature='';
function loadEditor(item){
const p=promotion(item),q=quote(item);
editorOriginal=p?{...p}:null;
if(!$('ctPromotionEnabled'))return;
$('boxPrice').value=basePrice(item);
$('ctPromotionEnabled').checked=Boolean(p&&!q.expired);
$('ctDiscountType').value=p?.type==='amount'?'amount':'percent';
$('ctDiscountValue').value=p?.value??'';
const keep=$('ctPromotionDuration').querySelector('[value="keep"]');
keep.hidden=!(p?.endsAt&&!q.expired);
$('ctPromotionDuration').value=p&&!q.expired?(p.endsAt?'keep':'none'):'7';
$('ctPromotionEnd').value=p?.endsAt?localTime(p.endsAt):'';
$('ctPromotionError').textContent='';
updateEditor();
}
function readEditor(now=Date.now()){
const base=number($('boxPrice')?.value);
if(!Number.isFinite(base)||base<0)return {error:'Укажите обычную цену: число не меньше нуля.',field:'boxPrice'};
if(!$('ctPromotionEnabled')?.checked)return {base:cents(base),promotion:null};
const type=$('ctDiscountType').value,raw=$('ctDiscountValue').value,value=number(raw);
if(!raw.trim()||!Number.isFinite(value)||cents(value)<=0)return {error:'Укажите скидку больше нуля.',field:'ctDiscountValue'};
if(base<=0||value>(type==='percent'?100:base))return {error:type==='percent'?'Скидка должна быть не больше 100%, а обычная цена — больше нуля.':'Скидка не может превышать обычную цену.',field:'ctDiscountValue'};
const duration=$('ctPromotionDuration').value;
let startsAt=editorOriginal?.startsAt||new Date(now).toISOString(),endsAt=null;
if(duration==='keep')endsAt=editorOriginal?.endsAt;
else if(duration==='custom'){
const rawEnd=$('ctPromotionEnd').value,t=new Date(rawEnd).getTime();
if(!rawEnd||!Number.isFinite(t))return {error:'Выберите дату и время окончания акции.',field:'ctPromotionEnd'};
endsAt=new Date(t).toISOString();startsAt=new Date(now).toISOString();
}else if(duration!=='none'){
if(!['1','7','14','30'].includes(duration))return {error:'Выберите срок акции.',field:'ctPromotionDuration'};
startsAt=new Date(now).toISOString();endsAt=new Date(now+Number(duration)*DAY).toISOString();
}
if(endsAt&&stamp(endsAt)<=now)return {error:'Акция уже закончилась. Выберите новый срок или выключите её.',field:'ctPromotionDuration'};
return {base:cents(base),promotion:{type,value:cents(value),startsAt,endsAt}};
}
function updateEditor(){
const enabled=$('ctPromotionEnabled')?.checked,fields=$('ctPromotionFields');if(!fields)return;
fields.hidden=!enabled;
$('ctPromotionEndLabel').hidden=$('ctPromotionDuration').value!=='custom';
const type=$('ctDiscountType').value;
$('ctDiscountCaption').textContent=type==='percent'?'Скидка, %':'Скидка, ₽';
$('ctDiscountValue').max=type==='percent'?'100':String(number($('boxPrice').value)||0);
const data=readEditor(),preview=$('ctPromotionPreview');
if(data.error){preview.textContent=enabled?data.error:'';return;}
const q=quote({price:data.base,promotion:data.promotion});
const ending=data.promotion?.endsAt?` До ${new Date(data.promotion.endsAt).toLocaleString('ru-RU',{day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'})}. Затем снова ${money(data.base)}.`:' Без ограничения срока.';
preview.textContent=enabled?`Цена по акции: ${money(q.price)}.${ending}`:`Обычная цена: ${money(data.base)}.`;
}
function saveEditor(item){
const data=readEditor();
if(data.error){$('ctPromotionError').textContent=data.error;$(data.field)?.focus();return false;}
item.price=data.base;
if(data.promotion)item.promotion=data.promotion;else delete item.promotion;
delete item.oldPrice;delete item.sale;
$('ctPromotionError').textContent='';return true;
}
function refresh(){
clearTimeout(timer);timer=0;if(!getter)return;
const now=Date.now(),items=getter()||[];
const signature=JSON.stringify(items.map(i=>[String(i.id),price(i,now),quote(i,now).active]));
if(signature!==lastSignature){lastSignature=signature;window.dispatchEvent(new CustomEvent('caterium:catalog-prices-changed'));}
let next=Infinity;
for(const item of items){const p=promotion(item);for(const t of [stamp(p?.startsAt),stamp(p?.endsAt)])if(t>now)next=Math.min(next,t);}
if(Number.isFinite(next))timer=setTimeout(refresh,Math.min(MAX_TIMER,Math.max(1,next-Date.now()+10)));
}
function watch(catalogGetter){getter=catalogGetter;refresh();}
function boot(){
$('ctPromotionEditor')?.addEventListener('input',()=>{if($('ctPromotionError'))$('ctPromotionError').textContent='';updateEditor();});
$('ctPromotionEditor')?.addEventListener('change',updateEditor);
$('boxPrice')?.addEventListener('input',updateEditor);
window.addEventListener('sun:cloud-state-applied',refresh);
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
window.addEventListener('sun:cloud-tenant-changing',()=>{clearTimeout(timer);timer=0;lastSignature='';editorOriginal=null;});
}
window.CateriumPricing=Object.freeze({basePrice,promotion,quote,price,linePrice,badge,loadEditor,saveEditor,refresh,watch});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

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

View File

@ -0,0 +1,30 @@
/* Compact client summaries. Full addresses, orders and loyalty remain in the
existing detail dialog. The entire summary is a keyboard-accessible button. */
#clients #client-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;align-items:start}
#clients .client-card{min-width:0;margin:0;padding:0;border-radius:10px;overflow:hidden;background:var(--sun-ui-card,#fff);border:1px solid var(--sun-ui-border,#d9dde0);box-shadow:none}
#clients .client-open{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 8px;align-content:center;width:100%;min-height:82px;margin:0;padding:10px 12px;border:0;border-radius:9px;background:transparent;color:var(--sun-ui-text,#24211d);text-align:left;font:inherit;cursor:pointer}
#clients .client-open:hover{background:color-mix(in srgb,var(--sun-ui-accent,#c99a32) 8%,var(--sun-ui-card,#fff))}
#clients .client-open:focus-visible{outline:2px solid var(--sun-ui-accent,#c99a32);outline-offset:-3px}
#clients .client-name{font-size:14px;line-height:18px;font-weight:700;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#clients .client-phone{grid-column:1/-1;font-size:12px;line-height:15px;color:var(--sun-ui-muted,#68717a);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#clients .client-brief{grid-column:1/-1;font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#clients .client-loyalty-compact{align-self:center;padding:2px 5px;border-radius:6px;background:#f1ecff;color:#5b49ad;font-size:11px;line-height:14px;font-weight:700}
#clients .client-open-arrow{align-self:center;font-size:18px;line-height:18px;color:var(--sun-ui-muted,#68717a)}
#ctPromotionEditor{min-width:0;padding:12px;margin:12px 0;border:1px solid var(--sun-ui-border,#deded6);border-radius:12px;background:var(--sun-ui-card,#fff)}
#ctPromotionEditor legend{font-weight:700;font-size:14px;padding:0 5px}
#ctPromotionEditor .ct-promotion-toggle{display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;font-size:14px;cursor:pointer}
#ctPromotionEditor #ctPromotionEnabled{width:18px!important;height:18px;min-height:0;margin:0;flex:0 0 18px}
#ctPromotionFields{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}
#ctPromotionFields label{display:flex;flex-direction:column;gap:5px;margin:0;min-width:0}
#ctPromotionFields input,#ctPromotionFields select{width:100%;min-width:0;min-height:40px;box-sizing:border-box;font:inherit}
#ctPromotionFields .ct-promotion-wide{grid-column:1/-1}
#ctPromotionEditor [hidden]{display:none!important}
#ctPromotionEditor .ct-promotion-note{font-size:11px;line-height:1.4;color:var(--sun-ui-muted,#68717a);margin:7px 0 0}
#ctPromotionPreview{font-size:13px;line-height:1.45;margin:10px 0 0;color:var(--sun-ui-text,#24211d)}
#ctPromotionError{font-size:13px;line-height:1.4;color:#a12f24;margin:8px 0 0}
#ctPromotionError:empty{display:none}
.ct-promotion-badge{display:inline-flex;align-items:center;align-self:start;width:auto!important;padding:3px 7px;border-radius:999px;background:#b9473f;color:#fff!important;font-size:10px!important;line-height:1.2!important;font-weight:800!important;white-space:nowrap;letter-spacing:.02em}
#tiles .tile>.ct-promotion-badge{position:absolute;top:7px;right:7px;z-index:2}
.sun-menu-row-price .ct-promotion-badge{display:table;margin:4px 0 0 auto}
.ct-banquet-name .ct-promotion-badge{margin-left:6px;vertical-align:middle}
@media(max-width:480px){#clients #client-list{grid-template-columns:minmax(0,1fr)}#clients .client-open{min-height:80px}#ctPromotionFields{grid-template-columns:minmax(0,1fr)}}

View File

@ -0,0 +1,60 @@
(()=>{
'use strict';
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']);
const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||''));
const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка');
function routeUrl(base,url){
// Explicit PHP entry point also works for storage paths ending in .jpg:
// Timeweb serves static extensions before Apache rewrite rules.
return base.endsWith('.php')?base+'?__caterium_path='+encodeURIComponent(url.pathname)+(url.search?'&'+url.search.slice(1):''):base+url.pathname+url.search;
}
function mediaUrl(value){
if(!value)return value;
const url=new URL(value,location.href);
if(['https://usfjwhztqoopzzfmfbis.supabase.co','https://api.caterium.ru'].includes(url.origin)&&url.pathname.startsWith('/storage/v1/'))return routeUrl(location.origin+'/api/index.php',url);
return value;
}
function create({upstream,proxy,fallbackProxy=proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){
const origin=new URL(upstream).origin;
proxy=proxy.replace(/\/$/,'');fallbackProxy=fallbackProxy.replace(/\/$/,'');
let fallbackUntil=0;
return async function(input,init={}){
const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin;
// A Request used as RequestInit exposes its ReadableStream body. Safari
// cannot upload that stream; buffer once and preserve bytes on fallback.
const body=original.method==='GET'||original.method==='HEAD'?undefined:await original.clone().arrayBuffer();
const requestInit={method:original.method,headers:original.headers,body,credentials:original.credentials,mode:original.mode,cache:original.cache,redirect:original.redirect,referrer:original.referrer,referrerPolicy:original.referrerPolicy,integrity:original.integrity,keepalive:original.keepalive};
const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length)));
const passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password';
const safeFallback=isBackend&&proxy!==fallbackProxy&&(read||passwordLogin);
const expectJson=original.method!=='HEAD'&&(passwordLogin||url.pathname.startsWith('/rest/v1/rpc/')||(read&&(url.pathname.startsWith('/rest/v1/')||url.pathname.startsWith('/auth/v1/'))));
async function attempt(target,limit){
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
if(original.signal.aborted)abort();else original.signal.addEventListener('abort',abort,{once:true});
const timer=setTimeout(()=>controller.abort(new DOMException('Сервер не ответил вовремя. Проверьте соединение и повторите загрузку.','TimeoutError')),limit);
try{
if(controller.signal.aborted)throw controller.signal.reason;
const response=await fetch(new Request(target,requestInit),{signal:controller.signal});
if(expectJson&&response.ok&&response.status!==204){
const text=await response.clone().text();
try{if(!text.trim()||!response.headers.get('content-type')?.includes('json'))throw new Error();JSON.parse(text)}
catch(_){throw new Error('Сервис вернул пустой или некорректный ответ. Повторите загрузку.')}
}
return response;
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
}
const fallbackFirst=isBackend&&Date.now()<fallbackUntil;
const first=isBackend?routeUrl(fallbackFirst?fallbackProxy:proxy,url):original.url;
const second=routeUrl(fallbackFirst?proxy:fallbackProxy,url);
try{
const response=await attempt(first,safeFallback?(fallbackFirst?fallbackTimeout:timeout):writeTimeout);
if(!safeFallback||response.status<500)return response;
}catch(error){if(!safeFallback||original.signal.aborted)throw error}
// Same backend, same authorization, one SDK session. Never replay writes.
const response=await attempt(second,fallbackFirst?timeout:fallbackTimeout);
if(response.ok)fallbackUntil=fallbackFirst?0:Date.now()+cooldown;
return response;
};
}
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage,mediaUrl});
})();

View File

@ -0,0 +1,101 @@
(()=>{
'use strict';
if(window.CateriumBranding)return;
const NAME_KEY='sunPdfBrandNameV1',LOGO_KEY='sunPdfBrandLogoV1';
const PIXEL='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==';
const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const read=key=>{try{return localStorage.getItem(key)||''}catch(_){return ''}};
const object=key=>{try{const v=JSON.parse(read(key)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch(_){return {}}};
const safeLogo=src=>/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=\s]+$/i.test(String(src||''))&&String(src).length<=2000000?String(src):'';
const clean=value=>String(value||'').trim().slice(0,160);
function identity(){
const profile=object('sunCompanyProfileV1');
const storedName=read(NAME_KEY);
// An explicitly cleared logo must not reappear from an older profile.
let hasLogo=false;try{hasLogo=localStorage.getItem(LOGO_KEY)!==null}catch(_){}
return {
name:clean(storedName||profile.shortName||profile.name)||'Моя компания',
logo:safeLogo(hasLogo?read(LOGO_KEY):profile.logo),
city:clean(profile.city),
contacts:[profile.phone,profile.email,profile.website].map(clean).filter(Boolean).join(' · ')
};
}
function changed(){window.dispatchEvent(new CustomEvent('caterium:document-brand-changed'));}
function saveName(value){localStorage.setItem(NAME_KEY,clean(value));changed();}
function clearLogo(){localStorage.setItem(LOGO_KEY,'');changed();}
// Remove empty PNG margins, keeping a small transparent breathing space.
// Used both on upload and on older logos already saved in offer snapshots.
function prepareLogoImage(image){
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
const scale=Math.min(1,960/Math.max(width,height));
const source=document.createElement('canvas');source.width=Math.max(1,Math.round(width*scale));source.height=Math.max(1,Math.round(height*scale));
const ctx=source.getContext('2d',{willReadFrequently:true});ctx.drawImage(image,0,0,source.width,source.height);
const pixels=ctx.getImageData(0,0,source.width,source.height).data;
let left=source.width,top=source.height,right=-1,bottom=-1;
for(let y=0;y<source.height;y++)for(let x=0;x<source.width;x++)if(pixels[(y*source.width+x)*4+3]>8){left=Math.min(left,x);top=Math.min(top,y);right=Math.max(right,x);bottom=Math.max(bottom,y)}
if(right<left)throw new Error('Логотип полностью прозрачный. Выберите файл с изображением.');
const w=right-left+1,h=bottom-top+1,pad=Math.max(1,Math.round(Math.max(w,h)*.02));
const out=document.createElement('canvas');out.width=w+pad*2;out.height=h+pad*2;
out.getContext('2d').drawImage(source,left,top,w,h,pad,pad,w,h);return out;
}
async function saveLogo(file){
const scope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id,window.SunCloudV2?.getWorkspace?.()?.id]);
const startedIn=scope();
if(!file||!/^image\/(?:png|jpeg|webp)$/i.test(file.type))throw new Error('Выберите PNG, JPEG или WebP.');
if(file.size>15*1024*1024)throw new Error('Логотип должен быть меньше 15 МБ.');
const src=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=()=>reject(new Error('Не удалось прочитать файл.'));reader.readAsDataURL(file)});
const logo=await new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('Не удалось открыть изображение.'));img.src=src});
const canvas=prepareLogoImage(logo);
const data=canvas.toDataURL('image/png');
if(!safeLogo(data))throw new Error('Изображение слишком сложное. Выберите логотип меньшего размера.');
if(startedIn!==scope())throw new Error('Аккаунт или компания изменились. Загрузите логотип ещё раз.');
localStorage.setItem(LOGO_KEY,data);changed();return data;
}
const documentName=()=>identity().name;
const documentLogo=()=>identity().logo;
const documentImage=()=>documentLogo()||PIXEL;
const documentContacts=()=>identity().contacts;
const documentCity=()=>identity().city;
function snapshot(value){const brand=identity();return {...value,brandName:brand.name,logo:brand.logo,brandContacts:brand.contacts,brandCity:brand.city};}
let sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false};
let request=0,checkedUser='',pendingUser='';
const sidebarScope=()=>JSON.stringify([window.SunCloudV2?.getSession?.()?.user?.id||'',window.SunCloudV2?.getWorkspace?.()?.id||'']);
function sidebarHTML(){return `<img class="real-logo${sidebar.special?'':' caterium-product-mark'}" src="${esc(sidebar.logo)}" alt="${esc(sidebar.name)}"><span>${esc(sidebar.name)}</span>`;}
function renderSidebar(){
const node=document.querySelector('body > header .brand');if(!node)return;
const html=sidebarHTML();if(node.innerHTML!==html)node.innerHTML=html;
node.dataset.cateriumBrand=sidebar.special?'solnce':'caterium';
}
function resetSidebar(){request++;checkedUser='';pendingUser='';sidebar={name:'Caterium',logo:'caterium-mark-light.svg',special:false};renderSidebar();}
async function refreshSidebar(){
const cloud=window.SunCloudV2,session=cloud?.getSession?.(),uid=session?.user?.id||'';
if(!uid){resetSidebar();return;}
const ws=cloud.getWorkspace?.()?.id||'',scope=sidebarScope();
if(checkedUser===scope||pendingUser===scope){renderSidebar();return;}
resetSidebar();pendingUser=scope;const current=request;
try{
// New RPC verifies active membership and the selected company. An older
// database keeps its owner-only branding until the additive migration runs.
let result=ws?await cloud.getClient()?.rpc('caterium_workspace_sidebar_brand',{p_workspace:ws}):null;
if(!ws||['PGRST202','42883'].includes(result?.error?.code))result=await cloud.getClient()?.rpc('caterium_my_sidebar_brand');
if(current!==request||sidebarScope()!==scope)return;
if(result?.error||!result?.data)return;
if(result.data.variant==='solnce')sidebar={name:'Солнце Кейтеринг',logo:'sun-logo.png',special:true};
checkedUser=scope;renderSidebar();
}catch(_){/* Until identity is confirmed, show the shared product brand. */}
finally{if(current===request)pendingUser='';}
}
function boot(){
const style=document.createElement('style');style.id='caterium-company-branding-style';
style.textContent='#sunPdfBrandCard .sun-document-brand-setting{display:grid;grid-template-columns:minmax(180px,300px) minmax(0,1fr);gap:24px;align-items:center}#sunPdfBrandPreview{height:140px;display:flex;align-items:center;justify-content:center;padding:16px;border-radius:12px;background:repeating-conic-gradient(#edf0eb 0% 25%,#fff 0% 50%) 0/20px 20px;border:1px solid #dde2d9;color:#475349;font-size:13px}#sunPdfBrandPreview img{width:auto!important;height:auto!important;max-width:100%!important;max-height:108px!important;object-fit:contain;border:0!important;border-radius:0!important}@media(max-width:640px){#sunPdfBrandCard .sun-document-brand-setting{grid-template-columns:1fr}}body > header .brand .caterium-product-mark{background:#fff7e6;border-radius:14px;padding:6px;filter:none!important}';document.head.appendChild(style);
renderSidebar();refreshSidebar();
}
window.CateriumBranding=Object.freeze({identity,documentName,documentLogo,documentImage,documentContacts,documentCity,
nameHTML:()=>esc(documentName()),logoHTML:()=>esc(documentImage()),contactsHTML:()=>esc(documentContacts()),cityHTML:()=>esc(documentCity()),
escapeHTML:esc,snapshot,saveName,saveLogo,clearLogo,prepareLogoImage,sidebarHTML,renderSidebar,refreshSidebar,resetSidebar});
for(const event of ['sun:cloud-state-applied','sun:cloud-permissions-changed','suncloudsync'])window.addEventListener(event,()=>{refreshSidebar();changed()});
window.addEventListener('sun:cloud-tenant-changing',resetSidebar);
window.addEventListener('storage',event=>{if([NAME_KEY,LOGO_KEY,'sunCompanyProfileV1'].includes(event.key))changed();if(/^sb-.*-auth-token$/.test(event.key||''))resetSidebar()});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -9,6 +9,7 @@
const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1'; const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1';
const LEGACY_COMM_KEY='sunClientCommunicationV1'; const LEGACY_COMM_KEY='sunClientCommunicationV1';
const listeners=new Map(); const listeners=new Map();
let tenantChanging=false,tenantEpoch=0;
const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}}; const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}};
const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}}; const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}};
const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}}; const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}};
@ -91,13 +92,21 @@
const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`; const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`;
function orderTotal(order){ function orderTotal(order){
const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit; const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit;
const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+Number(item?.price||0)*Number(line?.qty||0)},0); const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+(window.CateriumPricing?.linePrice(line,item)??Number(line?.price??item?.price??0))*Number(line?.qty||0)},0);
} }
function readObject(key){const value=storage.read(key,{});return value&&typeof value==='object'&&!Array.isArray(value)?value:{}} function readObject(key){const value=storage.read(key,{});return value&&typeof value==='object'&&!Array.isArray(value)?value:{}}
function cacheCore(profile){return {key:profile.key,name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||''),loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,serverVersion:Number(profile.serverVersion||0)||null,serverUpdatedAt:String(profile.serverUpdatedAt||'')}} function cacheCore(profile){return {key:profile.key,name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||''),loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,serverVersion:Number(profile.serverVersion||0)||null,serverUpdatedAt:String(profile.serverUpdatedAt||'')}}
function readClientCache(){return readObject(CLIENT_CACHE_KEY)} function readClientCache(){return readObject(CLIENT_CACHE_KEY)}
function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map} function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map}
// Fingerprint of what the server already holds for a client, so unchanged clients are never re-sent.
const PUSHED_KEY='cateriumClientsPushedV1780';
const stableJson=v=>v===null||typeof v!=='object'?JSON.stringify(v):Array.isArray(v)?'['+v.map(stableJson).join(',')+']':'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+stableJson(v[k])).join(',')+'}';
function contentFp(p){const s=stableJson({n:String(p?.name||''),p:String(p?.phone||''),a:String(p?.latestAddress||''),l:p?.loyalty||null,c:p?.communication||null});let h=5381;for(let i=0;i<s.length;i++)h=((h<<5)+h+s.charCodeAt(i))|0;return (h>>>0).toString(36)+'.'+s.length}
const pushedSlot=(wsId,key)=>`${wsId}|${key}`;
const readPushed=()=>readObject(PUSHED_KEY);
function markPushed(wsId,key,fp){const map=readPushed(),slot=pushedSlot(wsId,key);if(map[slot]===fp)return;map[slot]=fp;storage.write(PUSHED_KEY,map,{silent:true})}
function buildClients(){ function buildClients(){
if(tenantChanging)return [];
const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map(); const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map();
const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)}; const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)};
for(const order of getOrders()){ for(const order of getOrders()){
@ -129,21 +138,42 @@
} }
return [...byKey.values()].sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru')); return [...byKey.values()].sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru'));
} }
function listClients(options={}){const source=typeof options==='string'?options:String(options?.source||'auto');if(source==='local'||source==='legacy')return clone(buildClients());if(source==='server')return clone(serverListClients());return clone(mergeClientSources())} function listClients(options={}){if(tenantChanging)return [];const source=typeof options==='string'?options:String(options?.source||'auto');if(source==='local'||source==='legacy')return clone(buildClients());if(source==='server')return clone(serverListClients());return clone(mergeClientSources())}
function compareClientSources(){ function compareClientSources(){
const local=buildClients(),server=serverListClients(),merged=mergeClientSources(local,server),serverKeys=new Set(server.map(x=>x.key)),localKeys=new Set(local.map(x=>x.key)); const local=buildClients(),server=serverListClients(),merged=mergeClientSources(local,server),serverKeys=new Set(server.map(x=>x.key)),localKeys=new Set(local.map(x=>x.key));
const metricMismatches=local.map(item=>{const m=merged.find(x=>x.key===item.key);return !m||m.orderCount!==item.orderCount||Number(m.totalSpent)!==Number(item.totalSpent)?item.key:null}).filter(Boolean); const metricMismatches=local.map(item=>{const m=merged.find(x=>x.key===item.key);return !m||m.orderCount!==item.orderCount||Number(m.totalSpent)!==Number(item.totalSpent)?item.key:null}).filter(Boolean);
return {localCount:local.length,serverCount:server.length,mergedCount:merged.length,serverPreferred:server.length>0,missingOnServer:[...localKeys].filter(k=>!serverKeys.has(k)),serverOnly:[...serverKeys].filter(k=>!localKeys.has(k)),legacyServerFallbackRows:server.filter(x=>x.key.startsWith('o:')).length,orderMetricsEqual:metricMismatches.length===0,metricMismatches}; return {localCount:local.length,serverCount:server.length,mergedCount:merged.length,serverPreferred:server.length>0,missingOnServer:[...localKeys].filter(k=>!serverKeys.has(k)),serverOnly:[...serverKeys].filter(k=>!localKeys.has(k)),legacyServerFallbackRows:server.filter(x=>x.key.startsWith('o:')).length,orderMetricsEqual:metricMismatches.length===0,metricMismatches};
} }
// When the server already holds exactly what this device has, remember that (and its version) instead of re-sending it.
function seedPushedFromServer(wsId,rows){
const local=new Map(buildClients().map(p=>[p.key,p])),pushed=readPushed(),cache=readClientCache();let pushedDirty=false,cacheDirty=false;
for(const row of rows){
const srv=serverProfile(row),mine=srv&&local.get(srv.key);if(!mine)continue;
const fp=contentFp(mine);if(fp!==contentFp(srv))continue;
const slot=pushedSlot(wsId,srv.key);if(pushed[slot]!==fp){pushed[slot]=fp;pushedDirty=true}
const entry=cache[srv.key];if(entry&&srv.serverVersion&&Number(entry.serverVersion||0)!==srv.serverVersion){cache[srv.key]={...entry,serverVersion:srv.serverVersion,serverUpdatedAt:srv.serverUpdatedAt};cacheDirty=true}
}
if(pushedDirty)storage.write(PUSHED_KEY,pushed,{silent:true});if(cacheDirty)writeClientCache(cache);
}
// Only used when nothing was ever sent for this client from here: the last server snapshot may already hold the same content.
function serverHolds(wsId,key,fp){
const cache=readServerClientCache();if(!cache||String(cache.workspaceId||'')!==String(wsId)||!Array.isArray(cache.rows))return false;
const row=cache.rows.find(r=>String(r?.client_key||r?.canonical_key||'')===key),srv=row&&serverProfile(row);
return Boolean(srv&&contentFp(srv)===fp);
}
let serverRefreshPromise=null; let serverRefreshPromise=null;
async function refreshServerClients({force=false,reason='manual'}={}){ async function refreshServerClients({force=false,reason='manual'}={}){
if(tenantChanging)return {status:'switching'};
if(serverRefreshPromise)return serverRefreshPromise;if(!isSignedIn())return {status:'local',diagnostics:compareClientSources()}; if(serverRefreshPromise)return serverRefreshPromise;if(!isSignedIn())return {status:'local',diagnostics:compareClientSources()};
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',diagnostics:compareClientSources()}; const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',diagnostics:compareClientSources()};
const cache=readServerClientCache(),age=cache?.fetchedAt?Date.now()-Date.parse(cache.fetchedAt):Infinity;if(!force&&String(cache?.workspaceId||'')===String(ws.id)&&age>=0&&age<15000)return {status:'cached',count:currentServerRows().length,diagnostics:compareClientSources()}; const cache=readServerClientCache(),age=cache?.fetchedAt?Date.now()-Date.parse(cache.fetchedAt):Infinity;if(!force&&String(cache?.workspaceId||'')===String(ws.id)&&age>=0&&age<15000)return {status:'cached',count:currentServerRows().length,diagnostics:compareClientSources()};
const epoch=tenantEpoch,userId=session()?.user?.id;
serverRefreshPromise=(async()=>{ serverRefreshPromise=(async()=>{
try{ try{
const {data,error}=await c.rpc('sun_v17_clients_snapshot_v1773',{p_workspace:ws.id});if(error)throw error;const rows=Array.isArray(data)?data:[]; const {data,error}=await c.rpc('sun_v17_clients_snapshot_v1773',{p_workspace:ws.id});if(error)throw error;const rows=Array.isArray(data)?data:[];
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale'};
storage.write(SERVER_CLIENT_CACHE_KEY,{workspaceId:String(ws.id),fetchedAt:new Date().toISOString(),rows:clone(rows)},{silent:true}); storage.write(SERVER_CLIENT_CACHE_KEY,{workspaceId:String(ws.id),fetchedAt:new Date().toISOString(),rows:clone(rows)},{silent:true});
try{seedPushedFromServer(String(ws.id),rows)}catch(_){}
const diagnostics=compareClientSources();emit('clients',{reason:`server:${reason}`,changedKeys:rows.map(r=>String(r?.client_key||'')).filter(Boolean),clients:listClients(),diagnostics}); const diagnostics=compareClientSources();emit('clients',{reason:`server:${reason}`,changedKeys:rows.map(r=>String(r?.client_key||'')).filter(Boolean),clients:listClients(),diagnostics});
try{window.dispatchEvent(new CustomEvent('caterium:clients-server-refresh',{detail:{reason,count:rows.length,diagnostics}}))}catch(_){} try{window.dispatchEvent(new CustomEvent('caterium:clients-server-refresh',{detail:{reason,count:rows.length,diagnostics}}))}catch(_){}
return {status:'loaded',count:rows.length,diagnostics}; return {status:'loaded',count:rows.length,diagnostics};
@ -160,6 +190,7 @@
function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map} function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map}
function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed} function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed}
function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){ function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){
if(tenantChanging)return {changed:0,keys:[],clients:[]};
const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)}; const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)};
} }
function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){ function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){
@ -171,18 +202,44 @@
} }
function setClientLoyalty(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_LOYALTY_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.loyalty'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.loyalty||null)} function setClientLoyalty(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_LOYALTY_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.loyalty'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.loyalty||null)}
function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)} function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)}
function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772',updatedAt:new Date().toISOString()}} function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772'}}
async function pushClient(key,knownProfile){ const pushInflight=new Map();
async function pushClient(key,knownProfile,{force=false}={}){
if(tenantChanging)return {status:'switching',key};
const profile=knownProfile||getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key}; const profile=knownProfile||getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key};
const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key}; const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key};
const fp=contentFp(profile),slot=pushedSlot(ws.id,key);
// Never re-send unchanged content, and never run two saves of one client at once: the second would carry a stale version and conflict with the first.
for(;;){
if(!force){
const known=readPushed()[slot];
if(known===fp)return {status:'unchanged',key};
if(known===undefined&&serverHolds(ws.id,key,fp)){markPushed(ws.id,key,fp);return {status:'unchanged',key}}
}
const running=pushInflight.get(slot);if(!running)break;
if(running.fp===fp)return running.promise;
await running.promise.catch(()=>{});if(tenantChanging)return {status:'switching',key};
}
const promise=(async()=>{
const epoch=tenantEpoch,userId=session()?.user?.id;
const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null; const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null;
const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error; const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error;
if(epoch!==tenantEpoch||tenantChanging||workspace()?.id!==ws.id||session()?.user?.id!==userId)return {status:'stale',key};
const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)} const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)}
markPushed(ws.id,key,fp);
return {status:'saved',key,version:Number(row?.version||0)||null}; return {status:'saved',key,version:Number(row?.version||0)||null};
})();
pushInflight.set(slot,{fp,promise});
try{return await promise}finally{if(pushInflight.get(slot)?.promise===promise)pushInflight.delete(slot)}
} }
const pendingServerKeys=new Set();let serverPushTimer=0; const pendingServerKeys=new Set();let serverPushTimer=0;
function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)} function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)}
async function pushAllClients(){const result=[];for(const profile of listClients()){try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result} let pushAllPromise=null;
function pushAllClients(){
if(pushAllPromise)return pushAllPromise;
pushAllPromise=(async()=>{const result=[],epoch=tenantEpoch,workspaceId=workspace()?.id;for(const profile of listClients()){if(tenantChanging||epoch!==tenantEpoch||workspace()?.id!==workspaceId)break;try{result.push(await pushClient(profile.key,profile))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result})().finally(()=>{pushAllPromise=null});
return pushAllPromise;
}
let bridgeSuppressed=false; let bridgeSuppressed=false;
function installLegacyClientBridge(){ function installLegacyClientBridge(){
@ -207,10 +264,12 @@
window.CateriumDataV1770=api; window.CateriumDataV1770=api;
window.CateriumData=api; window.CateriumData=api;
installLegacyClientBridge(); installLegacyClientBridge();
window.addEventListener('sun:cloud-tenant-changing',()=>{tenantChanging=true;tenantEpoch++;clearTimeout(serverPushTimer);serverPushTimer=0;pendingServerKeys.clear()});
window.addEventListener('sun:cloud-state-applied',()=>{tenantChanging=false});
adoptLegacy({pushServer:false,reason:'boot'}); adoptLegacy({pushServer:false,reason:'boot'});
window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{});clientsRepo.pushAll().catch(()=>{})},800)); window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{clientsRepo.refreshServer({force:true,reason:'permissions'}).catch(()=>{}).then(()=>clientsRepo.pushAll()).catch(()=>{})},800));
window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>{clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'});clientsRepo.refreshServer({force:true,reason:'cloud-sync'}).catch(()=>{})},500)); window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>{clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'});clientsRepo.refreshServer({force:true,reason:'cloud-sync'}).catch(()=>{})},500));
setTimeout(()=>clientsRepo.refreshServer({reason:'boot'}).catch(()=>{}),1400); setTimeout(()=>clientsRepo.refreshServer({reason:'boot'}).catch(()=>{}),1400);
setTimeout(()=>clientsRepo.pushAll().catch(()=>{}),3500); setTimeout(()=>Promise.resolve(serverRefreshPromise).catch(()=>{}).then(()=>clientsRepo.pushAll()).catch(()=>{}),3500);
try{window.dispatchEvent(new CustomEvent('caterium:data-ready',{detail:{version:VERSION,phase:4,clients:true,clientServerRead:true}}))}catch(_){} try{window.dispatchEvent(new CustomEvent('caterium:data-ready',{detail:{version:VERSION,phase:4,clients:true,clientServerRead:true}}))}catch(_){}
})(); })();

View File

@ -71,7 +71,7 @@
function createCompany(){ function createCompany(){
const ui=modal('Создать компанию',`<label>Название<input id="sunDevNewCompanyNameV1768" value="Новая компания"></label><label>Email владельца<input id="sunDevNewOwnerEmailV1768" type="email" placeholder="owner@example.com"></label><div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"><label>Тариф<select id="sunDevNewPlanV1768"><option value="basic">Базовый</option><option value="professional">Профессиональный</option><option value="full" selected>Полный</option></select></label><label>Срок, дней<input id="sunDevNewDaysV1768" type="number" min="1" max="3650" value="30"></label></div><p class="sun-dev-muted">Номер присвоится автоматически и больше не изменится, даже если другие компании будут удалены.</p><div class="sun-dev-modal-v1768-actions"><button class="outline" data-close>Отмена</button><button class="primary" id="sunDevNewCreateV1768">Создать</button></div><div id="sunDevNewErrorV1768" class="sun-dev-muted"></div>`); const ui=modal('Создать компанию',`<label>Название<input id="sunDevNewCompanyNameV1768" value="Новая компания"></label><label>Email владельца<input id="sunDevNewOwnerEmailV1768" type="email" placeholder="owner@example.com"></label><div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"><label>Тариф<select id="sunDevNewPlanV1768"><option value="basic">Базовый</option><option value="professional">Профессиональный</option><option value="full" selected>Полный</option></select></label><label>Срок, дней<input id="sunDevNewDaysV1768" type="number" min="1" max="3650" value="30"></label></div><p class="sun-dev-muted">Номер присвоится автоматически и больше не изменится, даже если другие компании будут удалены.</p><div class="sun-dev-modal-v1768-actions"><button class="outline" data-close>Отмена</button><button class="primary" id="sunDevNewCreateV1768">Создать</button></div><div id="sunDevNewErrorV1768" class="sun-dev-muted"></div>`);
$('sunDevNewCreateV1768').onclick=async()=>{const name=String($('sunDevNewCompanyNameV1768')?.value||'').trim(),email=String($('sunDevNewOwnerEmailV1768')?.value||'').trim(),plan=$('sunDevNewPlanV1768')?.value||'full',days=Math.max(1,Number($('sunDevNewDaysV1768')?.value||30));if(!name){$('sunDevNewErrorV1768').textContent='Введите название.';return}const btn=$('sunDevNewCreateV1768');btn.disabled=true;$('sunDevNewErrorV1768').textContent='Создаю…';try{await rpc('sun_dev_create_company',{p_name:name,p_owner_email:email||null,p_plan:plan,p_days:days,p_boxes:Array.isArray(window.SUN_OFFICIAL_CATALOG)?window.SUN_OFFICIAL_CATALOG:[],p_catalog_version:String(window.SUN_OFFICIAL_CATALOG_VERSION||'')});ui.close();toast('Компания создана и получила следующий номер.','success');renderEnhancedTab('companies')}catch(e){btn.disabled=false;$('sunDevNewErrorV1768').textContent=e?.message||String(e)}}; $('sunDevNewCreateV1768').onclick=async()=>{const name=String($('sunDevNewCompanyNameV1768')?.value||'').trim(),email=String($('sunDevNewOwnerEmailV1768')?.value||'').trim(),plan=$('sunDevNewPlanV1768')?.value||'full',days=Math.max(1,Number($('sunDevNewDaysV1768')?.value||30));if(!name){$('sunDevNewErrorV1768').textContent='Введите название.';return}const btn=$('sunDevNewCreateV1768');btn.disabled=true;$('sunDevNewErrorV1768').textContent='Создаю…';try{await rpc('sun_dev_create_company',{p_name:name,p_owner_email:email||null,p_plan:plan,p_days:days,p_boxes:[],p_catalog_version:String(window.SUN_OFFICIAL_CATALOG_VERSION||'')});ui.close();toast('Компания создана и получила следующий номер.','success');renderEnhancedTab('companies')}catch(e){btn.disabled=false;$('sunDevNewErrorV1768').textContent=e?.message||String(e)}};
} }
async function deleteCompany(row){ async function deleteCompany(row){
if(!row)return;const name=String(row.workspace_name||''); if(!row)return;const name=String(row.workspace_name||'');

View File

@ -0,0 +1,42 @@
/* Use the shared sidebar pseudo-element, sizing and theme colors; no extra icon node. */
.sun-enterprise-sidebar header nav #ctHelpNav{--sun-icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.9%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%229%22%2F%3E%3Cpath%20d%3D%22M9.5%209a2.5%202.5%200%200%201%205%200c0%201.7-2.5%202-2.5%204M12%2017h.01%22%2F%3E%3C%2Fsvg%3E")}
#ctHelpDialog{box-sizing:border-box;width:min(980px,calc(100vw - 24px));max-width:calc(100vw - 24px);height:min(820px,calc(100dvh - 24px));max-height:calc(100dvh - 24px);padding:0;border:1px solid #d9ddda;border-radius:20px;background:#fafaf7;color:#263d31;font:15px/1.55 Arial,sans-serif;overflow:hidden;margin:auto}
#ctHelpDialog::backdrop{background:#152c27a6}
#ctHelpDialog[open]{display:flex;flex-direction:column}
#ctHelpDialog *{box-sizing:border-box}
#ctHelpDialog [hidden]{display:none!important}
#ctHelpDialog .ct-help-header{position:static!important;display:flex!important;align-items:center;justify-content:space-between;gap:12px;padding:20px 24px;background:#f1f4ed;border-bottom:1px solid #d9ddda;flex:none;min-height:0;width:auto;box-shadow:none}
#ctHelpDialog h2{margin:0;font-size:24px;color:#263d31}
#ctHelpDialog h3{font-size:19px;color:#263d31;margin:18px 0 10px}
#ctHelpDialog p{margin:8px 0 16px}
#ctHelpDialog #ctHelpEdition{margin:2px 0 0;font-size:12px;color:#617166}
#ctHelpDialog button{font:inherit;white-space:normal;border:1px solid #cdd5cb;border-radius:10px;background:white;color:#263d31;padding:10px 14px;min-height:44px;cursor:pointer;box-shadow:none;letter-spacing:normal;text-transform:none}
#ctHelpDialog button:focus-visible,#ctHelpDialog input:focus-visible,#ctHelpDialog select:focus-visible,#ctHelpDialog summary:focus-visible{outline:3px solid #b39b47;outline-offset:2px}
#ctHelpDialog #ctHelpClose{width:44px;flex:none;padding:0;font-size:28px}
#ctHelpDialog .ct-help-modes{display:flex;gap:8px;padding:12px 24px;border-bottom:1px solid #d9ddda;flex:none}
#ctHelpDialog .ct-help-modes button[aria-pressed=true]{background:#304f3d;color:#fff;border-color:#304f3d}
#ctHelpDialog .ct-help-content{padding:20px 24px;overflow-y:auto;overscroll-behavior:contain;min-height:0;flex:1}
#ctHelpDialog section{display:block;padding:0;margin:0;max-width:none;background:none}
#ctHelpDialog .ct-help-filters{display:grid;grid-template-columns:2fr 1fr;gap:12px}
#ctHelpDialog label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:#425447}
#ctHelpDialog input,#ctHelpDialog select{width:100%;min-width:0;height:46px;padding:10px;border:1px solid #cdd5cb;border-radius:10px;background:white;color:#263d31;font:16px Arial,sans-serif}
#ctHelpDialog #ctHelpReset{justify-self:start}
#ctHelpDialog #ctHelpStatus{color:#617166;font-size:13px}
#ctHelpDialog details{border:1px solid #dce1d9;border-radius:12px;margin:10px 0;background:white;overflow-wrap:anywhere}
#ctHelpDialog summary{padding:15px;cursor:pointer;font-weight:700;min-height:48px}
#ctHelpDialog summary small{display:block;font-weight:400;font-size:12px;color:#6e7a6f;margin:3px 0 0 17px}
#ctHelpDialog ol{padding:0 22px 0 40px;margin:0 0 16px}
#ctHelpDialog li{margin:10px 0}
#ctHelpDialog .ct-help-note{background:#f7f4e8;border-left:3px solid #c5b366;padding:12px;margin:14px 16px;font-size:14px}
#ctHelpDialog .ct-help-related,#ctHelpDialog .ct-help-prompts{display:flex;flex-wrap:wrap;gap:8px;margin:16px}
#ctHelpDialog .ct-help-prompts{margin:18px 0}
#ctHelpDialog .ct-help-related button{font-size:13px}
#ctHelpDialog .ct-help-badge{display:inline-block;padding:5px 10px;border-radius:20px;background:#ede9d5;color:#635629;font-size:12px}
.ct-help-login{display:block!important;margin:16px auto 0!important;background:transparent!important;border:0!important;color:#5b655b!important;text-decoration:underline;min-height:44px;font-size:14px!important;cursor:pointer}
@media(max-width:600px){#ctHelpDialog{border-radius:14px}#ctHelpDialog h2{font-size:20px}#ctHelpDialog .ct-help-header,#ctHelpDialog .ct-help-content{padding:16px}#ctHelpDialog .ct-help-modes{padding:10px 16px}#ctHelpDialog .ct-help-filters{grid-template-columns:1fr}#ctHelpDialog .ct-help-prompts{flex-direction:column}}
@media print{#ctHelpDialog{display:none!important}}
#ctHelpDialog .ct-help-bot-start{margin:4px 0 16px;padding:11px 18px;border:0;border-radius:10px;background:#3b5340;color:#fff;font:inherit;font-weight:700;cursor:pointer}
#ctHelpDialog .ct-help-bot-start:hover{background:#2f4434}
#ctHelpDialog .ct-help-bot{height:min(440px,58vh);margin:4px 0 16px;border:1px solid #d9ddd2;border-radius:12px;overflow:hidden;background:#fff}
#ctHelpDialog .ct-help-bot iframe{display:block;width:100%;height:100%;border:0}

View File

@ -0,0 +1,79 @@
(()=>{
'use strict';
const version=new URL(document.currentScript.src).searchParams.get('v')||'1';
const url=new URL('../help/knowledge-v1.json',document.currentScript.src);url.searchParams.set('v',version);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const norm=s=>String(s).toLowerCase().replace(/ё/g,'е');
const BOT={origin:"https://ai-staff-alpha.vercel.app",agent:"e9cc0f28-aaa5-48d4-a020-ae9633988faf"};
let dialog,data,loading=false,returnFocus;
const supportUrl=new URL('support-form.js?v=20260921-support-recipient',document.currentScript.src);
function loadContactForm(){
if(window.CateriumSupportForm){window.CateriumSupportForm.attach(dialog);return;}
if(document.getElementById('ctSupportFormScript'))return;
const script=document.createElement('script');script.id='ctSupportFormScript';script.src=supportUrl.href;
script.onerror=()=>{script.remove();};document.head.append(script);
}
function render(){
if(!data||!dialog)return;
const query=norm(dialog.querySelector('#ctHelpSearch').value).trim(),category=dialog.querySelector('#ctHelpCategory').value;
const terms=query.split(/\s+/).filter(Boolean);
const rows=data.articles.filter(a=>!category||a.category===category).map(a=>({a,score:terms.reduce((n,t)=>n+(norm(a.title+' '+a.keywords).includes(t)?3:0)+(norm(a.steps.join(' ')+' '+a.note).includes(t)?1:0),0)})).filter(r=>!terms.length||r.score>0).sort((a,b)=>b.score-a.score);
dialog.querySelector('#ctHelpStatus').textContent=rows.length?`Инструкций: ${rows.length}`:'Ничего не найдено. Попробуйте «оплата», «PDF», «склад» или откройте все темы.';
dialog.querySelector('#ctHelpResults').innerHTML=rows.map(({a})=>`<details data-article="${esc(a.id)}"><summary>${esc(a.title)}<small>${esc(a.category)}</small></summary><ol>${a.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p class="ct-help-note">${esc(a.note)}</p><div class="ct-help-related">${(a.related||[]).map(id=>{const r=data.articles.find(x=>x.id===id);return r?`<button type="button" data-help-article="${esc(id)}">${esc(r.title)}</button>`:''}).join('')}</div></details>`).join('');
}
function showGuide(query=''){
switchMode(false);dialog.querySelector('#ctHelpSearch').value=query;dialog.querySelector('#ctHelpCategory').value='';render();dialog.querySelector('#ctHelpSearch').focus();
}
function startBot(){
const box=dialog.querySelector("#ctHelpBot"),start=dialog.querySelector("#ctHelpBotStart");
if(box.querySelector("iframe"))return;
// The chat lives in a sandboxed iframe on the assistant's own origin: nothing from the app (orders, clients, session) is reachable from it, and no request leaves the device until the user asks for the assistant.
const frame=document.createElement("iframe");
frame.src=`${BOT.origin}/embed/${BOT.agent}`;frame.title="ИИ-помощник Caterium";frame.loading="eager";frame.referrerPolicy="no-referrer";
frame.setAttribute("sandbox","allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
box.textContent="";box.hidden=false;box.appendChild(frame);start.hidden=true;
}
function switchMode(support){
dialog.querySelector('#ctHelpGuide').hidden=support;dialog.querySelector('#ctHelpSupport').hidden=!support;
dialog.querySelector('#ctHelpGuideTab').setAttribute('aria-pressed',String(!support));dialog.querySelector('#ctHelpSupportTab').setAttribute('aria-pressed',String(support));
}
async function load(){
if(data){render();return;}if(loading)return;loading=true;
const status=dialog.querySelector('#ctHelpStatus'),retry=dialog.querySelector('#ctHelpRetry');status.textContent='Загружаю руководство…';retry.hidden=true;
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),8000);
try{
const r=await fetch(url,{signal:controller.signal,credentials:'omit'});if(!r.ok)throw new Error('http');const value=await r.json();
if(!Array.isArray(value.articles)||!value.articles.every(a=>typeof a.id==='string'&&typeof a.title==='string'&&typeof a.category==='string'&&Array.isArray(a.steps)&&a.steps.every(s=>typeof s==='string')))throw new Error('format');
data=value;dialog.querySelector('#ctHelpCategory').innerHTML='<option value="">Все темы</option>'+[...new Set(data.articles.map(a=>a.category))].map(c=>`<option>${esc(c)}</option>`).join('');
dialog.querySelector('#ctHelpEdition').textContent=`Руководство · редакция ${data.version}`;render();
}catch(_){status.textContent='Не удалось загрузить руководство. Проверьте соединение и повторите.';retry.hidden=false;}finally{loading=false;clearTimeout(timer);}
}
function ensureDialog(){
if(dialog)return;
dialog=document.createElement('dialog');dialog.id='ctHelpDialog';dialog.setAttribute('aria-labelledby','ctHelpTitle');
dialog.innerHTML=`<div class="ct-help-header"><div><h2 id="ctHelpTitle">Помощь в Caterium</h2><p id="ctHelpEdition">Руководство пользователя и поддержка</p></div><button type="button" id="ctHelpClose" aria-label="Закрыть помощь">×</button></div><div class="ct-help-modes" aria-label="Раздел помощи"><button type="button" id="ctHelpGuideTab" aria-pressed="true">Руководство</button><button type="button" id="ctHelpSupportTab" aria-pressed="false">Поддержка</button></div><div class="ct-help-content"><section id="ctHelpGuide"><div class="ct-help-filters"><label>Что хотите сделать?<input id="ctHelpSearch" type="search" placeholder="Например: оплата, PDF, склад" maxlength="240" autocomplete="off"></label><label>Категория<select id="ctHelpCategory"><option value="">Все темы</option></select></label><button type="button" id="ctHelpReset">Все инструкции</button></div><p id="ctHelpStatus" role="status"></p><button type="button" id="ctHelpRetry" hidden>Повторить загрузку</button><div id="ctHelpResults"></div></section><section id="ctHelpSupport" hidden><span class="ct-help-badge">ИИ-помощник</span><h3>Ответы по работе с приложением</h3><p>Помощник отвечает на вопросы «как это сделать» по руководству Caterium. Он не видит данные вашей компании, но ваш вопрос обрабатывает внешний сервис, поэтому не пишите пароли, коды подтверждения и данные клиентов. Поиск по руководству остаётся на этом устройстве и ничего не отправляет.</p><button type="button" id="ctHelpBotStart" class="ct-help-bot-start">Задать вопрос помощнику</button><div id="ctHelpBot" class="ct-help-bot" hidden></div><div class="ct-help-prompts"><button type="button" data-help-query="создать заказ">Как создать заказ?</button><button type="button" data-help-query="оплата">Как отметить оплату?</button><button type="button" data-help-query="закупки">Как рассчитать закупки?</button><button type="button" data-help-query="синхронизация">Не загружается база</button></div><h3>Если инструкция не помогла</h3><p>Почта поддержки: <a href="mailto:support@caterium.ru">support@caterium.ru</a>. Вкладка «Написать в поддержку» открывает форму обращения.</p><p>Подготовьте название раздела, последовательность действий, точный текст ошибки, устройство и браузер. На снимке экрана закройте лишние телефоны и адреса. Не передавайте пароли и коды подтверждения.</p><button type="button" data-help-query="обращение">Памятка для обращения</button></section></div>`;
document.body.appendChild(dialog);
dialog.querySelector('#ctHelpClose').onclick=()=>dialog.close();
dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();dialog.close();}});
dialog.addEventListener('close',()=>{dialog.querySelector('#ctHelpSearch').value='';if(returnFocus?.isConnected)returnFocus.focus({preventScroll:true});});
dialog.querySelector('#ctHelpBotStart').onclick=startBot;dialog.querySelector('#ctHelpGuideTab').onclick=()=>switchMode(false);dialog.querySelector('#ctHelpSupportTab').onclick=()=>switchMode(true);
dialog.querySelector('#ctHelpSearch').oninput=render;dialog.querySelector('#ctHelpCategory').onchange=render;
dialog.querySelector('#ctHelpReset').onclick=()=>showGuide();dialog.querySelector('#ctHelpRetry').onclick=load;
dialog.addEventListener('click',e=>{
const q=e.target.closest('[data-help-query]');if(q){showGuide(q.dataset.helpQuery);return;}
const a=e.target.closest('[data-help-article]');if(a&&data){showGuide();const target=[...dialog.querySelectorAll('details')].find(x=>x.dataset.article===a.dataset.helpArticle);if(target){target.open=true;target.scrollIntoView({block:'nearest'});target.querySelector('summary').focus();}}
});
}
function open(trigger){ensureDialog();loadContactForm();dialog.querySelector('#ctContactSection')?.setAttribute('hidden','');dialog.querySelector('#ctContactTab')?.setAttribute('aria-pressed','false');returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();}
function mount(){
const nav=document.querySelector('header nav');
if(nav&&!document.getElementById('ctHelpNav')){const b=document.createElement('button');b.id='ctHelpNav';b.type='button';b.dataset.navLabel='Помощь';b.textContent='Помощь';b.onclick=open;nav.appendChild(b);window.sunRegisterNavButton?.({button:b,group:'management',navLabel:'Помощь'});}
const gate=document.getElementById('sunCloudAuthGateV3');
if(gate&&!gate.querySelector('.ct-help-login')){const b=document.createElement('button');b.type='button';b.className='ct-help-login';b.textContent='Помощь со входом';b.onclick=()=>{open(b);showGuide('вход');};(gate.querySelector('.sun-cloud-auth-card')||gate).appendChild(b);}
}
function boot(){mount();let frame=0;new MutationObserver(()=>{if(frame)return;frame=requestAnimationFrame(()=>{frame=0;mount();});}).observe(document.body,{childList:true,subtree:true});
const context=()=>`${window.SunCloudV2?.getSession?.()?.user?.id||''}:${window.SunCloudV2?.getWorkspace?.()?.id||''}`;
let current=context();window.addEventListener('sun:cloud-permissions-changed',()=>{const next=context();if(next!==current&&dialog?.open)dialog.close();current=next;});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -3,10 +3,6 @@
if(window.SunHotfixV1763)return; if(window.SunHotfixV1763)return;
const VERSION='17.6.3'; const VERSION='17.6.3';
const $=id=>document.getElementById(id);
const esc=value=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(value??'')):String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let checkingGate=false;
function patchDeveloperOpen(){ function patchDeveloperOpen(){
const dev=window.SunDeveloperV22; const dev=window.SunDeveloperV22;
if(!dev||dev.__sunV1763SafeOpen||typeof dev.open!=='function')return false; if(!dev||dev.__sunV1763SafeOpen||typeof dev.open!=='function')return false;
@ -16,34 +12,8 @@
return true; return true;
} }
async function platformAdmin(){ // Authentication screens belong to the unified auth gate. Never replace
const dev=window.SunDeveloperV22,cloud=window.SunCloudV2; // its loading/error state with an old developer-only login card.
if(!dev||!cloud?.getSession?.()?.user)return false;
if(dev.isPlatformAdmin?.()===true)return true;
try{return await dev.checkPlatformAdmin?.(false)===true}catch(_){return false}
}
async function enhanceDeveloperGate(){
if(checkingGate)return false;
const cloud=window.SunCloudV2,dev=window.SunDeveloperV22;
if(!cloud||!dev)return false;
const session=cloud.getSession?.(),workspace=cloud.getWorkspace?.();
if(!session?.user||workspace)return false;
checkingGate=true;
try{
if(!await platformAdmin())return false;
const gate=$('sunCloudAuthGateV3'),card=gate?.querySelector('.sun-cloud-auth-card');
if(!gate||!card)return false;
if(card.dataset.dev1763==='1')return true;
gate.dataset.saasEnhanced='1';
card.dataset.devEnhanced='1';
card.dataset.dev1763='1';
card.innerHTML=`<div class="sun-cloud-auth-brand"><img src="caterium-login-logo.png" alt="Caterium"><div><h2>Аккаунт разработчика</h2><div class="hint">${esc(session.user.email||'')}</div></div></div><p class="hint">Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.</p><div class="sun-cloud-auth-actions"><button class="primary" type="button" data-open-dev-v1763>Открыть кабинет разработчика</button><button class="outline" type="button" data-signout-v1763>Выйти</button></div><div class="sun-cloud-auth-error" id="sunDevGateStatusV1763"></div>`;
card.querySelector('[data-open-dev-v1763]')?.addEventListener('click',()=>{patchDeveloperOpen();dev.open?.();});
card.querySelector('[data-signout-v1763]')?.addEventListener('click',()=>cloud.signOut?.());
return true;
}finally{checkingGate=false;}
}
function interceptSaasAdmin(event){ function interceptSaasAdmin(event){
const button=event.target?.closest?.('[data-saas-admin]'); const button=event.target?.closest?.('[data-saas-admin]');
@ -56,9 +26,18 @@
dev.open?.(); dev.open?.();
} }
function loadPromoControls(){
const id='cateriumTrialPromoDeveloperV181Script';
if(window.CateriumTrialPromoDeveloperV181||document.getElementById(id))return;
const script=document.createElement('script');script.id=id;
script.src='core/trial-promo-developer-v181.js?v=20260921-promo-entry';script.async=true;
script.onerror=()=>{script.remove();console.warn('[Caterium] Не загрузился раздел промокодов.');};
document.head.append(script);
}
function maintain(){ function maintain(){
loadPromoControls();
patchDeveloperOpen(); patchDeveloperOpen();
enhanceDeveloperGate().catch(()=>{});
} }
document.addEventListener('click',interceptSaasAdmin,true); document.addEventListener('click',interceptSaasAdmin,true);
@ -67,5 +46,5 @@
setTimeout(maintain,0); setTimeout(maintain,0);
setInterval(()=>{if(!document.hidden)maintain();},10000); setInterval(()=>{if(!document.hidden)maintain();},10000);
window.SunHotfixV1763={VERSION,patchDeveloperOpen,enhanceDeveloperGate}; window.SunHotfixV1763={VERSION,patchDeveloperOpen};
})(); })();

View File

@ -0,0 +1,12 @@
(()=>{
'use strict';
// The one-time import is complete. Keep this cleanup entry point for old caches.
// Source details attached to individual orders remain available in order history.
function removeArchiveUI(){
document.querySelectorAll('#cateriumTelegramArchive,dialog[aria-label="Архив заказов из Telegram"]').forEach(el=>el.remove());
}
window.CateriumImportArchive={show:removeArchiveUI,refresh:removeArchiveUI};
window.addEventListener('sun:cloud-state-applied',removeArchiveUI);
window.addEventListener('sun:cloud-permissions-changed',removeArchiveUI);
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',removeArchiveUI,{once:true});else removeArchiveUI();
})();

View File

@ -0,0 +1,33 @@
body.sun-cloud-auth-required{overflow:hidden!important;background:#f5f0e7!important}body.sun-cloud-auth-required>header,body.sun-cloud-auth-required>.view{visibility:hidden!important}
#sunCloudAuthGateV3.sun-cloud-auth-gate{position:fixed!important;inset:0!important;z-index:20000!important;overflow:auto!important;display:grid!important;place-items:center!important;padding:clamp(32px,6vh,78px) clamp(24px,8vw,120px)!important;background:radial-gradient(circle at 18% 18%,rgba(255,255,255,.92),transparent 34%),radial-gradient(circle at 77% 22%,rgba(255,255,255,.5),transparent 28%),linear-gradient(135deg,#f8f4ec 0%,#f2ece1 56%,#f7f3eb 100%)!important;color:#282621!important}
#sunCloudAuthGateV3.sun-cloud-auth-gate:before{content:'C';position:fixed;z-index:0;left:-7vw;top:49%;transform:translateY(-50%);pointer-events:none;font:400 min(72vw,860px)/.72 Georgia,'Times New Roman',serif;color:rgba(73,67,59,.032)}
#sunCloudAuthGateV3.sun-cloud-auth-gate:after{content:'';position:fixed;z-index:0;right:-7vw;bottom:-14vh;width:min(48vw,720px);height:min(70vh,850px);pointer-events:none;background:radial-gradient(ellipse at 58% 20%,rgba(67,64,58,.14) 0 7%,transparent 8%),radial-gradient(ellipse at 36% 35%,rgba(67,64,58,.13) 0 6%,transparent 7%),radial-gradient(ellipse at 68% 47%,rgba(67,64,58,.12) 0 8%,transparent 9%),linear-gradient(106deg,transparent 43%,rgba(67,64,58,.08) 44% 46%,transparent 47%);filter:blur(18px);transform:rotate(-17deg);opacity:.45}
#sunCloudAuthGateV3 .sun-cloud-auth-card{position:relative!important;z-index:3!important;width:min(790px,100%)!important;margin:0!important;padding:0 0 58px!important;border:0!important;border-radius:0!important;background:transparent!important;color:#2c2924!important;box-shadow:none!important;backdrop-filter:none!important}
#sunCloudAuthGateV3 .caterium-signature-brand{display:flex;align-items:center;gap:13px;margin:0 0 clamp(46px,7vh,88px)!important}.caterium-signature-mark{display:block;width:62px;height:62px;object-fit:contain;flex:0 0 62px}.caterium-signature-word{font:500 36px/1 Georgia,'Times New Roman',serif;color:#26231f;letter-spacing:-.02em}
#sunCloudAuthGateV3 .sun-cloud-auth-brand{display:block!important;margin:0 0 35px!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand img{display:none!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand h2{margin:0!important;max-width:720px;color:#24211d!important;font:500 clamp(48px,5.2vw,76px)/.99 Georgia,'Times New Roman',serif!important;letter-spacing:-.045em!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand .hint{margin-top:18px!important;color:#8e887f!important;font-size:clamp(16px,1.55vw,21px)!important;line-height:1.45!important;font-weight:400!important}
#sunCloudAuthGateV3 .sun-cloud-auth-card>p.hint{color:#817b72!important}#sunCloudAuthGateV3 .sun-cloud-auth-card label{display:block!important;margin:14px 0 0!important;color:#716b62!important;font-size:12px!important;font-weight:700!important}#sunCloudAuthGateV3 label.caterium-clean-field{font-size:0!important;color:transparent!important;position:relative!important}
#sunCloudAuthGateV3 .sun-cloud-auth-card input,#sunCloudAuthGateV3 .sun-cloud-auth-card select,#sunCloudAuthGateV3 .sun-cloud-auth-card textarea{width:100%!important;min-height:58px!important;margin-top:7px!important;padding:0 18px!important;border:1px solid #bdb5aa!important;border-radius:13px!important;background:rgba(255,255,255,.13)!important;color:#312e29!important;box-shadow:none!important;outline:none!important;font-size:16px!important;font-weight:500!important}#sunCloudAuthGateV3 label.caterium-clean-field input{margin-top:0!important;padding-left:58px!important}#sunCloudAuthGateV3 input::placeholder{color:#9c958c!important;opacity:1!important}#sunCloudAuthGateV3 input:focus{border-color:#d7a632!important;box-shadow:0 0 0 4px rgba(216,167,50,.12)!important;background:rgba(255,255,255,.5)!important}
#sunCloudAuthGateV3 .caterium-email-field:before{content:'✉';position:absolute;z-index:2;left:20px;top:15px;color:#777168;font-size:25px;font-weight:400}#sunCloudAuthGateV3 .caterium-password-field:before{content:'♙';position:absolute;z-index:2;left:21px;top:14px;color:#777168;font-size:24px;transform:rotate(180deg);opacity:.8}
#sunCloudAuthGateV3 .sun-auth-password{display:block!important;position:relative!important}#sunCloudAuthGateV3 .sun-auth-password input{padding-right:58px!important}#sunCloudAuthGateV3 .sun-auth-eye{right:9px!important;bottom:10px!important;width:38px!important;height:38px!important;color:#777168!important;background:transparent!important;border-radius:9px!important}
#sunCloudAuthGateV3 .sun-cloud-auth-actions{display:block!important;margin-top:25px!important}#sunCloudAuthGateV3 .sun-cloud-auth-actions .primary,#sunCloudAuthGateV3 #sunGateSubmitV3{width:100%!important;min-height:62px!important;border:0!important;border-radius:13px!important;background:linear-gradient(100deg,#d9aa42,#efca69)!important;color:#171512!important;font-size:18px!important;font-weight:900!important;box-shadow:0 14px 34px rgba(184,135,34,.14)!important}#sunCloudAuthGateV3 #sunGateSubmitV3:after{content:' →';font-size:24px;font-weight:500;margin-left:13px}
#sunCloudAuthGateV3 .sun-cloud-auth-actions .outline{width:100%!important;margin-top:9px!important;min-height:48px!important;border:1px solid #c9c1b6!important;border-radius:11px!important;background:rgba(255,255,255,.25)!important;color:#37332d!important}#sunCloudAuthGateV3 .sun-cloud-auth-error{min-height:19px!important;margin-top:10px!important;color:#a64c40!important;font-size:12px!important}#sunCloudAuthGateV3 .sun-auth-switch{margin:26px 0 0!important;padding-top:25px!important;border-top:1px solid #cfc7bc!important;color:#898279!important;font-size:14px!important;text-align:center!important}#sunCloudAuthGateV3 .sun-auth-switch button{border:0!important;background:transparent!important;color:#c18f24!important;text-decoration:none!important;font-weight:900!important}
.caterium-signature-caption{position:fixed;left:42px;bottom:31px;z-index:4;color:#9d968d;font-size:9px;letter-spacing:.31em;text-transform:uppercase;pointer-events:none}.caterium-signature-caption:before{content:'';display:block;width:48px;height:2px;margin-bottom:13px;background:#d3a43c}.caterium-signature-manifesto{position:fixed;right:56px;top:44px;z-index:4;width:270px;color:#a19a91;font-size:10px;line-height:1.9;letter-spacing:.28em;text-transform:uppercase;pointer-events:none;white-space:pre-line}.caterium-flow-top{position:fixed;z-index:1;right:5.5vw;top:13vh;width:min(37vw,470px);opacity:.86;pointer-events:none;color:#a7a097}.caterium-flow-top svg{width:100%;height:auto}.caterium-flow-top path{fill:none;stroke:currentColor;stroke-width:1.6}.caterium-flow-top circle{fill:currentColor}.caterium-flow-top circle.gold{fill:#e5a313}
@media(max-width:980px){#sunCloudAuthGateV3.sun-cloud-auth-gate{padding:34px 28px 70px!important;place-items:start center!important}#sunCloudAuthGateV3 .sun-cloud-auth-card{width:min(720px,100%)!important;padding-top:12px!important}.caterium-signature-manifesto{display:none!important}.caterium-flow-top{right:-60px;top:125px;width:360px;opacity:.45}}
@media(max-width:620px){body.sun-cloud-auth-required{overflow:auto!important}#sunCloudAuthGateV3.sun-cloud-auth-gate{min-height:100dvh!important;padding:25px 18px 50px!important;place-items:start center!important}#sunCloudAuthGateV3 .sun-cloud-auth-card{width:100%!important;padding:0!important}.caterium-signature-brand{margin-bottom:42px!important}.caterium-signature-mark{width:50px;height:50px;flex-basis:50px}.caterium-signature-word{font-size:29px}#sunCloudAuthGateV3 .sun-cloud-auth-brand h2{font-size:clamp(38px,12vw,52px)!important}.caterium-flow-top{right:-125px;top:100px;width:330px;opacity:.25}.caterium-signature-caption{display:none!important}}
#sunCloudAuthGateV3 .sun-auth-eye{position:absolute;border:0;cursor:pointer;font-size:17px}
#sunCloudAuthGateV3 .sun-auth-switch button{cursor:pointer}
/* Waiting for workspace access is normal progress, not a recovery screen.
Keep these rules in the synchronously loaded stylesheet to avoid a button
flash even when the optional login decoration script is delayed or blocked.
Error, missing-company and signed-out states keep their existing controls. */
#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-actions,
#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-card>p.hint,
#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-brand .hint,
#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-error,
#sunCloudAuthGateV3[data-auth-state="loading"] .ct-help-login{display:none!important}
#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-brand:after{content:'';display:block;width:28px;height:28px;margin-top:30px;border:3px solid rgba(211,164,60,.2);border-top-color:#d3a43c;border-radius:50%;animation:caterium-workspace-loading .9s linear infinite}
@keyframes caterium-workspace-loading{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){#sunCloudAuthGateV3[data-auth-state="loading"] .sun-cloud-auth-brand:after{animation:none}}

View File

@ -1,30 +1,12 @@
(()=>{ (()=>{
'use strict'; 'use strict';
if(window.CateriumLoginSignatureV1776)return; if(window.CateriumLoginSignatureV1776)return;
const VERSION='17.7.7-cream-login-v2'; const VERSION='17.7.8-single-login';
const $=(s,r=document)=>r.querySelector(s); const $=(s,r=document)=>r.querySelector(s);
const boot=()=>$('#cateriumAuthBootV1776'); const boot=()=>$('#cateriumAuthBootV1776');
const removeBoot=()=>{const b=boot();if(b){b.classList.add('leave');setTimeout(()=>b.remove(),180)}}; const removeBoot=()=>{const b=boot();if(b){b.classList.add('leave');setTimeout(()=>b.remove(),180)}};
const FLOW='<svg viewBox="0 0 430 220" aria-hidden="true"><path d="M18 42H122V108H220V58H318V164H420"/><circle cx="18" cy="42" r="7"/><circle cx="122" cy="42" r="7"/><circle cx="122" cy="108" r="7"/><circle cx="220" cy="108" r="7"/><circle cx="220" cy="58" r="7"/><circle cx="318" cy="58" r="7"/><circle cx="318" cy="164" r="7"/><circle cx="420" cy="164" r="8" class="gold"/></svg>'; const FLOW='<svg viewBox="0 0 430 220" aria-hidden="true"><path d="M18 42H122V108H220V58H318V164H420"/><circle cx="18" cy="42" r="7"/><circle cx="122" cy="42" r="7"/><circle cx="122" cy="108" r="7"/><circle cx="220" cy="108" r="7"/><circle cx="220" cy="58" r="7"/><circle cx="318" cy="58" r="7"/><circle cx="318" cy="164" r="7"/><circle cx="420" cy="164" r="8" class="gold"/></svg>';
function styles(){if($('#caterium-login-signature-v1776-style'))return;const s=document.createElement('style');s.id='caterium-login-signature-v1776-style';s.textContent=`
body.sun-cloud-auth-required{overflow:hidden!important;background:#f5f0e7!important}body.sun-cloud-auth-required>header,body.sun-cloud-auth-required>.view{visibility:hidden!important}
#sunCloudAuthGateV3.sun-cloud-auth-gate{position:fixed!important;inset:0!important;z-index:20000!important;overflow:auto!important;display:grid!important;place-items:center!important;padding:clamp(32px,6vh,78px) clamp(24px,8vw,120px)!important;background:radial-gradient(circle at 18% 18%,rgba(255,255,255,.92),transparent 34%),radial-gradient(circle at 77% 22%,rgba(255,255,255,.5),transparent 28%),linear-gradient(135deg,#f8f4ec 0%,#f2ece1 56%,#f7f3eb 100%)!important;color:#282621!important}
#sunCloudAuthGateV3.sun-cloud-auth-gate:before{content:'C';position:fixed;z-index:0;left:-7vw;top:49%;transform:translateY(-50%);pointer-events:none;font:400 min(72vw,860px)/.72 Georgia,'Times New Roman',serif;color:rgba(73,67,59,.032)}
#sunCloudAuthGateV3.sun-cloud-auth-gate:after{content:'';position:fixed;z-index:0;right:-7vw;bottom:-14vh;width:min(48vw,720px);height:min(70vh,850px);pointer-events:none;background:radial-gradient(ellipse at 58% 20%,rgba(67,64,58,.14) 0 7%,transparent 8%),radial-gradient(ellipse at 36% 35%,rgba(67,64,58,.13) 0 6%,transparent 7%),radial-gradient(ellipse at 68% 47%,rgba(67,64,58,.12) 0 8%,transparent 9%),linear-gradient(106deg,transparent 43%,rgba(67,64,58,.08) 44% 46%,transparent 47%);filter:blur(18px);transform:rotate(-17deg);opacity:.45}
#sunCloudAuthGateV3 .sun-cloud-auth-card{position:relative!important;z-index:3!important;width:min(790px,100%)!important;margin:0!important;padding:0 0 58px!important;border:0!important;border-radius:0!important;background:transparent!important;color:#2c2924!important;box-shadow:none!important;backdrop-filter:none!important}
#sunCloudAuthGateV3 .caterium-signature-brand{display:flex;align-items:center;gap:13px;margin:0 0 clamp(46px,7vh,88px)!important}.caterium-signature-mark{display:block;width:62px;height:62px;object-fit:contain;flex:0 0 62px}.caterium-signature-word{font:500 36px/1 Georgia,'Times New Roman',serif;color:#26231f;letter-spacing:-.02em}
#sunCloudAuthGateV3 .sun-cloud-auth-brand{display:block!important;margin:0 0 35px!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand img{display:none!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand h2{margin:0!important;max-width:720px;color:#24211d!important;font:500 clamp(48px,5.2vw,76px)/.99 Georgia,'Times New Roman',serif!important;letter-spacing:-.045em!important}#sunCloudAuthGateV3 .sun-cloud-auth-brand .hint{margin-top:18px!important;color:#8e887f!important;font-size:clamp(16px,1.55vw,21px)!important;line-height:1.45!important;font-weight:400!important}
#sunCloudAuthGateV3 .sun-cloud-auth-card>p.hint{color:#817b72!important}#sunCloudAuthGateV3 .sun-cloud-auth-card label{display:block!important;margin:14px 0 0!important;color:#716b62!important;font-size:12px!important;font-weight:700!important}#sunCloudAuthGateV3 label.caterium-clean-field{font-size:0!important;color:transparent!important;position:relative!important}
#sunCloudAuthGateV3 .sun-cloud-auth-card input,#sunCloudAuthGateV3 .sun-cloud-auth-card select,#sunCloudAuthGateV3 .sun-cloud-auth-card textarea{width:100%!important;min-height:58px!important;margin-top:7px!important;padding:0 18px!important;border:1px solid #bdb5aa!important;border-radius:13px!important;background:rgba(255,255,255,.13)!important;color:#312e29!important;box-shadow:none!important;outline:none!important;font-size:16px!important;font-weight:500!important}#sunCloudAuthGateV3 label.caterium-clean-field input{margin-top:0!important;padding-left:58px!important}#sunCloudAuthGateV3 input::placeholder{color:#9c958c!important;opacity:1!important}#sunCloudAuthGateV3 input:focus{border-color:#d7a632!important;box-shadow:0 0 0 4px rgba(216,167,50,.12)!important;background:rgba(255,255,255,.5)!important}
#sunCloudAuthGateV3 .caterium-email-field:before{content:'✉';position:absolute;z-index:2;left:20px;top:15px;color:#777168;font-size:25px;font-weight:400}#sunCloudAuthGateV3 .caterium-password-field:before{content:'♙';position:absolute;z-index:2;left:21px;top:14px;color:#777168;font-size:24px;transform:rotate(180deg);opacity:.8}
#sunCloudAuthGateV3 .sun-auth-password{display:block!important;position:relative!important}#sunCloudAuthGateV3 .sun-auth-password input{padding-right:58px!important}#sunCloudAuthGateV3 .sun-auth-eye{right:9px!important;bottom:10px!important;width:38px!important;height:38px!important;color:#777168!important;background:transparent!important;border-radius:9px!important}
#sunCloudAuthGateV3 .sun-cloud-auth-actions{display:block!important;margin-top:25px!important}#sunCloudAuthGateV3 .sun-cloud-auth-actions .primary,#sunCloudAuthGateV3 #sunGateSubmitV3{width:100%!important;min-height:62px!important;border:0!important;border-radius:13px!important;background:linear-gradient(100deg,#d9aa42,#efca69)!important;color:#171512!important;font-size:18px!important;font-weight:900!important;box-shadow:0 14px 34px rgba(184,135,34,.14)!important}#sunCloudAuthGateV3 #sunGateSubmitV3:after{content:' →';font-size:24px;font-weight:500;margin-left:13px}
#sunCloudAuthGateV3 .sun-cloud-auth-actions .outline{width:100%!important;margin-top:9px!important;min-height:48px!important;border:1px solid #c9c1b6!important;border-radius:11px!important;background:rgba(255,255,255,.25)!important;color:#37332d!important}#sunCloudAuthGateV3 .sun-cloud-auth-error{min-height:19px!important;margin-top:10px!important;color:#a64c40!important;font-size:12px!important}#sunCloudAuthGateV3 .sun-auth-switch{margin:26px 0 0!important;padding-top:25px!important;border-top:1px solid #cfc7bc!important;color:#898279!important;font-size:14px!important;text-align:center!important}#sunCloudAuthGateV3 .sun-auth-switch button{border:0!important;background:transparent!important;color:#c18f24!important;text-decoration:none!important;font-weight:900!important}
.caterium-signature-caption{position:fixed;left:42px;bottom:31px;z-index:4;color:#9d968d;font-size:9px;letter-spacing:.31em;text-transform:uppercase;pointer-events:none}.caterium-signature-caption:before{content:'';display:block;width:48px;height:2px;margin-bottom:13px;background:#d3a43c}.caterium-signature-manifesto{position:fixed;right:56px;top:44px;z-index:4;width:270px;color:#a19a91;font-size:10px;line-height:1.9;letter-spacing:.28em;text-transform:uppercase;pointer-events:none;white-space:pre-line}.caterium-flow-top{position:fixed;z-index:1;right:5.5vw;top:13vh;width:min(37vw,470px);opacity:.86;pointer-events:none;color:#a7a097}.caterium-flow-top svg{width:100%;height:auto}.caterium-flow-top path{fill:none;stroke:currentColor;stroke-width:1.6}.caterium-flow-top circle{fill:currentColor}.caterium-flow-top circle.gold{fill:#e5a313}
@media(max-width:980px){#sunCloudAuthGateV3.sun-cloud-auth-gate{padding:34px 28px 70px!important;place-items:start center!important}#sunCloudAuthGateV3 .sun-cloud-auth-card{width:min(720px,100%)!important;padding-top:12px!important}.caterium-signature-manifesto{display:none!important}.caterium-flow-top{right:-60px;top:125px;width:360px;opacity:.45}}
@media(max-width:620px){body.sun-cloud-auth-required{overflow:auto!important}#sunCloudAuthGateV3.sun-cloud-auth-gate{min-height:100dvh!important;padding:25px 18px 50px!important;place-items:start center!important}#sunCloudAuthGateV3 .sun-cloud-auth-card{width:100%!important;padding:0!important}.caterium-signature-brand{margin-bottom:42px!important}.caterium-signature-mark{width:50px;height:50px;flex-basis:50px}.caterium-signature-word{font-size:29px}#sunCloudAuthGateV3 .sun-cloud-auth-brand h2{font-size:clamp(38px,12vw,52px)!important}.caterium-flow-top{right:-125px;top:100px;width:330px;opacity:.25}.caterium-signature-caption{display:none!important}}
`;document.head.appendChild(s)}
function field(g,id,ph,type){const i=$('#'+id,g);if(!i)return;i.placeholder=ph;const l=i.closest('label');if(l)l.classList.add('caterium-clean-field',type==='email'?'caterium-email-field':'caterium-password-field')} function field(g,id,ph,type){const i=$('#'+id,g);if(!i)return;i.placeholder=ph;const l=i.closest('label');if(l)l.classList.add('caterium-clean-field',type==='email'?'caterium-email-field':'caterium-password-field')}
function decorate(g){if(!g)return false;const c=$('.sun-cloud-auth-card',g);if(!c)return false;if(!$('.caterium-signature-brand',c)){const b=document.createElement('div');b.className='caterium-signature-brand';b.innerHTML='<img class="caterium-signature-mark" src="caterium-mark-light.svg" alt=""><span class="caterium-signature-word">Caterium</span>';c.prepend(b)}if(!$('.caterium-signature-caption',g)){const n=document.createElement('div');n.className='caterium-signature-caption';n.textContent='Caterium · вкус в деталях';g.appendChild(n)}if(!$('.caterium-signature-manifesto',g)){const n=document.createElement('div');n.className='caterium-signature-manifesto';n.textContent='Простые решения\nдля больших событий';g.appendChild(n)}if(!$('.caterium-flow-top',g)){const n=document.createElement('div');n.className='caterium-flow-top';n.innerHTML=FLOW;g.appendChild(n)}const t=$('#sunGateTitleV3',g),sub=$('#sunGateSubtitleV3',g);if(t&&t.textContent.trim()==='Вход в Caterium')t.textContent='Войти в рабочее пространство';if(sub&&sub.textContent.trim()==='Введите данные своего аккаунта')sub.textContent='Ваши заказы. Ваша команда. Ваш результат.';field(g,'sunGateEmailV3','Email','email');field(g,'sunGatePasswordV3','Пароль','password');field(g,'sunGatePassword2V27','Подтвердите пароль','password');removeBoot();return true} function decorate(g){if(!g)return false;const c=$('.sun-cloud-auth-card',g);if(!c)return false;if(!$('.caterium-signature-brand',c)){const b=document.createElement('div');b.className='caterium-signature-brand';b.innerHTML='<img class="caterium-signature-mark" src="caterium-mark-light.svg" alt=""><span class="caterium-signature-word">Caterium</span>';c.prepend(b)}if(!$('.caterium-signature-caption',g)){const n=document.createElement('div');n.className='caterium-signature-caption';n.textContent='Caterium · вкус в деталях';g.appendChild(n)}if(!$('.caterium-signature-manifesto',g)){const n=document.createElement('div');n.className='caterium-signature-manifesto';n.textContent='Простые решения\nдля больших событий';g.appendChild(n)}if(!$('.caterium-flow-top',g)){const n=document.createElement('div');n.className='caterium-flow-top';n.innerHTML=FLOW;g.appendChild(n)}const t=$('#sunGateTitleV3',g),sub=$('#sunGateSubtitleV3',g);if(t&&t.textContent.trim()==='Вход в Caterium')t.textContent='Войти в рабочее пространство';if(sub&&sub.textContent.trim()==='Введите данные своего аккаунта')sub.textContent='Ваши заказы. Ваша команда. Ваш результат.';field(g,'sunGateEmailV3','Email','email');field(g,'sunGatePasswordV3','Пароль','password');field(g,'sunGatePassword2V27','Подтвердите пароль','password');removeBoot();return true}
function scan(){return decorate($('#sunCloudAuthGateV3'))}styles();scan();const o=new MutationObserver(scan);o.observe(document.documentElement,{childList:true,subtree:true,characterData:true});window.addEventListener('sun:cloud-state-applied',scan);const p=setInterval(()=>{if(scan()||!boot())clearInterval(p)},80);setTimeout(()=>{clearInterval(p);if(boot()&&!$('#sunCloudAuthGateV3'))removeBoot()},10000);window.CateriumLoginSignatureV1776=Object.freeze({VERSION,scan,removeBoot,disconnect:()=>{o.disconnect();clearInterval(p)}}) function scan(){return decorate($('#sunCloudAuthGateV3'))}scan();const o=new MutationObserver(scan);o.observe(document.documentElement,{childList:true,subtree:true,characterData:true});window.addEventListener('sun:cloud-state-applied',scan);const p=setInterval(()=>{if(scan()||!boot())clearInterval(p)},80);setTimeout(()=>{clearInterval(p);if(boot()&&!$('#sunCloudAuthGateV3'))removeBoot()},10000);window.CateriumLoginSignatureV1776=Object.freeze({VERSION,scan,removeBoot,disconnect:()=>{o.disconnect();clearInterval(p)}})
})(); })();

113
public/core/mobile-order.js Normal file
View File

@ -0,0 +1,113 @@
(()=>{
'use strict';
function boot(){
const view=document.getElementById('new'),work=view?.querySelector('.work');
const catalog=work?.querySelector(':scope > .catalog'),order=work?.querySelector(':scope > section.card');
if(!work||!catalog||!order||document.getElementById('ctOrderTop'))return;
const mobile=matchMedia('(max-width:760px)');
const button=document.createElement('button');button.id='ctOrderTop';button.type='button';button.hidden=true;
button.setAttribute('aria-label','Наверх к заказу');button.title='Наверх к заказу';
button.innerHTML='<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5m-6 6 6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Наверх</span>';
document.body.appendChild(button);
const style=document.createElement('style');style.textContent=`
#ctOrderTop{display:none}
@media(max-width:760px){
#new.on{display:flex!important;flex-direction:column}
#new .work{order:-1;width:100%}
#new .work.sun-resizable-work>section.card.sun-order-pane{order:-1!important;grid-column:1!important;grid-row:1!important;position:static!important}
#new .work.sun-resizable-work>.catalog{order:0!important;grid-column:1!important;grid-row:2!important}
#new #orderForm .lines{min-height:0!important}
#ctOrderTop:not([hidden]){display:flex;align-items:center;justify-content:center;gap:6px;position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:900;min-width:48px;min-height:48px;padding:10px 14px;border:1px solid #d7bb65;border-radius:24px;background:#f6e7af;color:#302919;box-shadow:0 4px 18px #0002;font:700 13px/1.2 Arial,sans-serif;cursor:pointer}
#ctOrderTop:focus-visible{outline:3px solid #546747;outline-offset:3px}
}
@media print{#ctOrderTop{display:none!important}}
`;document.head.appendChild(style);
function visibility(){button.hidden=!mobile.matches||!view.classList.contains('on')||document.body.classList.contains('sun-cloud-auth-required')||window.scrollY<300;}
function layout(){
// Move the existing node, preserving inputs, listeners and the current draft.
if(mobile.matches){if(order.nextElementSibling!==catalog)work.insertBefore(order,catalog);}
else{const splitter=work.querySelector(':scope > .sun-work-splitter');const after=splitter||catalog;if(after.nextElementSibling!==order)after.insertAdjacentElement('afterend',order);}
visibility();
}
let frame=0;function update(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;visibility()});}
button.addEventListener('click',()=>{window.scrollTo({top:0,left:0,behavior:'instant'});order.setAttribute('tabindex','-1');order.focus({preventScroll:true});visibility();});
window.addEventListener('scroll',update,{passive:true});mobile.addEventListener('change',layout);
const observer=new MutationObserver(update);observer.observe(view,{attributes:true,attributeFilter:['class']});observer.observe(document.body,{attributes:true,attributeFilter:['class']});
layout();
}
// The menu page is created later by ops-ux. Attach once when it exists, then
// observe only its visibility and direct detail children, never the whole app.
function bootMenu(){
const view=document.getElementById('sun-menu-editor-v1762'),layout=view?.querySelector('.sun-menu-layout');
const list=layout?.querySelector(':scope > .sun-menu-list-pane'),detail=document.getElementById('sunMenuDetailV1762');
if(!view||!layout||!list||!detail)return false;
if(view.dataset.ctMobileMenu==='1')return true;
view.dataset.ctMobileMenu='1';
const mobile=matchMedia('(max-width:760px)');
const button=document.createElement('button');button.id='ctMenuTop';button.type='button';button.hidden=true;
button.setAttribute('aria-label','Наверх к редактору меню');button.title='Наверх к редактору меню';
button.setAttribute('aria-controls','sunMenuDetailV1762');
button.innerHTML='<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5m-6 6 6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Наверх</span>';
document.body.appendChild(button);
const style=document.createElement('style');style.id='ctMobileMenuStyle';style.textContent=`
#ctMenuTop{display:none}
@media(max-width:760px){
#sun-menu-editor-v1762 .sun-menu-layout{grid-template-columns:minmax(0,1fr)}
#sun-menu-editor-v1762 #sunMenuDetailV1762{grid-column:1;grid-row:1;min-width:0;min-height:0}
#sun-menu-editor-v1762 .sun-menu-list-pane{grid-column:1;grid-row:2;min-width:0;min-height:0}
#sun-menu-editor-v1762 .sun-menu-list{max-height:none;overflow:visible}
#sun-menu-editor-v1762 .sun-menu-detail-placeholder{min-height:76px}
#ctMenuTop:not([hidden]){display:flex;align-items:center;justify-content:center;gap:6px;position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:900;min-width:48px;min-height:48px;padding:10px 14px;border:1px solid #d7bb65;border-radius:24px;background:#f6e7af;color:#302919;box-shadow:0 4px 18px #0002;font:700 13px/1.2 Arial,sans-serif;cursor:pointer}
#ctMenuTop:focus-visible{outline:3px solid #546747;outline-offset:3px}
}
@media print{#ctMenuTop{display:none!important}}
`;document.head.appendChild(style);
function active(){return mobile.matches&&view.classList.contains('on')&&!document.body.classList.contains('sun-cloud-auth-required');}
function visibility(){button.hidden=!active()||document.body.classList.contains('sun-modal-open')||window.scrollY<300;}
function neutralHint(){
const label=detail.querySelector('.sun-menu-detail-placeholder b');
if(label?.textContent==='Выберите позицию слева, чтобы открыть карточку.')label.textContent='Выберите позицию из списка, чтобы открыть карточку.';
}
function arrange(){
// Move the LIST, not the form: an unsaved or focused editor keeps its node,
// input values, file selection and listeners across viewport changes.
if(mobile.matches){if(detail.nextElementSibling!==list)detail.after(list);}
else if(list.nextElementSibling!==detail)layout.insertBefore(list,detail);
neutralHint();visibility();
}
function returnToEditor(){
if(!active())return;
window.scrollTo({top:0,left:0,behavior:'instant'});
detail.setAttribute('tabindex','-1');detail.focus({preventScroll:true});visibility();
}
let frame=0;
function update(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;neutralHint();visibility();});}
button.addEventListener('click',returnToEditor);
view.addEventListener('click',e=>{
const target=e.target.closest?.('[data-menu-item-v1762],#sunMenuAddV1762');
if(!target||target.disabled||!active())return;
// The existing click handler docks the editor in a zero-delay task. Wait
// for it, and do not scroll if the user has left Menu in the meantime.
setTimeout(()=>requestAnimationFrame(()=>{
// Never take focus or scroll away after the user has started editing.
const focused=document.activeElement;
if(detail.contains(focused)&&focused?.matches('input,textarea,select,[contenteditable="true"]'))return;
if(detail.querySelector(':scope > .dialog,:scope > .sun-menu-readonly'))returnToEditor();
}),0);
});
window.addEventListener('scroll',update,{passive:true});mobile.addEventListener('change',arrange);
const observer=new MutationObserver(update);
observer.observe(view,{attributes:true,attributeFilter:['class']});
observer.observe(document.body,{attributes:true,attributeFilter:['class']});
observer.observe(detail,{childList:true});
arrange();return true;
}
function start(){
boot();
if(!bootMenu()){
const waiting=new MutationObserver(()=>{if(bootMenu())waiting.disconnect();});
waiting.observe(document.body,{childList:true});
}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();
})();

View File

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

View File

@ -20,7 +20,7 @@
const qa=(s,r=document)=>[...r.querySelectorAll(s)]; const qa=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const toast=(text,type='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,ms)}catch(_){}console[type==='error'?'error':'log'](text)}; const toast=(text,type='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,ms)}catch(_){}console[type==='error'?'error':'log'](text)};
const money=n=>`${Math.round(Number(n||0)).toLocaleString('ru-RU')}`; const money=n=>`${Number(n||0).toLocaleString('ru-RU',{maximumFractionDigits:2})}`;
const readJson=(key,fallback)=>{try{const v=JSON.parse(localStorage.getItem(key)||'');return v??fallback}catch(_){return fallback}}; const readJson=(key,fallback)=>{try{const v=JSON.parse(localStorage.getItem(key)||'');return v??fallback}catch(_){return fallback}};
const writeJson=(key,value)=>localStorage.setItem(key,JSON.stringify(value)); const writeJson=(key,value)=>localStorage.setItem(key,JSON.stringify(value));
const orders=()=>{const v=readJson('sunOrders',[]);return Array.isArray(v)?v:[]}; const orders=()=>{const v=readJson('sunOrders',[]);return Array.isArray(v)?v:[]};
@ -103,7 +103,7 @@
function orderTotal(order){ function orderTotal(order){
if(Number.isFinite(Number(order?.total))&&Number(order.total)>0)return Number(order.total); if(Number.isFinite(Number(order?.total))&&Number(order.total)>0)return Number(order.total);
const catalog=new Map(boxes().map(x=>[String(x.id),x])); const catalog=new Map(boxes().map(x=>[String(x.id),x]));
return (order?.lines||[]).reduce((sum,line)=>{const live=catalog.get(String(line.id));const p=Number.isFinite(Number(line.price))?Number(line.price):Number(live?.price||0);return sum+p*Math.max(0,Number(line.qty||0));},0); return (order?.lines||[]).reduce((sum,line)=>{const live=catalog.get(String(line.id));const p=Number.isFinite(Number(line.price))?Number(line.price):(window.CateriumPricing?.price(live)??Number(live?.price||0));return sum+p*Math.max(0,Number(line.qty||0));},0);
} }
function showCalendarDay(cell){ function showCalendarDay(cell){
const date=calendarCellDate(cell);if(!date)return; const date=calendarCellDate(cell);if(!date)return;
@ -119,7 +119,7 @@
let originalModal=null,originalCloseModal=null,originalSaveBox=null,originalRemoveBox=null; let originalModal=null,originalCloseModal=null,originalSaveBox=null,originalRemoveBox=null;
function menuCanEdit(){if(support())return false;const c=cloud();if(!c?.getSession?.()?.user)return true;return can('catalog.edit')} function menuCanEdit(){if(support())return false;const c=cloud();if(!c?.getSession?.()?.user)return true;return can('catalog.edit')}
function currentCategory(){return CATEGORIES.find(x=>x.id===menuCategory)||CATEGORIES[0]} function currentCategory(){return CATEGORIES.find(x=>x.id===menuCategory)||CATEGORIES[0]}
function menuItems(){const q=menuQuery.trim().toLowerCase();return boxes().filter(x=>Number(x.category||0)===menuCategory&&(!q||[x.name,x.weight,x.catalogSection,...(x.composition||[])].join(' ').toLowerCase().includes(q)))} function menuItems(){const q=menuQuery.trim().toLowerCase();return boxes().filter(x=>(window.CateriumTrainingCatalog?.visible(x)??(x.hidden!==true))&&Number(x.category||0)===menuCategory&&(!q||[x.name,x.weight,x.catalogSection,...(x.composition||[])].join(' ').toLowerCase().includes(q)))}
function restoreEditorDialog(){if(editorHome&&editorDialog&&editorDialog.parentNode!==editorHome){editorHome.appendChild(editorDialog);editorDialog.classList.remove('sun-menu-editor-docked');}editorHome?.classList.remove('on');} function restoreEditorDialog(){if(editorHome&&editorDialog&&editorDialog.parentNode!==editorHome){editorHome.appendChild(editorDialog);editorDialog.classList.remove('sun-menu-editor-docked');}editorHome?.classList.remove('on');}
function emptyMenuDetail(text='Выберите позицию слева, чтобы открыть карточку.'){ function emptyMenuDetail(text='Выберите позицию слева, чтобы открыть карточку.'){
const pane=$('sunMenuDetailV1762');if(!pane)return;restoreEditorDialog();pane.innerHTML=`<div class="sun-menu-detail-placeholder"><div><b>${esc(text)}</b>${!menuCanEdit()?'<div class="sun-menu-readonly-badge" style="margin-top:9px">только просмотр</div>':''}</div></div>`; const pane=$('sunMenuDetailV1762');if(!pane)return;restoreEditorDialog();pane.innerHTML=`<div class="sun-menu-detail-placeholder"><div><b>${esc(text)}</b>${!menuCanEdit()?'<div class="sun-menu-readonly-badge" style="margin-top:9px">только просмотр</div>':''}</div></div>`;
@ -127,17 +127,19 @@
function showReadOnlyMenuItem(item){ function showReadOnlyMenuItem(item){
restoreEditorDialog();const pane=$('sunMenuDetailV1762');if(!pane)return; restoreEditorDialog();const pane=$('sunMenuDetailV1762');if(!pane)return;
const comp=Array.isArray(item.composition)?item.composition.filter(Boolean):[],ingredients=Array.isArray(item.ingredients)?item.ingredients:[]; const comp=Array.isArray(item.composition)?item.composition.filter(Boolean):[],ingredients=Array.isArray(item.ingredients)?item.ingredients:[];
pane.innerHTML=`<div class="sun-menu-readonly"><h2>${esc(item.name||'Позиция')}<span class="sun-menu-readonly-badge">только просмотр</span></h2>${item.photo?`<img class="sun-menu-readonly-photo" src="${esc(item.photo)}" alt="">`:''}<div class="sun-menu-readonly-grid"><div class="sun-menu-readonly-box"><small>Категория</small><b>${esc(currentCategory().label)}</b></div><div class="sun-menu-readonly-box"><small>Цена</small><b>${esc(money(item.price||0))}</b></div><div class="sun-menu-readonly-box"><small>Вес</small><b>${esc(item.weight||'—')}</b></div><div class="sun-menu-readonly-box"><small>Количество</small><b>${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}</b></div></div><div class="sun-route-order-section"><b>Состав для клиента</b>${comp.length?`<ul class="sun-menu-readonly-list">${comp.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div><div class="sun-route-order-section"><b>Состав / ТТК</b>${ingredients.length?`<ul class="sun-menu-readonly-list">${ingredients.map(x=>`<li>${esc(x?.[0]||'')}${esc(x?.[1]??'')} ${esc(x?.[2]||'')}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div></div>`; pane.innerHTML=`<div class="sun-menu-readonly"><h2>${esc(item.name||'Позиция')}<span class="sun-menu-readonly-badge">только просмотр</span></h2>${item.photo?`<img class="sun-menu-readonly-photo" src="${esc(item.photo)}" alt="">`:''}<div class="sun-menu-readonly-grid"><div class="sun-menu-readonly-box"><small>Категория</small><b>${esc(currentCategory().label)}</b></div><div class="sun-menu-readonly-box"><small>Цена</small><b>${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}</b></div><div class="sun-menu-readonly-box"><small>Вес</small><b>${esc(item.weight||'—')}</b></div><div class="sun-menu-readonly-box"><small>Количество</small><b>${Number(item.pieces||0)||'—'}${item.pieces?' шт.':''}</b></div></div><div class="sun-route-order-section"><b>Состав для клиента</b>${comp.length?`<ul class="sun-menu-readonly-list">${comp.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div><div class="sun-route-order-section"><b>Состав / ТТК</b>${ingredients.length?`<ul class="sun-menu-readonly-list">${ingredients.map(x=>`<li>${esc(x?.[0]||'')}${esc(x?.[1]??'')} ${esc(x?.[2]||'')}</li>`).join('')}</ul>`:'<p class="hint">Не заполнен.</p>'}</div></div>`;
} }
function dockEditor(){ function dockEditor(){
if(!menuActive||!menuView?.classList.contains('on'))return;
const pane=$('sunMenuDetailV1762'),home=$('editor'),dialog=home?.querySelector('.dialog')||editorDialog;if(!pane||!home||!dialog)return; const pane=$('sunMenuDetailV1762'),home=$('editor'),dialog=home?.querySelector('.dialog')||editorDialog;if(!pane||!home||!dialog)return;
if(dialog.parentElement===pane)return;
editorHome=home;editorDialog=dialog;home.classList.remove('on');pane.innerHTML='';pane.classList.add('sun-menu-editor-pane');pane.appendChild(dialog);dialog.classList.add('sun-menu-editor-docked'); editorHome=home;editorDialog=dialog;home.classList.remove('on');pane.innerHTML='';pane.classList.add('sun-menu-editor-pane');pane.appendChild(dialog);dialog.classList.add('sun-menu-editor-docked');
} }
function renderMenuList(){ function renderMenuList(){
if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return; if(!menuView)return;const list=$('sunMenuListV1762'),count=$('sunMenuCountV1762');if(!list)return;
qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory)); qa('[data-menu-cat-v1762]',menuView).forEach(b=>b.classList.toggle('on',Number(b.dataset.menuCatV1762)===menuCategory));
const items=menuItems();if(count)count.textContent=`${items.length} поз.`; const items=menuItems();if(count)count.textContent=`${items.length} поз.`;
list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b><small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money(item.price||0))}${Number(item.oldPrice||0)>Number(item.price||0)?`<small><s>${esc(money(item.oldPrice))}</s></small>`:''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>'; list.innerHTML=items.length?items.map(item=>`<button type="button" class="sun-menu-row" data-menu-item-v1762="${esc(item.id)}">${item.photo?`<img src="${esc(item.photo)}" alt="" loading="lazy" decoding="async">`:'<div class="ph"></div>'}<span><b>${esc(item.name||'Без названия')}</b>${window.CateriumTrainingCatalog?.badge(item)||''}<small>${[item.weight,Number(item.pieces||0)?`${Number(item.pieces)} шт.`:'',item.catalogSection].filter(Boolean).map(esc).join(' · ')}</small></span><span class="sun-menu-row-price">${esc(money((window.CateriumPricing?.price(item)??Number(item?.price||0))))}${window.CateriumPricing?.badge(item)||''}</span></button>`).join(''):'<div class="sun-menu-empty">В этом разделе ничего не найдено.</div>';
qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)}); qa('[data-menu-item-v1762]',list).forEach(b=>b.onclick=()=>{const item=boxes().find(x=>String(x.id)===String(b.dataset.menuItemV1762));if(!item)return;if(menuCanEdit()){window.editBox?.(item.id);setTimeout(dockEditor,0)}else showReadOnlyMenuItem(item)});
} }
function patchMenuLegacyEditor(){ function patchMenuLegacyEditor(){
@ -145,7 +147,7 @@
originalModal=window.modal;originalCloseModal=window.closeModal;originalSaveBox=window.saveBox;originalRemoveBox=window.removeBox; originalModal=window.modal;originalCloseModal=window.closeModal;originalSaveBox=window.saveBox;originalRemoveBox=window.removeBox;
if(typeof originalModal==='function')window.modal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')setTimeout(dockEditor,0);return;}return originalModal.call(this,id,...args)}; if(typeof originalModal==='function')window.modal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')setTimeout(dockEditor,0);return;}return originalModal.call(this,id,...args)};
if(typeof originalCloseModal==='function')window.closeModal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')emptyMenuDetail('Изменения сохранены. Выберите следующую позицию.');return;}return originalCloseModal.call(this,id,...args)}; if(typeof originalCloseModal==='function')window.closeModal=function(id,...args){if(menuActive&&(id==='editor'||id==='manager')){if(id==='editor')emptyMenuDetail('Изменения сохранены. Выберите следующую позицию.');return;}return originalCloseModal.call(this,id,...args)};
if(typeof originalSaveBox==='function')window.saveBox=function(...args){const r=originalSaveBox.apply(this,args);if(menuActive)setTimeout(()=>{renderMenuList();emptyMenuDetail('Изменения сохранены. Выберите позицию для продолжения.');},0);return r}; if(typeof originalSaveBox==='function')window.saveBox=function(...args){const r=originalSaveBox.apply(this,args);if(menuActive&&r!==false)setTimeout(()=>{renderMenuList();emptyMenuDetail('Изменения сохранены. Выберите позицию для продолжения.');},0);return r};
if(typeof originalRemoveBox==='function')window.removeBox=function(...args){const r=originalRemoveBox.apply(this,args);if(menuActive)setTimeout(()=>{renderMenuList();emptyMenuDetail('Позиция удалена.');},0);return r}; if(typeof originalRemoveBox==='function')window.removeBox=function(...args){const r=originalRemoveBox.apply(this,args);if(menuActive)setTimeout(()=>{renderMenuList();emptyMenuDetail('Позиция удалена.');},0);return r};
} }
function leaveMenu(){if(!menuActive)return;menuActive=false;restoreEditorDialog();} function leaveMenu(){if(!menuActive)return;menuActive=false;restoreEditorDialog();}
@ -216,7 +218,7 @@
const list=routeOrders(),distance=routeDistance(list,start),summary=qa('.route-summary>div',view).find(x=>/Примерно км/i.test(x.querySelector('small')?.textContent||''));if(summary?.querySelector('b'))summary.querySelector('b').textContent=distance.toLocaleString('ru-RU',{maximumFractionDigits:1}); const list=routeOrders(),distance=routeDistance(list,start),summary=qa('.route-summary>div',view).find(x=>/Примерно км/i.test(x.querySelector('small')?.textContent||''));if(summary?.querySelector('b'))summary.querySelector('b').textContent=distance.toLocaleString('ru-RU',{maximumFractionDigits:1});
} }
function routeOrderById(id){return orders().find(x=>String(x.id)===String(id))||null} function routeOrderById(id){return orders().find(x=>String(x.id)===String(id))||null}
function routeOrderLines(order){const catalog=new Map(boxes().map(x=>[String(x.id),x]));return (order.lines||[]).map(line=>{const item=catalog.get(String(line.id)),qty=Math.max(0,Number(line.qty||0)),price=Number.isFinite(Number(line.price))?Number(line.price):Number(item?.price||0);return {name:item?.name||line.name||`Позиция ${line.id}`,qty,price,sum:qty*price}}).filter(x=>x.qty>0)} function routeOrderLines(order){const catalog=new Map(boxes().map(x=>[String(x.id),x]));return (order.lines||[]).map(line=>{const item=catalog.get(String(line.id)),qty=Math.max(0,Number(line.qty||0)),price=Number.isFinite(Number(line.price))?Number(line.price):(window.CateriumPricing?.price(item)??Number(item?.price||0));return {name:item?.name||line.name||`Позиция ${line.id}`,qty,price,sum:qty*price}}).filter(x=>x.qty>0)}
function showRouteOrder(id){ function showRouteOrder(id){
const o=routeOrderById(id);if(!o)return toast('Заказ не найден.','warn');const lines=routeOrderLines(o),total=orderTotal(o),paid=Math.max(0,Number(o.prepayment||0)),balance=Math.max(0,total-paid),editable=!support()&&can('orders.edit'); const o=routeOrderById(id);if(!o)return toast('Заказ не найден.','warn');const lines=routeOrderLines(o),total=orderTotal(o),paid=Math.max(0,Number(o.prepayment||0)),balance=Math.max(0,total-paid),editable=!support()&&can('orders.edit');
const body=`<div class="sun-route-order-grid"><div class="sun-route-order-kpi"><small>Дата и время</small><b>${esc(fmtDate(o.date))} · ${esc(o.time||'—')}</b></div><div class="sun-route-order-kpi"><small>Статус</small><b>${esc(o.status||'Новый')}</b></div><div class="sun-route-order-kpi"><small>Сумма</small><b>${esc(money(total))}</b></div><div class="sun-route-order-kpi"><small>Остаток</small><b>${esc(money(balance))}</b></div></div><div class="sun-route-order-section"><p><b>${esc(o.event||'Заказ')}</b>${o.guestsCount?` · ${esc(o.guestsCount)} гостей`:''}</p><p><b>Клиент:</b> ${esc(o.contact||'—')} · ${esc(o.phone||'—')}</p><p><b>Адрес:</b> ${esc(o.address||'—')}</p>${o.delivery?`<p><b>Доставка:</b> ${esc(money(o.delivery))}</p>`:''}${o.courierNote?`<div class="sun-route-order-note"><b>Курьеру:</b><br>${esc(o.courierNote)}</div>`:''}${o.note?`<div class="sun-route-order-note"><b>Комментарий:</b><br>${esc(o.note)}</div>`:''}</div><div class="sun-route-order-section"><b>Меню заказа</b><div style="overflow:auto;margin-top:8px"><table class="sun-route-order-lines"><thead><tr><th>Позиция</th><th>Кол.</th><th>Цена</th><th>Сумма</th></tr></thead><tbody>${lines.length?lines.map(x=>`<tr><td>${esc(x.name)}</td><td>${x.qty}</td><td>${esc(money(x.price))}</td><td><b>${esc(money(x.sum))}</b></td></tr>`).join(''):'<tr><td colspan="4">Позиции не указаны.</td></tr>'}</tbody></table></div></div>`; const body=`<div class="sun-route-order-grid"><div class="sun-route-order-kpi"><small>Дата и время</small><b>${esc(fmtDate(o.date))} · ${esc(o.time||'—')}</b></div><div class="sun-route-order-kpi"><small>Статус</small><b>${esc(o.status||'Новый')}</b></div><div class="sun-route-order-kpi"><small>Сумма</small><b>${esc(money(total))}</b></div><div class="sun-route-order-kpi"><small>Остаток</small><b>${esc(money(balance))}</b></div></div><div class="sun-route-order-section"><p><b>${esc(o.event||'Заказ')}</b>${o.guestsCount?` · ${esc(o.guestsCount)} гостей`:''}</p><p><b>Клиент:</b> ${esc(o.contact||'—')} · ${esc(o.phone||'—')}</p><p><b>Адрес:</b> ${esc(o.address||'—')}</p>${o.delivery?`<p><b>Доставка:</b> ${esc(money(o.delivery))}</p>`:''}${o.courierNote?`<div class="sun-route-order-note"><b>Курьеру:</b><br>${esc(o.courierNote)}</div>`:''}${o.note?`<div class="sun-route-order-note"><b>Комментарий:</b><br>${esc(o.note)}</div>`:''}</div><div class="sun-route-order-section"><b>Меню заказа</b><div style="overflow:auto;margin-top:8px"><table class="sun-route-order-lines"><thead><tr><th>Позиция</th><th>Кол.</th><th>Цена</th><th>Сумма</th></tr></thead><tbody>${lines.length?lines.map(x=>`<tr><td>${esc(x.name)}</td><td>${x.qty}</td><td>${esc(money(x.price))}</td><td><b>${esc(money(x.sum))}</b></td></tr>`).join(''):'<tr><td colspan="4">Позиции не указаны.</td></tr>'}</tbody></table></div></div>`;
@ -251,6 +253,8 @@
installStyles();patchSupportPermissions();installMenuPage();maintainSupport();syncMenuPermission();enhanceRoutePage(); installStyles();patchSupportPermissions();installMenuPage();maintainSupport();syncMenuPermission();enhanceRoutePage();
document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true); document.addEventListener('click',e=>{const more=e.target.closest('#calendar .cal-more');if(more){e.preventDefault();e.stopImmediatePropagation();showCalendarDay(more.closest('.cal-day'));return}interceptRouteActions(e)},true);
window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}}); window.addEventListener('sun:cloud-permissions-changed',()=>{patchSupportPermissions();maintainSupport();syncMenuPermission();if(menuActive){renderMenuList();emptyMenuDetail();}});
window.addEventListener('caterium:catalog-prices-changed',()=>{if(menuActive)renderMenuList();});
window.addEventListener('caterium:training-catalog-changed',()=>{if(menuActive)renderMenuList();});
window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)}); window.addEventListener('sun:cloud-state-applied',()=>{maintainSupport();syncMenuPermission();if(menuActive)renderMenuList();setTimeout(enhanceRoutePage,80)});
const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true}); const mo=new MutationObserver(records=>{let route=false,supportBanner=false;for(const r of records){for(const n of r.addedNodes){if(n.nodeType!==1)continue;if(n.id==='routeContent'||n.querySelector?.('#routeContent')||n.closest?.('#sun-routes-view'))route=true;if(n.id==='sunDevSupportBannerV22'||n.querySelector?.('#sunDevSupportBannerV22'))supportBanner=true;}}if(route)setTimeout(enhanceRoutePage,30);if(supportBanner)setTimeout(renderSupportStatus,20);});mo.observe(document.documentElement,{childList:true,subtree:true});
if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000); if(!maintenanceTimer)maintenanceTimer=setInterval(()=>{if(document.hidden)return;maintainSupport();syncMenuPermission();if($('sun-routes-view')?.classList.contains('on'))enhanceRoutePage();},8000);

View File

@ -9,8 +9,8 @@ function activeLines(){return Array.isArray(draft?.lines)?draft.lines.filter(l=>
function comments(){let rows=lineRows(),ls=activeLines();rows.forEach((row,i)=>{let l=ls[i];if(!l)return;let w=$('.sun-line-comment-v1775',row);if(!w){w=document.createElement('div');w.className='sun-line-comment-v1775';w.innerHTML='<button type="button" class="sun-line-comment-btn-v1775">💬 Оставить комментарий</button><div class="sun-line-comment-editor-v1775"><input type="text" maxlength="300" placeholder="Аллергия, убрать ингредиент, изменение состава…"><button type="button" class="sun-line-comment-save-v1775">Готово</button></div>';row.appendChild(w)}let btn=$('.sun-line-comment-btn-v1775',w),editor=$('.sun-line-comment-editor-v1775',w),input=$('input',w),save=$('.sun-line-comment-save-v1775',w);let current=safe(l.comment);btn.classList.toggle('has-comment',!!current);btn.textContent=current?'💬 Комментарий добавлен':'💬 Оставить комментарий';if(document.activeElement!==input&&input.value!==current)input.value=current;if(!btn.dataset.bound){btn.dataset.bound='1';btn.addEventListener('click',()=>{editor.classList.toggle('on');if(editor.classList.contains('on')){input.value=safe(l.comment);setTimeout(()=>input.focus(),0)}});input.addEventListener('input',()=>{l.comment=input.value.slice(0,300)});input.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();save.click()}});save.addEventListener('click',()=>{l.comment=input.value.trim().slice(0,300);btn.classList.toggle('has-comment',!!l.comment);btn.textContent=l.comment?'💬 Комментарий добавлен':'💬 Оставить комментарий';editor.classList.remove('on')})}})} function comments(){let rows=lineRows(),ls=activeLines();rows.forEach((row,i)=>{let l=ls[i];if(!l)return;let w=$('.sun-line-comment-v1775',row);if(!w){w=document.createElement('div');w.className='sun-line-comment-v1775';w.innerHTML='<button type="button" class="sun-line-comment-btn-v1775">💬 Оставить комментарий</button><div class="sun-line-comment-editor-v1775"><input type="text" maxlength="300" placeholder="Аллергия, убрать ингредиент, изменение состава…"><button type="button" class="sun-line-comment-save-v1775">Готово</button></div>';row.appendChild(w)}let btn=$('.sun-line-comment-btn-v1775',w),editor=$('.sun-line-comment-editor-v1775',w),input=$('input',w),save=$('.sun-line-comment-save-v1775',w);let current=safe(l.comment);btn.classList.toggle('has-comment',!!current);btn.textContent=current?'💬 Комментарий добавлен':'💬 Оставить комментарий';if(document.activeElement!==input&&input.value!==current)input.value=current;if(!btn.dataset.bound){btn.dataset.bound='1';btn.addEventListener('click',()=>{editor.classList.toggle('on');if(editor.classList.contains('on')){input.value=safe(l.comment);setTimeout(()=>input.focus(),0)}});input.addEventListener('input',()=>{l.comment=input.value.slice(0,300)});input.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();save.click()}});save.addEventListener('click',()=>{l.comment=input.value.trim().slice(0,300);btn.classList.toggle('has-comment',!!l.comment);btn.textContent=l.comment?'💬 Комментарий добавлен':'💬 Оставить комментарий';editor.classList.remove('on')})}})}
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(){let positions=Math.max(0,Number(typeof calcOrderTotal==='function'?calcOrderTotal():0)),d=delivery(),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 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 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 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='20260916-v18-1-1-template-whitelist-fix'; const RELEASE='20260922-order-summary-fix';
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(){
@ -16,16 +16,23 @@
#cateriumAuthBootV1776 .cab-word{font:500 36px/1 Georgia,'Times New Roman',serif;color:#26231f;letter-spacing:-.02em} #cateriumAuthBootV1776 .cab-word{font:500 36px/1 Georgia,'Times New Roman',serif;color:#26231f;letter-spacing:-.02em}
#cateriumAuthBootV1776 h1{margin:0;max-width:720px;color:#24211d;font:500 clamp(48px,5.2vw,76px)/.99 Georgia,'Times New Roman',serif;letter-spacing:-.045em} #cateriumAuthBootV1776 h1{margin:0;max-width:720px;color:#24211d;font:500 clamp(48px,5.2vw,76px)/.99 Georgia,'Times New Roman',serif;letter-spacing:-.045em}
#cateriumAuthBootV1776 p{margin:18px 0 0;color:#8e887f;font:400 clamp(16px,1.55vw,21px)/1.45 Arial,sans-serif} #cateriumAuthBootV1776 p{margin:18px 0 0;color:#8e887f;font:400 clamp(16px,1.55vw,21px)/1.45 Arial,sans-serif}
#cateriumAuthBootV1776 .cab-fields{margin-top:25px;display:grid;gap:14px}.cab-field{height:58px;border:1px solid #bdb5aa;border-radius:13px;background:rgba(255,255,255,.13)}
#cateriumAuthBootV1776 .cab-btn{height:62px;margin-top:11px;border-radius:13px;background:linear-gradient(100deg,#d9aa42,#efca69)}
#cateriumAuthBootV1776 .cab-note{position:fixed;left:42px;bottom:31px;color:#9d968d;font:9px/1 Arial,sans-serif;letter-spacing:.31em;text-transform:uppercase;text-shadow:none} #cateriumAuthBootV1776 .cab-note{position:fixed;left:42px;bottom:31px;color:#9d968d;font:9px/1 Arial,sans-serif;letter-spacing:.31em;text-transform:uppercase;text-shadow:none}
@media(max-width:900px){#cateriumAuthBootV1776{padding:34px 28px 70px;place-items:start center}#cateriumAuthBootV1776 .cab-wrap{width:min(720px,100%);padding-top:12px}} @media(max-width:900px){#cateriumAuthBootV1776{padding:34px 28px 70px;place-items:start center}#cateriumAuthBootV1776 .cab-wrap{width:min(720px,100%);padding-top:12px}}
@media(max-width:620px){#cateriumAuthBootV1776{min-height:100dvh;padding:25px 18px 50px;place-items:start center}#cateriumAuthBootV1776 .cab-wrap{width:100%}#cateriumAuthBootV1776 .cab-brand{margin-bottom:42px}#cateriumAuthBootV1776 .cab-mark{width:50px;height:50px;flex-basis:50px}#cateriumAuthBootV1776 .cab-word{font-size:29px}#cateriumAuthBootV1776 h1{font-size:clamp(38px,12vw,52px)}#cateriumAuthBootV1776 .cab-note{display:none}} @media(max-width:620px){#cateriumAuthBootV1776{min-height:100dvh;padding:25px 18px 50px;place-items:start center}#cateriumAuthBootV1776 .cab-wrap{width:100%}#cateriumAuthBootV1776 .cab-brand{margin-bottom:42px}#cateriumAuthBootV1776 .cab-mark{width:50px;height:50px;flex-basis:50px}#cateriumAuthBootV1776 .cab-word{font-size:29px}#cateriumAuthBootV1776 h1{font-size:clamp(38px,12vw,52px)}#cateriumAuthBootV1776 .cab-note{display:none}}
`;document.head.appendChild(style); `;document.head.appendChild(style);
const el=document.createElement('div');el.id='cateriumAuthBootV1776';el.innerHTML='<div class="cab-wrap"><div class="cab-brand"><img class="cab-mark" src="caterium-mark-light.svg" alt=""><span class="cab-word">Caterium</span></div><h1>Войти в рабочее пространство</h1><p>Ваши заказы. Ваша команда. Ваш результат.</p><div class="cab-fields"><div class="cab-field"></div><div class="cab-field"></div><div class="cab-btn"></div></div></div><div class="cab-note">Caterium · вкус в деталях</div>';document.documentElement.appendChild(el); const el=document.createElement('div');el.id='cateriumAuthBootV1776';el.innerHTML='<div class="cab-wrap"><div class="cab-brand"><img class="cab-mark" src="caterium-mark-light.svg" alt=""><span class="cab-word">Caterium</span></div><h1>Открываю Caterium…</h1><p role="status">Проверяю сохранённый вход</p></div><div class="cab-note">Caterium · вкус в деталях</div>';document.documentElement.appendChild(el);
} }
function loadLoginSignatureEarly(){if(window.CateriumLoginSignatureV1776||document.getElementById('cateriumLoginSignatureV1776Script'))return;const s=document.createElement('script');s.id='cateriumLoginSignatureV1776Script';s.src=`core/login-signature-v1776.js?v=${RELEASE}`;s.async=false;document.head.appendChild(s)} function loadLoginSignatureEarly(){if(window.CateriumLoginSignatureV1776||document.getElementById('cateriumLoginSignatureV1776Script'))return;const s=document.createElement('script');s.id='cateriumLoginSignatureV1776Script';s.src=`core/login-signature-v1776.js?v=${RELEASE}`;s.async=false;document.head.appendChild(s)}
installAuthBoot();loadLoginSignatureEarly(); installAuthBoot();loadLoginSignatureEarly();
// Boot cleanup is independent of the optional decorative script.
if(document.getElementById('cateriumAuthBootV1776')){
const bootObserver=new MutationObserver(()=>{
const boot=document.getElementById('cateriumAuthBootV1776');
if(!boot){bootObserver.disconnect();return}
if(document.getElementById('sunCloudAuthGateV3')||(window.SunAdminRBACV3&&!document.body?.classList.contains('sun-cloud-auth-required'))){boot.remove();bootObserver.disconnect()}
});
bootObserver.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});
}
const critical=img=>img.closest('header,.brand,#sunCloudAuthGate,.sun-auth-gate')||img.id==='sunLoginLogo'||img.classList.contains('sun-live-catalog-logo'); const critical=img=>img.closest('header,.brand,#sunCloudAuthGate,.sun-auth-gate')||img.id==='sunLoginLogo'||img.classList.contains('sun-live-catalog-logo');
const tune=img=>{if(!(img instanceof HTMLImageElement)||critical(img))return;if(!img.hasAttribute('loading'))img.loading='lazy';if(!img.hasAttribute('decoding'))img.decoding='async';if(!img.hasAttribute('fetchpriority'))img.setAttribute('fetchpriority','low');}; const tune=img=>{if(!(img instanceof HTMLImageElement)||critical(img))return;if(!img.hasAttribute('loading'))img.loading='lazy';if(!img.hasAttribute('decoding'))img.decoding='async';if(!img.hasAttribute('fetchpriority'))img.setAttribute('fetchpriority','low');};

View File

@ -0,0 +1,295 @@
/* Client proposals. A4 layouts inspired by the original v17.6.7 covers.
* Coordinates are shared by preview and download; text is measured after local fonts load.
*/
(()=>{
'use strict';
const W=1000,H=1414,M=70,BOTTOM=1310,SCALE=2.4;
const SANS='"Caterium Manrope",Arial,sans-serif';
const SERIF='"Caterium Playfair",Georgia,serif';
const ITALIC='"Caterium Playfair Italic",Georgia,serif';
const definitions=[
['light','Минимализм','split','#fffdf8','#20362d','#98713b','serif','rows'],
['midnight-glass','Luxury Dark','night','#111916','#f6f1e5','#d4b272','serif','rows'],
['editorial-grid','Editorial Magazine','editorial','#f6f3ec','#183d30','#94743c','serif','cards'],
['warm-sun','Warm Sun','sun','#fff4d8','#51361e','#9a5b1b','serif','rows'],
['bento-cards','Bento Cards','bento','#f3f2eb','#27382b','#8b6022','sans','cards'],
['event-story','Event Story','story','#f4ece0','#523a2b','#92643c','serif','rows'],
['black-gold','Food First','photo','#10100e','#fff7e9','#d8b671','serif','cards'],
['personal-letter','Personal Letter','letter','#f7f1e6','#473b2d','#8b673b','italic','rows'],
['event-ticket','Event Ticket','ticket','#eef2f3','#203f53','#38677a','sans','rows'],
['solar-experience','Solar Experience','solar','#ffd54f','#28392c','#77521f','sans','cards'],
['midnight-compact','Ночной минимализм','compact','#122630','#edf4f4','#9cbec4','sans','compact'],
['emerald-gold','Изумрудная классика','frame','#123b31','#f8f2df','#d1b578','serif','rows'],
['neon-emerald','Изумрудный акцент','neon-vertical','#071f1d','#edfff6','#7debaf','sans','compact'],
['cream-elegance','Кремовая классика','arch','#faf3e6','#35432e','#917445','serif','cards'],
['neon-menu','Неоновое меню','neon','#111a15','#f1ffef','#a1ed92','sans','compact'],
['emerald-circles','Изумрудные круги','circles','#102e2c','#f0f6ee','#d3b779','serif','cards'],
['midnight-checklist','Тёмный чек-лист','checklist','#12241d','#f1f5ea','#d7c593','sans','rows'],
['gourmet-hero','Гастро-витрина','gallery','#181611','#fff6e6','#dbb775','serif','cards'],
['diamond-gold','Изумруд и золото','diamond','#133329','#fff6de','#dabe7b','serif','rows'],
['premium-dark','Премиум тёмный','panorama','#141820','#f7f2e9','#cfb18a','sans','rows'],
['premium-emerald','Премиум изумрудный','botanical','#0e382e','#f5f2df','#b8d19c','italic','cards']
];
const THEMES=Object.fromEntries(definitions.map(([id,name,cover,bg,ink,accent,type,menu])=>[id,{id,name,cover,bg,ink,accent,type,menu,dark:!['light','editorial-grid','warm-sun','bento-cards','event-story','personal-letter','event-ticket','solar-experience','cream-elegance'].includes(id)}]));
const IDs=Object.keys(THEMES);
const CURATED_IDS=['light','editorial-grid','midnight-glass','event-story','bento-cards','event-ticket'];
const names={'light':'Светлая классика','editorial-grid':'Редакционный','midnight-glass':'Вечерний','event-story':'Тёплый приём','bento-cards':'Гастро','event-ticket':'Приглашение'};
// Resolve old choices without rewriting orders or their saved snapshots.
const aliases={'warm-sun':'event-story','black-gold':'midnight-glass','personal-letter':'event-story','solar-experience':'bento-cards','midnight-compact':'midnight-glass','emerald-gold':'midnight-glass','neon-emerald':'midnight-glass','cream-elegance':'light','neon-menu':'bento-cards','emerald-circles':'midnight-glass','midnight-checklist':'midnight-glass','gourmet-hero':'bento-cards','diamond-gold':'midnight-glass','premium-dark':'midnight-glass','premium-emerald':'midnight-glass'};
const resolveTemplate=id=>CURATED_IDS.includes(id)?id:aliases[id]||'light';
const number=v=>Number.isFinite(Number(v))?Number(v):0;
const money=v=>Math.round(number(v)).toLocaleString('ru-RU')+' ₽';
const qty=v=>number(v).toLocaleString('ru-RU',{maximumFractionDigits:3});
const date=v=>{if(!v)return '';const d=new Date(String(v)+'T12:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'})};
let fontsPromise;
async function ready(){
if(!fontsPromise)fontsPromise=(async()=>{
if(!window.FontFace||!document.fonts)return;
const defs=[['Caterium Manrope','Manrope.ttf','200 800'],['Caterium Playfair','PlayfairDisplay.ttf','400 900'],['Caterium Playfair Italic','PlayfairDisplay-Italic.ttf','400 900']];
await Promise.all(defs.map(async([family,file,weight])=>{
const face=new FontFace(family,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});
try{await Promise.race([face.load(),new Promise((_,reject)=>setTimeout(()=>reject(new Error('Font timeout')),8000))]);document.fonts.add(face)}catch(e){console.warn('Proposal font fallback:',family,e.message)}
}));
// Other app/UI webfonts may still be loading indefinitely. Only the
// three awaited proposal faces determine this canvas layout.
})();
return fontsPromise;
}
function loadImage(src){return new Promise(resolve=>{
if(!/^data:image\/(?:png|jpe?g|webp);base64,/i.test(String(src||'')))return resolve(null);
const image=new Image();let done=false;
const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};
const timer=setTimeout(()=>finish(null),4000);image.onload=()=>finish(image);image.onerror=()=>finish(null);image.src=src;
})}
function split(ctx,value,width){
const out=[];
for(const para of String(value??'').split(/\r?\n/)){
if(!para.trim()){out.push('');continue}
let line='';
for(const word of para.trim().split(/\s+/)){
if(ctx.measureText((line?line+' ':'')+word).width<=width){line+=(line?' ':'')+word;continue}
if(line){out.push(line);line=''}
for(const char of word){if(line&&ctx.measureText(line+char).width>width){out.push(line);line=''}line+=char}
}
if(line)out.push(line);
}
return out;
}
const defaults={showClientName:true,showDate:true,showGuests:true,showGallery:true,showPriceBreakdown:true,showAmountPerGuest:true,showBoxCount:true,showControl:true,showExtras:true,showFooterNote:true,showFinalPhoto:true,metricMode:'weight',texts:{}};
function settings(s){return {...defaults,...s.pdfSettings,texts:{...defaults.texts,...s.pdfSettings?.texts,...s.offerOverrides?.texts}}}
function painter(canvas,theme){
const ctx=canvas.getContext('2d',{alpha:false});const scale=canvas.width/W;ctx.setTransform(scale,0,0,scale,0,0);
const boxes=canvas.__proposalLayout||[];canvas.__proposalLayout=boxes;
const body=(size=19,weight=400)=>`${weight} ${size}px ${SANS}`;
const display=(size=54)=>`${theme.type==='sans'?700:500} ${size}px ${theme.type==='sans'?SANS:theme.type==='italic'?ITALIC:SERIF}`;
function rect(x,y,w,h,color,r=0){ctx.fillStyle=color;ctx.beginPath();ctx.roundRect(x,y,w,h,r);ctx.fill()}
function line(x,y,w,color=theme.accent){rect(x,y,w,1,color)}
function lines(value,w,font){ctx.font=font;return split(ctx,value,w)}
function write(value,x,y,w,{size=19,weight=400,color=theme.ink,font=body(size,weight),lh=size*1.42,align='left',role='text'}={}){
const values=Array.isArray(value)?value:lines(value,w,font);ctx.save();ctx.font=font;ctx.textBaseline='top';ctx.textAlign=align;ctx.fillStyle=color;
const xx=align==='right'?x+w:align==='center'?x+w/2:x;
values.forEach((v,i)=>ctx.fillText(v,xx,y+i*lh));ctx.restore();
boxes.push({x,y,w,h:values.length*lh,role,text:values.join('\n'),font});return values.length*lh;
}
function fit(value,x,y,w,h,{size=54,min=18,font,displayFont=true,...opts}={}){
let f,ls,lh;
do{f=font|| (displayFont?display(size):body(size,opts.weight||500));lh=size*1.27;ls=lines(value,w,f);if(ls.length*lh<=h)break;size-=1}while(size>=min);
// Cover copy is repeated in the flowing details when unusually long.
if(ls.length*lh>h){canvas.__proposalOverflow=canvas.__proposalOverflow||[];canvas.__proposalOverflow.push(String(value));return write('Подробности на следующих страницах',x,y,w,{size:18,lh:25,...opts})}
return write(ls,x,y,w,{...opts,font:f,size,lh});
}
function photo(img,x,y,w,h,{radius=0,shape='rect',contain=false}={}){
ctx.save();ctx.beginPath();if(shape==='circle')ctx.ellipse(x+w/2,y+h/2,w/2,h/2,0,0,Math.PI*2);else if(shape==='diamond'){ctx.moveTo(x+w/2,y);ctx.lineTo(x+w,y+h/2);ctx.lineTo(x+w/2,y+h);ctx.lineTo(x,y+h/2);ctx.closePath()}else ctx.roundRect(x,y,w,h,radius);ctx.clip();
rect(x,y,w,h,theme.dark?'#263e34':'#e9e3d7');
if(img){const scale=(contain?Math.min:Math.max)(w/img.width,h/img.height);ctx.drawImage(img,x+(w-img.width*scale)/2,y+(h-img.height*scale)/2,img.width*scale,img.height*scale)}
ctx.restore();
}
return {ctx,body,display,rect,line,lines,write,fit,photo};
}
function page(t,kind){const c=document.createElement('canvas');c.width=W*(t.scale||SCALE);c.height=Math.round(H*(t.scale||SCALE));c.dataset.sunProposalTemplate=t.id;c.dataset.sunProposalPage=kind;const d=painter(c,t);d.rect(0,0,W,H,t.bg);return {c,...d}}
function logoForDocument(image,t){
if(!image)return null;
let canvas;try{canvas=window.CateriumBranding.prepareLogoImage(image)}catch(_){return null}
const ctx=canvas.getContext('2d',{willReadFrequently:true}),pixels=ctx.getImageData(0,0,canvas.width,canvas.height).data;
const linear=v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4};
const luminance=(r,g,b)=>.2126*linear(r)+.7152*linear(g)+.0722*linear(b);
const rgb=t.bg.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),bg=luminance(...rgb);
let light=0,dark=0,weak=0,count=0;
for(let i=0;i<pixels.length;i+=4){const a=pixels[i+3]/255;if(a<.1)continue;const l=luminance(pixels[i],pixels[i+1],pixels[i+2]);count+=a;if((Math.max(l,bg)+.05)/(Math.min(l,bg)+.05)<2)weak+=a;if(l>.4)light+=a;else dark+=a}
// A full masthead, never a square tile, provides contrast for monochrome logos.
const alternate=count&&weak/count>.55;
return {canvas,bg:alternate?(light>dark?'#17251f':'#fffdf8'):t.bg,ink:alternate?(light>dark?'#fffdf8':'#20362d'):t.ink};
}
function brand(d,s,t,logo,{x=M,y=48,w=860}={}){
let ink=t.ink;
if(logo){
ink=logo.ink;
if(logo.bg!==t.bg)d.rect(0,0,W,y+84,logo.bg);
const image=logo.canvas,maxW=Math.min(280,w*.44),scale=Math.min(maxW/image.width,76/image.height),lw=image.width*scale,lh=image.height*scale;
const yy=y+(76-lh)/2;d.ctx.drawImage(image,x,yy,lw,lh);
(d.c.__proposalImages??=[]).push({role:'logo',x,y:yy,w:lw,h:lh,sourceWidth:image.width,sourceHeight:image.height,background:logo.bg});
x+=lw+32;w-=lw+32;
}
d.fit(s.brandName||'Моя компания',x,y+17,w,57,{size:23,min:15,displayFont:false,weight:700,color:ink});
}
function facts(s,cfg){return [cfg.showDate?[date(s.date),s.time].filter(Boolean).join(' · '):'',cfg.showGuests&&number(s.guests)?`${qty(s.guests)} гостей`:''].filter(Boolean)}
function cover(s,t,images,logo,cfg){
const d=page(t,'cover'),hero=images.find(Boolean),hero2=images.filter(Boolean)[1]||hero,hero3=images.filter(Boolean)[2]||hero;
const title=s.event||'Ваше мероприятие',client=cfg.showClientName?s.client||'':'',eyebrow=cfg.texts.proposalLabel||'Индивидуальное предложение';
const ink=t.ink,accent=t.accent;
const heading=(x,y,w,h=250,size=64)=>d.fit(title,x,y,w,h,{size});
const sub=(x,y,w,h=94)=>d.fit(client,x,y,w,h,{size:25,displayFont:false});
const label=(x,y,w=860)=>d.fit(eyebrow,x,y,w,54,{size:18,displayFont:false,color:accent});
const fact=(x,y,w=860)=>d.fit(facts(s,cfg).join(' / '),x,y,w,65,{size:21,displayFont:false});
const note=(x,y,w,h=140)=>d.fit(cfg.texts.heroNote||'Меню, подобранное для вашего события.',x,y,w,h,{size:22,displayFont:false,color:ink});
const total=(x,y,w=400)=>{d.fit(cfg.texts.totalPriceTitle||'Итоговая стоимость',x,y,w,50,{size:17,displayFont:false,color:accent});d.fit(money(s.pricing?.total),x,y+50,w,100,{size:52})};
if(!['night','photo'].includes(t.cover))brand(d,s,t,logo);
switch(t.cover){
case 'split':
label(M,198,400);heading(M,265,414,290,62);sub(M,582,395);d.line(M,718,370);note(M,758,380);d.photo(hero,520,195,410,760,{radius:180});fact(M,1010);total(M,1120);break;
case 'night':
d.photo(hero,482,155,518,H-155);d.rect(0,0,482,H,t.bg);brand(d,s,t,logo);label(M,235,350);heading(M,310,350,310,58);sub(M,650,345);fact(M,835,345);d.line(M,1000,340);total(M,1050,350);break;
case 'editorial':{
label(M,180);const bottom=244+heading(M,244,840,200,72),photoY=Math.max(435,bottom+66);d.line(M,photoY-32,860);d.photo(hero,414,photoY,516,1170-photoY);d.write('МЕНЮ / 01',M,photoY+4,290,{size:19,weight:700,color:accent});sub(M,photoY+95,280,145);fact(M,photoY+290,290);total(M,1040,310);break;}
case 'sun':
d.rect(758,180,172,172,'#f2c55b',86);label(M,200,550);heading(M,280,630,250,64);sub(M,562,780);d.photo(hero,320,698,610,390,{radius:180});fact(M,649,850);total(M,1140,530);break;
case 'bento':
label(M,180);heading(M,255,850,185,67);sub(M,465,800,65);note(M,545,850,52);d.photo(hero,M,635,556,405,{radius:26});d.photo(hero2,650,635,280,190,{radius:24});d.photo(hero3,650,850,280,190,{radius:24});fact(M,1080);total(M,1160,740);break;
case 'story':
label(M,182);heading(M,252,835,185,68);sub(M,470,820,75);note(M,556,840,64);d.line(M,670,860);['Меню','Подготовка','Ваше событие'].forEach((v,i)=>{const x=M+i*310;d.rect(x,662,16,16,accent,8);d.write(v,x,697,240,{size:18})});d.photo(hero,M,757,860,340);fact(M,1140,410);total(553,1130,377);break;
case 'photo':{
d.photo(hero,0,0,W,H);const g=d.ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'rgba(0,0,0,.82)');g.addColorStop(.48,'rgba(0,0,0,.24)');g.addColorStop(1,'rgba(0,0,0,.95)');d.ctx.fillStyle=g;d.ctx.fillRect(0,0,W,H);brand(d,s,t,logo);label(M,215);heading(M,290,800,270,74);sub(M,620,800);fact(M,1010);total(M,1120,820);break;}
case 'letter':
d.rect(44,162,912,1125,'#fffcf5',4);label(94,218,700);d.fit(client?`${client},`:'Дорогие гости,',94,308,760,165,{size:56});d.write('Предложение для вашего мероприятия',94,496,730,{size:22});heading(94,549,730,170,44);note(94,761,470,155);d.photo(hero,690,769,210,210,{shape:'circle'});fact(94,1010,790);total(94,1100,570);break;
case 'ticket':
d.rect(M,190,860,1080,'#fbfcfa',18);d.rect(740,190,190,1080,'#dbe8ea',18);d.ctx.save();d.ctx.setLineDash([7,9]);d.ctx.strokeStyle=accent;d.ctx.beginPath();d.ctx.moveTo(716,215);d.ctx.lineTo(716,1240);d.ctx.stroke();d.ctx.restore();label(106,246,570);heading(106,329,560,220,59);sub(106,590,550,94);note(106,710,550,64);fact(106,822,550);d.photo(hero,106,930,255,180,{radius:10});total(390,946,280);d.write('МЕНЮ',762,268,150,{size:17,weight:700});if(cfg.showDate){d.fit(date(s.date),765,341,140,180,{size:26,displayFont:false});d.fit(s.time||'',765,570,140,80,{size:34,displayFont:false})}break;
case 'solar':
label(M,205);heading(M,276,460,300,64);d.photo(hero,571,390,359,359,{shape:'circle'});sub(M,649,435,150);d.line(M,860,860);fact(M,913);d.rect(M,1040,860,238,'#fff1b5',30);total(106,1080,780);break;
case 'compact':
label(M,212);heading(M,300,850,250,70);sub(M,590,820);fact(M,760);d.line(M,867,860);d.photo(hero,550,935,380,330);total(M,980,420);break;
case 'frame':
d.ctx.strokeStyle=accent;d.ctx.lineWidth=1;d.ctx.strokeRect(38,158,924,1140);label(96,227,808);d.fit(title,96,313,808,255,{size:67,align:'center'});d.fit(client,96,604,808,90,{size:24,displayFont:false,align:'center'});d.photo(hero,240,750,520,300,{radius:160});fact(96,1103,810);total(96,1175,810);break;
case 'neon-vertical':
d.rect(M,218,12,910,accent);label(114,218,815);heading(114,300,800,240,72);sub(114,571,780);d.photo(hero,114,750,390,390,{radius:195});d.photo(hero2,536,750,390,390,{radius:195});fact(114,668,800);total(114,1170,790);break;
case 'arch':
label(M,189);d.fit(title,M,265,860,205,{size:65,align:'center'});d.fit(client,M,490,860,94,{size:24,displayFont:false,align:'center'});d.photo(hero,244,648,512,445,{radius:[250,250,10,10]});fact(M,1140,420);total(555,1136,375);break;
case 'neon':
d.rect(M,190,860,328,accent,24);d.fit(eyebrow,105,222,790,54,{size:18,displayFont:false,color:'#142019'});d.fit(title,105,290,790,200,{size:62,color:'#142019'});sub(M,558,800);fact(M,660);d.photo(hero,M,785,520,425,{radius:24});d.photo(hero2,616,785,314,205,{radius:24});total(616,1040,314);break;
case 'circles':
label(M,213);heading(M,291,820,225,68);sub(M,550,820);d.photo(hero,M,731,400,400,{shape:'circle'});d.photo(hero2,513,711,256,256,{shape:'circle'});d.photo(hero3,701,919,229,229,{shape:'circle'});fact(M,646);total(M,1165,800);break;
case 'checklist':
label(M,212);heading(M,294,850,240,67);sub(M,568,810);fact(M,680);d.line(M,786,860);(s.items||[]).slice(0,3).forEach((it,i)=>{const y=843+i*99;d.write(String(i+1).padStart(2,'0'),M,y,70,{size:24,color:accent});d.fit(it.name,170,y,750,77,{size:24,displayFont:false})});total(M,1170,810);break;
case 'gallery':
label(M,203);heading(M,280,840,210,68);sub(M,528,810);[hero,hero2,hero3].forEach((im,i)=>d.photo(im,M+i*294,707+i*37,272,355,{radius:136}));fact(M,626);total(M,1160,830);break;
case 'diamond':
label(M,200);heading(M,284,840,220,66);sub(M,537,810);fact(M,648);d.photo(hero,430,735,500,455,{shape:'diamond'});d.write('МЕНЮ',M,879,225,{size:18,color:accent});total(M,969,330);break;
case 'panorama':
d.photo(hero,0,176,W,467);label(M,701);heading(M,778,840,220,65);sub(M,1040,430,140);fact(M,1209,410);total(550,1135,380);break;
case 'botanical':
label(M,199,460);heading(M,284,470,350,64);d.photo(hero,590,205,340,590,{radius:170});sub(M,686,460,155);d.line(M,901,860);note(M,958,475,140);fact(M,1150,445);total(568,1119,362);break;
}
return d.c;
}
function composition(item){const values=item.compositionTotal?.length?item.compositionTotal:item.composition||[];return (Array.isArray(values)?values:[values]).map(v=>typeof v==='string'?v:v?.name||'').filter(Boolean).join(' · ')}
async function renderPages(s){
const cfg=settings(s),items=s.items||[],t={...THEMES[resolveTemplate(s.offerTemplateId)],scale:items.length>24?2:SCALE},cache=new Map();
const img=src=>{if(!src)return Promise.resolve(null);if(!cache.has(src))cache.set(src,loadImage(src));return cache.get(src)};
await ready();
const [rawLogo,images,gallery]=await Promise.all([img(s.logo),Promise.all(items.map(i=>img((i.photoData||i.photo)===s.logo?'':i.photoData||i.photo))),Promise.all((s.finalGallery||[]).map(img))]);
const logo=logoForDocument(rawLogo,t);
const covers=[cover(s,t,images,logo,cfg)],pages=[...covers];let d,y;
const muted=t.dark?'#d1d6ce':'#515b50',lineColor=t.dark?'#426055':'#d9d2c4',panel=t.dark?'#203b30':'#ffffff';
// A sunny cover is paired with warm paper inside, making long menus easier to read.
const inner={...t,bg:t.id==='solar-experience'?'#fff9e8':t.bg};
function newPage(title){d=page(inner,'content');pages.push(d.c);brand(d,s,t,logo,{y:48});d.line(M,135,860,lineColor);y=172;if(title){y+=d.write(title,M,y,860,{size:36,font:d.display(36),lh:46});y+=28}}
function ensure(h,title){if(!d||y+h>BOTTOM)newPage(title)}
function flow(value,{size=19,color=t.ink,font,role='text',gap=20}={}){
if(!String(value||'').trim())return;
ensure(size*1.45+gap);const lines=d.lines(value,860,font||d.body(size));let pos=0;const lh=size*1.45;
while(pos<lines.length){let count=Math.floor((BOTTOM-y-gap)/lh);if(count<1){newPage();count=Math.floor((BOTTOM-y-gap)/lh)}const part=lines.slice(pos,pos+count);y+=d.write(part,M,y,860,{font:font||d.body(size),size,lh,color,role});pos+=part.length;if(pos<lines.length)newPage()}
y+=gap;
}
const title=cfg.texts.menuTitle||'Меню';newPage(title);
for(const value of covers[0].__proposalOverflow||[])flow(value,{size:22});
const meta=it=>[it.categoryName,it.weight?`Вес: ${it.weight}`:'',number(it.pieces)>0?`${qty(it.pieces)} шт. в порции`:''].filter(Boolean).join(' · ');
function priceLine(it){return `${qty(it.qty)} × ${money(it.unitPrice)} = ${money(it.sum)}`}
function measure(it,width,card){return d.lines(it.name||'Позиция меню',width,d.body(card?25:24,700)).length*(card?33:32)+10+(meta(it)?d.lines(meta(it),width,d.body(18)).length*26+10:0)+(composition(it)?d.lines(composition(it),width,d.body(20)).length*29:0)}
function details(it,x,yy,width,card){
yy+=d.write(it.name||'Позиция меню',x,yy,width,{size:card?25:24,weight:700,lh:card?33:32,role:'item-name'})+10;
if(meta(it))yy+=d.write(meta(it),x,yy,width,{size:18,lh:26,color:muted,role:'item-meta'})+10;
if(composition(it))yy+=d.write(composition(it),x,yy,width,{size:20,lh:29,color:muted,role:'composition'});
return yy;
}
function tallItem(it,index){
if(y>280)newPage(title+' · продолжение');
flow(it.name||'Позиция меню',{size:25,font:d.body(25,700),role:'item-name'});flow(meta(it),{size:18,color:muted});flow(composition(it),{size:20,color:muted,role:'composition'});flow(priceLine(it),{size:21,role:'item-price'});d.line(M,y-4,860,lineColor);y+=24;
}
if(!items.length)flow('Позиции меню пока не добавлены.',{color:muted});
if(t.id==='event-story'){
for(let index=0;index<items.length;index++){
const it=items[index],photoW=cfg.showGallery&&images[index]?190:0,x=M+24+(photoW?photoW+28:0),width=906-x;
const h=Math.max(photoW?138:0,measure(it,width,false))+90;
if(h>1060){tallItem(it,index);continue}
ensure(h+20,title+' · продолжение');d.rect(M,y,860,h,'#fffaf1',12);
if(photoW)d.photo(images[index],M+24,y+24,photoW,138,{radius:6});
details(it,x,y+24,width,false);const priceY=y+h-44;
d.line(x,priceY-10,width,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x,priceY,width*.52,{size:18,color:muted,role:'quantity-price'});
d.write(money(it.sum),x+width*.55,priceY,width*.45,{size:23,weight:700,align:'right',role:'item-price'});y+=h+20;
}
}else if(t.menu==='cards'){
const cw=415,gap=30,photoH=cfg.showGallery?160:0;
for(let index=0;index<items.length;){
const pair=items.slice(index,index+2),heights=pair.map(it=>measure(it,cw-40,true)+photoH+132);
if(heights.some(h=>h>1080)){tallItem(items[index],index);index++;continue}
const h=Math.max(...heights);ensure(h+24,title+' · продолжение');
pair.forEach((it,k)=>{const x=M+k*(cw+gap),r=t.id==='editorial-grid'?0:16;d.rect(x,y,cw,h,t.id==='editorial-grid'?t.bg:panel,r);if(photoH)d.photo(images[index+k],x,y,cw,photoH,{radius:[r,r,0,0]});details(it,x+20,y+photoH+24,cw-40,true);const priceY=y+h-75;d.line(x+20,priceY-15,cw-40,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x+20,priceY,cw-40,{size:18,color:muted,role:'quantity-price'});d.write(money(it.sum),x+20,priceY+28,cw-40,{size:23,weight:700,align:'right',role:'item-price'})});y+=h+24;index+=pair.length;
}
}else{
const photoW=cfg.showGallery&&images.some(Boolean)?(t.id==='event-ticket'?90:t.id==='midnight-glass'?120:145):0,tx=M+(photoW?photoW+25:0),tw=860-(tx-M)-185;
const rowHeight=it=>Math.max(photoW,measure(it,tw,false)+40,d.lines(money(it.sum),176,d.body(23,700)).length*32+12+d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)).length*23)+42;
const totalMenuHeight=items.reduce((sum,it)=>sum+rowHeight(it),0),capacity=BOTTOM-y;
const target=totalMenuHeight/Math.max(1,Math.ceil(totalMenuHeight/Math.max(1,capacity)));let menuStart=y;
for(let index=0;index<items.length;index++){
const it=items[index],amountLines=d.lines(money(it.sum),176,d.body(23,700)),amountH=amountLines.length*32,unitLines=d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)),h=Math.max(photoW,measure(it,tw,false)+40,amountH+12+unitLines.length*23)+42;
if(h>1080){tallItem(it,index);continue}
// Balance ordinary rows across pages instead of leaving a short tail page.
if(target>0&&y>menuStart&&y+h>menuStart+target+h*.4){newPage(title+' · продолжение');menuStart=y}
ensure(h,title+' · продолжение');if(t.id==='event-ticket'&&index%2===0)d.rect(M-12,y-12,884,h-20,'#e3ebec',6);if(photoW)d.photo(images[index],M,y,photoW,photoW,{radius:t.id==='midnight-glass'?photoW/2:6});details(it,tx,y,tw,false);
d.write(amountLines,754,y,176,{size:23,lh:32,weight:700,align:'right',role:'item-price'});
d.write(unitLines,754,y+amountH+12,176,{size:16,lh:23,color:muted,align:'right',role:'quantity-price'});
d.line(M,y+h-23,860,lineColor);y+=h;
}
}
const p=s.pricing||{},text=cfg.texts;
const rows=cfg.showPriceBreakdown?[[text.priceItemsLabel||'Стоимость позиций',p.base],...(number(p.manual)?[[text.discountLabel||'Скидка',-number(p.manual)]]:[]),...(number(p.promo)?[[`${text.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-number(p.promo)]]:[]),[text.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[text.deliveryLabel||'Доставка',p.delivery]]:[];
// Keep the calculation and its total together when they fit on one page.
const priceRowHeight=([label,value])=>Math.max(d.lines(label,610,d.body(19)).length*28,d.lines(money(value),215,d.body(21,700)).length*29)+22;
const summaryHeight=Math.max(60,d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29)).length*39,d.lines(money(p.total),460,d.body(35,700)).length*45);
const pricingHeight=22+d.lines(text.pricingTitle||'Расчёт стоимости',860,d.display(34)).length*34*1.45+24+rows.reduce((n,row)=>n+priceRowHeight(row),0)+28+summaryHeight+26+85;
ensure(Math.min(pricingHeight,BOTTOM-172));
y+=22;flow(text.pricingTitle||'Расчёт стоимости',{size:34,font:d.display(34),gap:24});
for(const [label,value] of rows){
const ls=d.lines(label,610,d.body(19)),rs=d.lines(money(value),215,d.body(21,700)),h=Math.max(ls.length*28,rs.length*29)+22;ensure(h+95);
d.write(ls,M,y,610,{size:19,lh:28,color:muted,role:'pricing-label'});d.write(rs,715,y,215,{size:21,weight:700,lh:29,align:'right',role:'pricing-value'});y+=h;
}
ensure(28+summaryHeight+26);d.line(M,y,860,lineColor);y+=28;const totalLabel=d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29));d.write(totalLabel,M,y,380,{size:29,font:d.display(29),lh:39,role:'total-label'});const amount=d.lines(money(p.total),460,d.body(35,700));y+=Math.max(60,totalLabel.length*39,d.write(amount,470,y,460,{size:35,weight:700,lh:45,align:'right',role:'total-value'}))+26;
const metrics=[];const guests=number(s.guests);
if(cfg.showBoxCount)metrics.push(`${text.boxCountLabel||'Количество боксов'}: ${qty(items.filter(i=>[0,5].includes(number(i.categoryId))).reduce((n,i)=>n+number(i.qty),0))}`);
if(cfg.showGuests&&guests){if(cfg.showAmountPerGuest)metrics.push(`${text.amountPerGuestLabel||'Сумма на гостя'}: ${money(number(p.itemsTotal)/guests)}`);if(cfg.metricMode==='pieces'){if(number(s.foodPieces))metrics.push(`${text.piecesMetricLabel||'Канапе на гостя'}: ${qty(Math.ceil(number(s.foodPieces)/guests))} шт.`)}else if(number(s.foodGrams))metrics.push(`${text.weightMetricLabel||'Вес еды на гостя'}: ${qty(Math.ceil(number(s.foodGrams)/guests/5)*5)} г`)}
if(metrics.length)flow(metrics.join(' · '),{size:17,color:muted});
if(cfg.showFooterNote&&text.footerNote)flow(text.footerNote,{size:17,color:muted,gap:28});
if(text.salesTitle&&text.salesNote){ensure(155);flow(text.salesTitle,{size:27,font:d.display(27),gap:16});flow(text.salesNote,{size:19,gap:28})}
const sections=[[cfg.showControl,text.controlTitle||s.controlTitle||'Организация мероприятия',cfg.controlLines||s.controlLines],[cfg.showExtras,text.extrasTitle||s.extrasTitle||'Дополнительно',cfg.extraServices||s.extraServices]].filter(([enabled,,values])=>enabled&&Array.isArray(values)&&values.length);
const columnH=([,heading,values])=>d.lines(heading,405,d.display(27)).length*36+24+values.reduce((sum,v)=>sum+d.lines('• '+String(v),405,d.body(18)).length*26+12,0);
if(sections.length===2&&sections.every(section=>columnH(section)<650)){
const h=Math.max(...sections.map(columnH));ensure(h+35);sections.forEach(([,heading,values],index)=>{const x=M+index*455;let yy=y;d.line(x,yy,405,lineColor);yy+=18;yy+=d.write(heading,x,yy,405,{size:27,font:d.display(27),lh:36})+18;for(const value of values)yy+=d.write('• '+String(value),x,yy,405,{size:18,lh:26})+12});y+=h+35;
}else for(const [,heading,values] of sections){ensure(135);flow(heading,{size:27,font:d.display(27),gap:16});for(const value of values)flow('• '+String(value),{size:18,gap:10});y+=18}
const pics=gallery.filter(Boolean).slice(0,2);
// Gallery fills existing space; it never creates a nearly empty extra page.
if(cfg.showGallery&&cfg.showFinalPhoto&&pics.length&&BOTTOM-y>=260){const label=text.finalGalleryTitle||'Сервировка вашего события',h=d.lines(label,860,d.display(27)).length*38;const available=BOTTOM-y-h-18;if(available>=180){y+=d.write(label,M,y,860,{size:27,font:d.display(27),lh:38})+18;const w=(860-22*(pics.length-1))/pics.length;pics.forEach((im,i)=>d.photo(im,M+i*(w+22),y,w,Math.min(340,available),{radius:8}))}}
pages.forEach((c,i)=>{const fd=painter(c,i?inner:t);const darkCover=i===0&&t.cover==='photo';if(i===0&&['night','photo'].includes(t.cover))fd.rect(0,1330,W,84,t.bg);fd.line(M,1343,860,darkCover?'#8b8b78':lineColor);fd.fit([s.brandName||'Моя компания',s.brandCity].filter(Boolean).join(' · '),M,s.brandContacts?1351:1361,720,26,{size:14,min:12,displayFont:false,color:darkCover?'#fff5e6':muted});if(s.brandContacts)fd.fit(s.brandContacts,M,1376,720,26,{size:12,min:10,displayFont:false,color:darkCover?'#fff5e6':muted});fd.write(`${i+1} / ${pages.length}`,850,1361,80,{size:14,align:'right',color:darkCover?'#fff5e6':muted,role:'page-number'});});
return pages;
}
const descriptions={split:'Светлая обложка, вертикальное фото и спокойная типографика.',night:'Тёмная полоса с текстом и фотография на всю высоту.',editorial:'Журнальная сетка, выразительные заголовки и карточки меню.',sun:'Тёплая бумага, солнечный акцент и широкая фотография.',bento:'Модульная фотокомпозиция и меню в двух колонках.',story:'История события с этапами подготовки и панорамным фото.',photo:'Фотография на всю обложку и золотистые акценты.',letter:'Личное обращение на светлой бумаге и курсивные заголовки.',ticket:'Обложка в форме приглашения с отдельной полосой даты.',solar:'Яркая солнечная обложка и светлые внутренние страницы.',compact:'Лаконичная ночная палитра и компактный список меню.',frame:'Тонкая золотистая рамка и симметричная композиция.', 'neon-vertical':'Вертикальный акцент и пара круглых фотографий.',arch:'Кремовая бумага, арочное фото и классические заголовки.',neon:'Яркий заголовок на тёмной бумаге и модульные фото.',circles:'Круглые фотографии разных размеров и изумрудная палитра.',checklist:'Нумерованные акценты меню на строгой тёмной обложке.',gallery:'Три фотографии в арках и крупная журнальная типографика.',diamond:'Фотография в ромбе и изумрудно-золотая палитра.',panorama:'Широкая панорама и сдержанные современные заголовки.',botanical:'Высокая арка, курсивные заголовки и глубокий изумрудный фон.'};
window.CateriumProposalPDF=Object.freeze({VERSION:'20260918-proposals-six',IDS:IDs,CURATED_IDS,resolveTemplate,templates:CURATED_IDS.map(id=>({id,name:names[id],desc:descriptions[THEMES[id].cover],thumb:`offer-templates/quality-${id}.jpg`})),ready,renderPages});
})();

View File

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

View File

@ -0,0 +1,233 @@
(()=>{
'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

@ -4,9 +4,24 @@
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
})[ch]); })[ch]);
const escapeAttr=escapeHTML; const escapeAttr=escapeHTML;
// Personal UI preferences must never be included in a company payload.
const personalStorageKey=name=>{
const cloud=window.SunCloudV2,user=cloud?.getSession?.()?.user?.id,ws=cloud?.getWorkspace?.()?.id;
return user&&ws?'caterium:personal:'+encodeURIComponent(user)+':'+encodeURIComponent(ws)+':'+encodeURIComponent(name):'';
};
const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,''); const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,'');
const jsArg=value=>escapeHTML(JSON.stringify(String(value??'')));
// Existing trial databases and saved offers still reference the PNG originals.
// Resolve only our ten bundled demo assets; never rewrite customer photos.
const demoNames='berry-dessert|bruschetta-tomato|caprese|cheese-fruit|chicken-sandwich|meat-assortment|mushroom-tartlet|salmon-cream|turkey-wrap|vegetables-hummus';
const demoPath=new RegExp('^/(demo/images/(?:'+demoNames+'))\\.png$');
const imageAssetSrc=value=>{
const src=String(value??'').trim();
try{const url=new URL(src,document.baseURI);const match=url.pathname.match(demoPath);if(url.origin===location.origin&&match)return match[1]+'.webp'+url.search+url.hash;}catch(_){}
return src;
};
const safeImageSrc=value=>{ const safeImageSrc=value=>{
const s=String(value??'').trim(); const s=imageAssetSrc(value);
if(!s)return ''; if(!s)return '';
if(/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(s))return s; if(/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(s))return s;
if(/^(?:\.\/|\.\.\/|\/)?[a-z0-9_./-]+\.(?:png|jpe?g|webp|gif)(?:[?#][^\s]*)?$/i.test(s))return s; if(/^(?:\.\/|\.\.\/|\/)?[a-z0-9_./-]+\.(?:png|jpe?g|webp|gif)(?:[?#][^\s]*)?$/i.test(s))return s;
@ -19,14 +34,14 @@
if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node); if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node);
return node; return node;
}; };
window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,safeImageSrc,setText,insertBefore}); window.SunSafe=Object.freeze({escapeHTML,escapeAttr,personalStorageKey,idToken,jsArg,safeImageSrc,imageAssetSrc,setText,insertBefore});
// Small bootstrap for account/profile UI. Keeping it here makes the account // Small bootstrap for account/profile UI. Keeping it here makes the account
// center available on every Caterium screen without touching the legacy monolith. // center available on every Caterium screen without touching the legacy monolith.
if(!document.getElementById('cateriumAccountCenterV1780Script')){ if(!document.getElementById('cateriumAccountCenterV1780Script')){
const script=document.createElement('script'); const script=document.createElement('script');
script.id='cateriumAccountCenterV1780Script'; script.id='cateriumAccountCenterV1780Script';
script.src='core/account-center-v1780.js?v=20260912-v17-8-0-account-center-2'; script.src='core/account-center-v1780.js?v=20260920-employee-session';
script.async=true; script.async=true;
document.head.appendChild(script); document.head.appendChild(script);
} }

View File

@ -0,0 +1,87 @@
/* Human support: explicit submit only. Do not serialize the SDK or application state. */
(()=>{
'use strict';
if(window.CateriumSupportForm)return;
const EMAIL='support@caterium.ru',ENDPOINT=new URL('../api/support.php',document.currentScript.src).href;
const identity=()=>`${window.SunCloudV2?.getSession?.()?.user?.id||''}:${window.SunCloudV2?.getWorkspace?.()?.id||''}`;
let root,form,tab,csrf='',busy=false,controller=null,scope=identity(),generation=0,lastPayload='',requestId='';
const $=id=>root?.querySelector('#'+id);
const uuid=()=>typeof crypto.randomUUID==='function'?crypto.randomUUID():[4,2,2,2,6].map(n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),v=>v.toString(16).padStart(2,'0')).join('')).join('-');
function status(message,error=false){$('ctContactStatus').textContent=message;$('ctContactStatus').setAttribute('role',error?'alert':'status');$('ctContactStatus').classList.toggle('ct-contact-error',error);}
function show(){
if(!root)return;
$('ctHelpGuide').hidden=true;$('ctHelpSupport').hidden=true;$('ctContactSection').hidden=false;
$('ctHelpGuideTab').setAttribute('aria-pressed','false');$('ctHelpSupportTab').setAttribute('aria-pressed','false');tab.setAttribute('aria-pressed','true');
root.querySelector('.ct-help-content').scrollTop=0;
const user=window.SunCloudV2?.getSession?.()?.user;
if(!form.dataset.prefilled){$('ctContactEmail').value=typeof user?.email==='string'?user.email:'';$('ctContactName').value=typeof user?.user_metadata?.name==='string'?user.user_metadata.name:'';form.dataset.prefilled='1';}
$('ctContactName').focus({preventScroll:true});
}
async function request(options={}){
const activeController=new AbortController();controller=activeController;const timer=setTimeout(()=>activeController.abort(),20000);
try{
const response=await fetch(ENDPOINT,{credentials:'same-origin',cache:'no-store',signal:activeController.signal,...options});
let value;try{value=await response.json();}catch(_){throw new Error('Сервер не подтвердил отправку. Текст остаётся в форме.');}
if(!response.ok){if(response.status===403)csrf='';throw new Error(typeof value.message==='string'?value.message:'Не удалось отправить сообщение.');}
return value;
}finally{clearTimeout(timer);if(controller===activeController)controller=null;}
}
async function send(event){
event.preventDefault();if(busy||!form.reportValidity())return;
const ticket=generation,who=identity();busy=true;form.setAttribute('aria-busy','true');
const payload={name:$('ctContactName').value.trim(),email:$('ctContactEmail').value.trim(),topic:$('ctContactTopic').value,subject:$('ctContactSubject').value.trim(),message:$('ctContactMessage').value.trim(),website:$('ctContactWebsite').value};
if($('ctContactDiagnostics').checked){
payload.device=String(navigator.userAgent).slice(0,600)+`; экран ${innerWidth} × ${innerHeight}`;
payload.section=String(document.querySelector('header nav button.on')?.textContent||'Вход').trim().slice(0,100);
payload.release=String(window.SunPerformance?.VERSION||'').slice(0,60);
}
const fingerprint=JSON.stringify(payload);if(fingerprint!==lastPayload||!requestId){lastPayload=fingerprint;requestId=uuid();}
status('Отправляю обращение…');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=true);
try{
if(!csrf){const setup=await request();if(typeof setup.csrf!=='string'||setup.recipient!==EMAIL)throw new Error('Не удалось подготовить защищённую отправку.');csrf=setup.csrf;}
if(ticket!==generation||who!==identity())return;
const answer=await request({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...payload,csrf,request_id:requestId})});
if(ticket!==generation||who!==identity())return;
if(answer.ok!==true||answer.status!=='accepted'||!/^SUP-[0-9]{8}-[A-F0-9]{12}$/.test(answer.id||''))throw new Error('Результат отправки не подтверждён. Текст остаётся в форме.');
status(`Обращение ${answer.id} принято почтовым сервером для отправки на ${EMAIL}. Ответ поддержки придёт на ${payload.email}.`);
$('ctContactMessage').value='';$('ctContactSubject').value='';lastPayload='';requestId='';
}catch(error){
if(ticket===generation&&who===identity())status(error.name==='AbortError'?'Не удалось дождаться подтверждения. Текст сохранён в форме. Повторная отправка этого же сообщения не создаст дубликат в течение 48 часов.':String(error.message||'Не удалось отправить сообщение.'),true);
}finally{
if(ticket===generation){busy=false;form.setAttribute('aria-busy','false');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=false);}
}
}
function attach(dialog){
if(root===dialog)return;
root=dialog;scope=identity();
const style=document.createElement('style');style.textContent=`
#ctHelpDialog .ct-help-modes{flex-wrap:wrap}#ctHelpDialog #ctContactTab{font-weight:700}
#ctHelpDialog .ct-contact-cta{padding:16px;border:1px solid #d1c49d;border-radius:12px;background:#f8f4e7;margin:18px 0}
#ctHelpDialog .ct-contact-cta p{margin:0 0 10px}#ctHelpDialog .ct-contact-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
#ctHelpDialog .ct-contact-wide{grid-column:1/-1}#ctHelpDialog #ctContactForm textarea{font:16px/1.5 Arial,sans-serif;resize:vertical;width:100%;min-height:150px;padding:12px;border:1px solid #cdd5cb;border-radius:10px;background:#fff;color:#263d31}
#ctHelpDialog #ctContactForm textarea:focus-visible{outline:3px solid #b39b47;outline-offset:2px}#ctHelpDialog .ct-contact-check{display:flex;flex-direction:row;align-items:flex-start;font-size:13px;gap:8px}
#ctHelpDialog .ct-contact-check input{width:18px;height:18px;min-height:18px;flex:0 0 18px;margin:2px 0 0}
#ctHelpDialog .ct-contact-note{font-size:12px;color:#617166}#ctHelpDialog #ctContactSend{background:#304f3d;color:#fff;justify-self:start}
#ctHelpDialog [disabled]{cursor:wait;opacity:.65}#ctHelpDialog .ct-contact-error{color:#9e3028}#ctHelpDialog #ctContactStatus:empty{display:none}
#ctHelpDialog .ct-contact-hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}#ctHelpDialog #ctContactStatus{overflow-wrap:anywhere}
@media(max-width:600px){#ctHelpDialog .ct-contact-grid{grid-template-columns:minmax(0,1fr)}#ctHelpDialog #ctContactSend{width:100%}}
`;document.head.append(style);
tab=document.createElement('button');tab.id='ctContactTab';tab.type='button';tab.textContent='Написать в поддержку';tab.setAttribute('aria-pressed','false');root.querySelector('.ct-help-modes').append(tab);tab.onclick=show;
const section=document.createElement('section');section.id='ctContactSection';section.hidden=true;
section.innerHTML=`<h3>Не нашли ответ или возникла проблема?</h3><p>Опишите вопрос — обращение будет направлено на <a href="mailto:${EMAIL}">${EMAIL}</a>. Для ответа укажите свою почту.</p><form id="ctContactForm"><div class="ct-contact-grid"><label>Ваше имя<input id="ctContactName" name="name" autocomplete="name" maxlength="60" required></label><label>Ваш email для ответа<input id="ctContactEmail" name="email" type="email" autocomplete="email" maxlength="254" required></label><label>Что случилось?<select id="ctContactTopic" name="topic"><option value="question">Вопрос по приложению</option><option value="problem">Ошибка или проблема</option><option value="access">Вход и подписка</option><option value="suggestion">Предложение</option><option value="other">Другое</option></select></label><label>Тема обращения<input id="ctContactSubject" name="subject" maxlength="90" required placeholder="Например: не получается открыть PDF"></label><label class="ct-contact-wide">Ваш вопрос или описание проблемы<textarea id="ctContactMessage" name="message" rows="6" maxlength="4000" required placeholder="Что вы хотели сделать? Что произошло? Какие действия уже пробовали?"></textarea></label><label class="ct-contact-check ct-contact-wide"><input id="ctContactDiagnostics" type="checkbox"><span>Добавить сведения об устройстве: браузер, размер экрана, открытый раздел и версия приложения. Данные заказов и клиентов не отправляются.</span></label><div class="ct-contact-hp" aria-hidden="true"><label>Не заполняйте<input id="ctContactWebsite" name="website" tabindex="-1" autocomplete="off"></label></div><p class="ct-contact-note ct-contact-wide">Не указывайте пароли, коды подтверждения, секретные ключи и лишние персональные данные. При нажатии «Отправить» имя, email и сообщение передаются службе поддержки. Форма не открывает почтовое приложение.</p><button id="ctContactSend" class="ct-contact-wide" type="submit">Отправить в поддержку</button></div></form><p id="ctContactStatus" role="status" aria-live="polite"></p><p class="ct-contact-note">Если отправка через форму недоступна, напишите напрямую: <a href="mailto:${EMAIL}">${EMAIL}</a>.</p>`;
root.querySelector('.ct-help-content').append(section);form=$('ctContactForm');form.addEventListener('submit',send);
for(const id of ['ctHelpGuide','ctHelpSupport']){
const cta=document.createElement('div');cta.className='ct-contact-cta';cta.innerHTML='<p><b>Не нашли ответ? Мы поможем разобраться.</b></p><button type="button" data-human-support>Написать в поддержку</button>';
$(id).append(cta);cta.querySelector('button').onclick=show;
}
root.addEventListener('click',event=>{if(event.target.closest('#ctHelpGuideTab,#ctHelpSupportTab')){section.hidden=true;tab.setAttribute('aria-pressed','false');}});
}
function resetIfChanged(){
const next=identity();if(next===scope)return;scope=next;generation++;controller?.abort();csrf='';lastPayload='';requestId='';busy=false;
if(form){form.reset();delete form.dataset.prefilled;form.setAttribute('aria-busy','false');form.querySelectorAll('[disabled]').forEach(e=>e.disabled=false);status('');}
}
window.addEventListener('sun:cloud-permissions-changed',resetIfChanged);
window.addEventListener('sun:cloud-tenant-changing',()=>{scope='';resetIfChanged();});
window.CateriumSupportForm=Object.freeze({attach});
const existing=document.getElementById('ctHelpDialog');if(existing)attach(existing);
})();

View File

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

116
public/core/trial-demo.js Normal file
View File

@ -0,0 +1,116 @@
(()=>{
'use strict';
if(window.CateriumTrialDemo)return;
const KEY='sunTrialDemoV1',BATCH='trial-demo-v1';
const $=id=>document.getElementById(id);
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const money=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:2}).format(Number(x)||0)+' ₽';
const num=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:3}).format(Number(x)||0);
const read=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(key))??fallback}catch(_){return fallback}};
const cloud=()=>window.SunCloudV2;
const data=()=>window.CateriumDataV1773;
const catalog=()=>data()?.catalog.list()||read('sunBoxes',[]);
const installed=()=>read(KEY,{})?.version===1;
const signedIn=()=>Boolean(cloud()?.getSession?.()?.user&&cloud()?.getWorkspace?.()?.id);
const writable=()=>signedIn()&&!cloud()?.isSupportMode?.()&&cloud()?.hasPermission?.('orders.create')&&window.SunSaaSV16?.isWritable?.()!==false;
let status=null,statusWorkspace='',pending=null,epoch=0,busy=false;
function navigate(id){const label={stock:'Склад',shopping:'Закупки'}[id];const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()===label);if(nav)nav.click();else window.show?.(id);}
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
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(){
const host=$('new');if(!host)return;
const clear=()=>{$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();};
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;
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){clear();return;}
// While training is on, the order screen only carries a one-line notice; the guide lives in Settings.
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()});
if(card.dataset.signature===signature)return;card.dataset.signature=signature;
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>
<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>
${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
<details><summary>Технологические карты боксов и блюд</summary><div class="ct-demo-actions"><label>Бокс или блюдо<select id="ctDemoBox">${[...new Set(items.map(b=>Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы'))].map(label=>`<optgroup label="${label}">${items.filter(b=>(Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы')===label).map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</optgroup>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`:
`<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовом меню</strong><p>Боксы и премиум-сеты, 18 банкетных блюд, напитки, посуда, дополнения и доставка. Учебные цены, составы и поставщики для проверки заказа, склада и закупки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`;
// Preserve the user's disclosure and selection across actual data changes.
if(previousGuide.open&&card.querySelector('details'))card.querySelector('details').open=true;
const selector=$('ctDemoBox');if(selector&&[...selector.options].some(o=>o.value===previousGuide.selected))selector.value=previousGuide.selected;
if(previousGuide.message&&$('ctDemoMessage'))$('ctDemoMessage').textContent=previousGuide.message;
card.querySelector('[data-demo-install]')?.addEventListener('click',install);
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
card.querySelectorAll('[data-demo-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.demoGo)));
}
async function install(){try{await window.CateriumTrainingCatalog.setEnabled(true);}catch(error){message(error.message);}}
function createOrder(){
if(!writable()||!installed()||(window.CateriumTrainingCatalog&&!window.CateriumTrainingCatalog.enabled()))return;
const repo=data()?.orders;if(!repo){message('Приложение ещё загружается. Повторите через несколько секунд.');return;}
const all=repo.list(),existing=all.find(o=>o.demoBatch===BATCH);
if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;}
const spec=read(KEY,{}).scenario?.lines,items=catalog();
if(!Array.isArray(spec)||spec.length!==3){message('Сценарий недоступен. Добавьте боксы в заказ самостоятельно.');return;}
const lines=spec.map(l=>{const box=items.find(b=>String(b.id)===String(l.id));return box?{id:box.id,name:box.name,qty:l.qty,price:(window.CateriumPricing?.price(box)??Number(box?.price||0))}:null});
if(lines.some(l=>!l)){message('Некоторые демо-боксы удалены. Добавьте оставшиеся в заказ самостоятельно.');return;}
const total=lines.reduce((s,l)=>s+l.price*l.qty,0),day=new Date();day.setDate(day.getDate()+1);
const date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;
const id=Math.max(0,...all.map(o=>Number(o.id)||0))+1;
repo.replace([...all,{id,event:'Демо: фуршет',date,time:'12:00',contact:'',phone:'',address:'',note:'Учебный заказ для проверки склада и закупки. Автозавершение отключено.',status:'Новый',lines,total,prepayment:0,balance:total,demo:true,demoBatch:BATCH,autoCompletionDisabled:true}],{reason:'trial-demo.order'});
window.renderOrders?.();window.sunRefreshStock?.();
message(`Заказ №${id} на ${money(total)} создан на завтра. Откройте «Закупки», чтобы увидеть нехватку продуктов.`);
}
function showTTK(id){
const box=catalog().find(b=>String(b.id)===String(id));if(!box?.ttk)return;
$('ctDemoTtk')?.remove();const t=box.ttk,dialog=document.createElement('dialog');dialog.id='ctDemoTtk';
const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit]));
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: <b>${money((window.CateriumPricing?.price(box)??Number(box?.price||0)))}</b></p>
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>
${t.waterGrams?`<p class="hint">В выходе учтено ${num(t.waterGrams)} г воды, поглощённой при приготовлении. Вода не списывается со склада.</p>`:''}
<h3>Приготовление и сборка</h3><ol>${t.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p><b>Аллергены:</b> ${t.allergens.length?t.allergens.map(esc).join(', '):'в учебном составе не указаны; уточните по продуктам поставщика'}.</p><p class="hint">${esc(t.note)}</p>
<div class="ct-demo-actions"><button class="outline" data-ttk-edit>Изменить состав и цену</button><button class="primary" data-ttk-close>Закрыть</button></div>`;
document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close());
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
}
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctTrainingNotice')?.remove();$('ctDemoTtk')?.remove();}
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}}`;
document.head.append(style);
window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});
window.addEventListener('sun:cloud-state-applied',()=>{render();refreshStatus()});
window.addEventListener('sun:subscription-changed',()=>{statusWorkspace='';render();refreshStatus()});
document.addEventListener('click',e=>{if(e.target.closest('header nav button')){render();refreshStatus()}});
data()?.subscribe('catalog',render);setTimeout(()=>{render();refreshStatus()},1400);
}
window.CateriumTrialDemo={render,refreshStatus,showTTK};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();

View File

@ -1,21 +1,76 @@
/* Developer invitations, using the existing server-enforced AAL2 RPCs. */
(()=>{ (()=>{
'use strict'; 'use strict';
if(window.CateriumTrialPromoDeveloperV181)return; if(window.CateriumTrialPromoDeveloperV181)return;
const VERSION='18.1-trial-promo-developer'; const VERSION='20260921-promo-entry',TAB='sunDeveloperActiveTabV22';
const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)]; const $=id=>document.getElementById(id),cloud=()=>window.SunCloudV2;
const client=()=>window.SunCloudV2?.getClient?.()||null; const plans={basic:'Базовый',professional:'Профессиональный',full:'Полный'};
const dev=()=>window.SunDeveloperV22||null; const esc=v=>window.SunSafe.escapeHTML(String(v??'')),fmt=v=>v?new Date(v).toLocaleString('ru-RU'):'—';
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); let view=null,frame=0;
const toast=(t,type='info')=>window.SunEnterprise?.toast?.(t,type,5000); const identity=()=>JSON.stringify([cloud()?.getSession?.()?.user?.id,cloud()?.getWorkspace?.()?.id]);
async function rpc(name,args={}){const c=client();if(!c)throw new Error('Supabase не подключён.');const {data,error}=await c.rpc(name,args);if(error)throw error;return data;} const allowed=()=>Boolean(cloud()?.getSession?.()?.user&&window.SunDeveloperV22?.isPlatformAdmin?.()&&!cloud()?.isSupportMode?.()&&!document.body.classList.contains('sun-cloud-auth-required'));
function fmt(v){if(!v)return'—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}} const selected=()=>allowed()&&$('sun-developer-console-v22')?.classList.contains('on')&&localStorage.getItem(TAB)==='promos';
function ensureStyle(){if($('cateriumPromoDevStyle'))return;const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`.ctm-promo-tools{display:grid;grid-template-columns:1.2fr 1.2fr .65fr .65fr .65fr auto;gap:8px;align-items:end;margin-bottom:14px}.ctm-promo-tools label{display:grid;gap:5px;font-size:11px;font-weight:800;color:#686e76}.ctm-promo-tools input,.ctm-promo-tools select{min-height:39px;border:1px solid #d8dbe0;border-radius:9px;padding:7px 9px;background:#fff}.ctm-promo-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:900;letter-spacing:.04em}.ctm-promo-copy{cursor:pointer}.ctm-promo-note{margin:0 0 12px;color:#747b83;font-size:12px}@media(max-width:1000px){.ctm-promo-tools{grid-template-columns:1fr 1fr}.ctm-promo-tools button{grid-column:1/-1}}`;document.head.appendChild(s);} const current=v=>view===v&&v.identity===identity()&&selected();
function addTab(){const root=$('sun-developer-console-v22');if(!root)return false;const tabs=root.querySelector('[data-dev-tab]')?.parentElement;if(!tabs)return false;if(!tabs.querySelector('[data-dev-tab="promos"]')){const b=document.createElement('button');b.type='button';b.dataset.devTab='promos';b.textContent='Промокоды';tabs.appendChild(b);}return true;} async function rpc(name,args={}){const c=cloud()?.getClient?.();if(!c||!allowed())throw new Error('Войдите в кабинет разработчика и подтвердите 2FA.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data;}
async function render(){const root=$('sunDevBody');if(!root)return;qa('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos'));try{localStorage.setItem('sunDeveloperActiveTabV22','promos')}catch(_){}root.innerHTML='<div class="sun-dev-empty">Загрузка промокодов…</div>'; const message=e=>['PGRST202','42883'].includes(e?.code)?'Серверный модуль промокодов недоступен.':String(e?.message||e||'Не удалось выполнить операцию.');
try{const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300})||[];root.innerHTML=`<div class="sun-dev-card"><h2 style="margin-top:0">Промокоды пробной версии</h2><p class="ctm-promo-note">Сгенерируйте код автоматически или напишите свой. Email можно оставить пустым для универсального кода. По умолчанию код одноразовый.</p><div class="ctm-promo-tools"><label>Промокод<input id="ctmPromoCode" maxlength="32" placeholder="Оставьте пустым для генерации"></label><label>Email клиента<input id="ctmPromoEmail" type="email" placeholder="client@example.com"></label><label>Trial, дней<input id="ctmPromoTrial" type="number" min="1" max="365" value="14"></label><label>Код действует, дней<input id="ctmPromoValid" type="number" min="1" max="365" value="7"></label><label>Активаций<input id="ctmPromoUses" type="number" min="1" max="10000" value="1"></label><button class="primary" id="ctmPromoCreate" type="button">Создать промокод</button></div><div id="ctmPromoCreated"></div></div><div class="sun-dev-table-wrap" style="margin-top:12px"><table class="sun-dev-table"><thead><tr><th>Код</th><th>Клиент</th><th>Trial</th><th>Действует до</th><th>Использования</th><th>Последняя активация</th><th>Статус</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr><td><button class="outline ctm-promo-code ctm-promo-copy" data-copy="${esc(r.code)}" title="Скопировать">${esc(r.code)}</button></td><td>${esc(r.client_email||'Любой email')}</td><td>${Number(r.trial_days||0)} дн.</td><td>${fmt(r.valid_until)}</td><td>${Number(r.use_count||0)} / ${Number(r.max_uses||0)}</td><td>${r.last_redeemed_at?`${fmt(r.last_redeemed_at)}<div class="sun-dev-muted">${esc(r.last_workspace_name||'')} ${esc(r.last_redeemed_email||'')}</div>`:'—'}</td><td><span class="sun-dev-pill ${r.is_active?'ok':'bad'}">${r.is_active?'Активен':'Отключён'}</span></td><td><button class="outline" data-toggle="${esc(r.promo_id)}" data-active="${r.is_active?'1':'0'}">${r.is_active?'Отключить':'Включить'}</button></td></tr>`).join('')||'<tr><td colspan="8" class="sun-dev-empty">Промокодов пока нет.</td></tr>'}</tbody></table></div>`; function styles(){
$('ctmPromoCode').oninput=e=>e.target.value=e.target.value.toUpperCase().replace(/\s+/g,'');$('ctmPromoCreate').onclick=create;qa('[data-copy]',root).forEach(b=>b.onclick=async()=>{try{await navigator.clipboard.writeText(b.dataset.copy);toast('Промокод скопирован.','success')}catch(_){toast('Не удалось скопировать код.','error')}});qa('[data-toggle]',root).forEach(b=>b.onclick=async()=>{b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:b.dataset.toggle,p_active:b.dataset.active!=='1'});toast('Статус промокода изменён.','success');await render()}catch(e){toast(e?.message||String(e),'error');b.disabled=false}}); if($('cateriumPromoDevStyle'))return;
}catch(e){root.innerHTML=`<div class="sun-dev-card"><h3>Не удалось загрузить промокоды</h3><p>${esc(e?.message||e)}</p></div>`;}} const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`
async function create(){const btn=$('ctmPromoCreate');btn.disabled=true;try{const data=await rpc('sun_dev_create_trial_promo',{p_code:String($('ctmPromoCode')?.value||'').trim()||null,p_email:String($('ctmPromoEmail')?.value||'').trim()||null,p_trial_days:Number($('ctmPromoTrial')?.value||14),p_valid_days:Number($('ctmPromoValid')?.value||7),p_max_uses:Number($('ctmPromoUses')?.value||1),p_plan:'full',p_note:null});const code=data?.code||'';try{await navigator.clipboard.writeText(code)}catch(_){}toast(`Промокод ${code} создан${code?' и скопирован':''}.`,'success');await render()}catch(e){toast(e?.message||String(e),'error')}finally{if(btn?.isConnected)btn.disabled=false}} #ctmPromoOpen{min-height:44px}#ctmPromoPanel{min-width:0}.ctm-promo-tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;align-items:end}.ctm-promo-tools label{display:grid;gap:6px;min-width:0;font-size:12px;font-weight:700}.ctm-promo-tools input,.ctm-promo-tools select{box-sizing:border-box;width:100%;min-width:0;min-height:44px;padding:9px;border:1px solid #d8dbe0;border-radius:9px;font:inherit;background:#fff}.ctm-promo-wide{grid-column:1/-1}.ctm-promo-note{font-size:13px;line-height:1.5;color:#697078}.ctm-promo-result{padding:14px;border:1px solid #d5bb77;border-radius:12px;background:#fffaec;margin-top:14px}.ctm-promo-result textarea{box-sizing:border-box;width:100%;min-height:175px;margin:10px 0;padding:10px;resize:vertical;font:13px/1.5 Arial,sans-serif}.ctm-promo-actions{display:flex;flex-wrap:wrap;gap:8px}.ctm-promo-actions button{min-height:44px}.ctm-promo-code{font-family:ui-monospace,monospace;overflow-wrap:anywhere}#ctmPromoError,#ctmPromoListError{color:#a23426}#ctmPromoError:empty,#ctmPromoListError:empty{display:none}.ctm-promo-list{display:grid;gap:10px;margin-top:15px}.ctm-promo-list article{padding:12px;border:1px solid #e0e0dc;border-radius:10px;min-width:0}.ctm-promo-list p{font-size:12px;line-height:1.5;margin:6px 0}
function bind(){ensureStyle();if(!addTab())return;const root=$('sun-developer-console-v22');if(root&&!root.dataset.promoV181){root.dataset.promoV181='1';root.addEventListener('click',e=>{const b=e.target.closest('[data-dev-tab="promos"]');if(!b)return;e.preventDefault();e.stopImmediatePropagation();render();},true);}const d=dev();if(d&&!d.__promoV181&&typeof d.open==='function'){const original=d.open.bind(d);d.open=async source=>{const r=await original(source);addTab();if(localStorage.getItem('sunDeveloperActiveTabV22')==='promos')await render();return r};Object.defineProperty(d,'__promoV181',{value:true,configurable:true});}} @media(max-width:600px){.ctm-promo-tools{grid-template-columns:minmax(0,1fr)}#ctmPromoOpen{width:100%}.ctm-promo-actions button{flex:1}}
const obs=new MutationObserver(bind);obs.observe(document.documentElement,{childList:true,subtree:true});bind();setInterval(()=>{if(!document.hidden)bind()},3000);window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind}); `;document.head.append(s);
}
function invitation(data,args){return `Приглашение в Caterium\nРегистрация: https://app.caterium.ru/\nНа экране входа выберите регистрацию и введите промокод: ${data.code}\nТариф: ${plans[args.p_plan]}\nСрок подписки: ${args.p_trial_days} дн. с активации после подтверждения email\nАктивировать приглашение до: ${fmt(data.valid_until)}${args.p_email?'\nДля email: '+args.p_email:''}`;}
async function copy(text,status){try{await navigator.clipboard.writeText(text);if(status.isConnected)status.textContent='Скопировано.';}catch(_){if(status.isConnected)status.textContent='Выделите и скопируйте текст вручную: автоматическое копирование недоступно.';}}
async function loadList(v){
const serial=++v.listRequest,list=v.node.querySelector('#ctmPromoList'),error=v.node.querySelector('#ctmPromoListError');error.textContent='';list.setAttribute('aria-busy','true');
try{
const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300});if(!current(v)||serial!==v.listRequest)return;
if(!Array.isArray(rows))throw new Error('Сервер вернул неполный список кодов.');
list.innerHTML=rows.map((r,i)=>`<article><b class="ctm-promo-code">${esc(r.code)}</b><p>${esc(r.client_email||'Любой email')} · Подписка: ${Number(r.trial_days)} дн.</p><p>Активировать до: ${esc(fmt(r.valid_until))} · Использовано: ${Number(r.use_count)} / ${Number(r.max_uses)} · ${r.is_active?'Включён':'Отключён'}</p><div class="ctm-promo-actions"><button type="button" class="outline" data-copy-code="${i}">Скопировать код</button><button type="button" class="outline" data-toggle-code="${i}">${r.is_active?'Отключить код':'Включить код'}</button></div></article>`).join('')||'<p>Промокодов пока нет. Создайте первый выше.</p>';
list.querySelectorAll('[data-copy-code]').forEach(b=>b.onclick=()=>copy(rows[Number(b.dataset.copyCode)].code,v.node.querySelector('#ctmPromoCopyStatus')));
list.querySelectorAll('[data-toggle-code]').forEach(b=>b.onclick=async()=>{const row=rows[Number(b.dataset.toggleCode)];if(!current(v))return;b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:row.promo_id,p_active:!row.is_active});if(current(v))await loadList(v);}catch(e){if(current(v))error.textContent=message(e);}finally{b.disabled=false;}});
}catch(e){if(current(v)&&serial===v.listRequest)error.textContent=message(e);}finally{if(serial===v.listRequest)list.removeAttribute('aria-busy');}
}
async function create(v){
if(!current(v)||v.creating)return;
const form=v.node.querySelector('form');if(!form.reportValidity())return;
const q=id=>v.node.querySelector('#'+id);
const args={p_code:q('ctmPromoCode').value.trim().toUpperCase()||null,p_email:q('ctmPromoEmail').value.trim()||null,p_plan:q('ctmPromoPlan').value,p_trial_days:Number(q('ctmPromoTrial').value),p_valid_days:Number(q('ctmPromoValid').value),p_max_uses:Number(q('ctmPromoUses').value),p_note:null};
if(!plans[args.p_plan])return;
const button=q('ctmPromoCreate'),error=q('ctmPromoError');v.creating=true;button.disabled=true;error.textContent='';
try{
const data=await rpc('sun_dev_create_trial_promo',args);if(!current(v))return;
if(!data?.code)throw new Error('Сервер не вернул созданный код. Проверьте список перед повторной попыткой.');
q('ctmPromoCreated').innerHTML='<section class="ctm-promo-result"><b>Промокод создан — отправьте приглашение получателю</b><textarea id="ctmInvitationText" readonly aria-label="Текст приглашения"></textarea><div class="ctm-promo-actions"><button type="button" class="primary" id="ctmInvitationCopy">Скопировать приглашение</button><button type="button" class="outline" id="ctmCodeCopy">Скопировать код</button></div></section>';
const text=invitation(data,args);q('ctmInvitationText').value=text;q('ctmInvitationCopy').onclick=()=>copy(text,q('ctmPromoCopyStatus'));q('ctmCodeCopy').onclick=()=>copy(data.code,q('ctmPromoCopyStatus'));void loadList(v);
}catch(e){if(current(v))error.textContent=message(e);}finally{v.creating=false;button.disabled=false;}
}
function render(){
if(!allowed()||!$('sun-developer-console-v22')?.classList.contains('on'))return;
localStorage.setItem(TAB,'promos');document.querySelectorAll('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos'));
if(view?.identity===identity()){$('sunDevBody')?.replaceChildren(view.node);return;}
const node=document.createElement('section');node.id='ctmPromoPanel';node.className='sun-dev-card';
node.innerHTML=`<h2>Промокоды для регистрации и подписки</h2><p class="ctm-promo-note">Выберите тариф и срок доступа. Промокод можно сгенерировать автоматически или задать вручную. Срок подписки начинается при активации после подтверждения email, а срок действия кода — при его создании.</p><form class="ctm-promo-tools"><label>Тариф<select id="ctmPromoPlan"><option value="basic">Базовый</option><option value="professional">Профессиональный</option><option value="full" selected>Полный</option></select></label><label>Подписка, дней<input id="ctmPromoTrial" type="number" min="1" max="365" step="1" required value="14"></label><label>Код можно активировать в течение, дней<input id="ctmPromoValid" type="number" min="1" max="365" step="1" required value="7"></label><label>Количество активаций<input id="ctmPromoUses" type="number" min="1" max="10000" step="1" required value="1"></label><label>Email получателя — необязательно<input id="ctmPromoEmail" type="email" maxlength="254" placeholder="client@example.com"></label><label>Свой код — необязательно<input id="ctmPromoCode" maxlength="32" pattern="[A-Za-z0-9\\-]{3,32}" placeholder="Оставьте пустым для генерации"></label><button id="ctmPromoCreate" type="submit" class="primary ctm-promo-wide">Создать промокод</button></form><p id="ctmPromoError" role="alert"></p><div id="ctmPromoCreated"></div><p id="ctmPromoCopyStatus" class="ctm-promo-note" role="status"></p><h3>Созданные промокоды</h3><button id="ctmPromoReload" type="button" class="outline">Обновить список</button><p id="ctmPromoListError" role="alert"></p><div id="ctmPromoList" class="ctm-promo-list"></div>`;
const v={node,identity:identity(),creating:false,listRequest:0};view=v;node.querySelector('form').addEventListener('submit',e=>{e.preventDefault();void create(v);});node.querySelector('#ctmPromoReload').onclick=()=>loadList(v);$('sunDevBody')?.replaceChildren(node);void loadList(v);
}
function bind(){
styles();const root=$('sun-developer-console-v22');if(!root||!allowed())return;
const tabs=root.querySelector('.sun-dev-tabs');if(!tabs)return;
let tab=tabs.querySelector('[data-dev-tab="promos"]');if(!tab){tab=document.createElement('button');tab.type='button';tab.dataset.devTab='promos';tab.textContent='Промокоды';}
const overview=tabs.querySelector('[data-dev-tab="overview"]');if(overview&&overview.nextElementSibling!==tab)overview.after(tab);else if(!tab.parentElement)tabs.prepend(tab);
const actions=root.querySelector('.sun-dev-head-actions');if(actions&&!$('ctmPromoOpen')){const button=document.createElement('button');button.type='button';button.id='ctmPromoOpen';button.className='primary';button.textContent='Создать промокод';actions.prepend(button);}
// A slow response from the previous tab must not replace a live promo form.
if(view&&current(view)&&!view.node.isConnected)$('sunDevBody')?.replaceChildren(view.node);
}
function schedule(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;bind();});}
function reset(){view?.node.remove();view=null;$('ctmPromoOpen')?.remove();document.querySelector('[data-dev-tab="promos"]')?.remove();}
document.addEventListener('click',e=>{
if(e.target.closest?.('#ctmPromoOpen,#sun-developer-console-v22 [data-dev-tab="promos"]')){e.preventDefault();e.stopImmediatePropagation();render();}
else if(e.target.closest?.('#sunDevRefresh')&&selected()){e.preventDefault();e.stopImmediatePropagation();if(view)void loadList(view);}
},true);
window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{if(!allowed()||view?.identity!==identity())reset();schedule();});
new MutationObserver(schedule).observe(document.documentElement,{childList:true,subtree:true});bind();
window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind});
})(); })();

View File

@ -76,7 +76,7 @@
if(!canAutoWrite())return {changed:0,ids:[]}; if(!canAutoWrite())return {changed:0,ids:[]};
const list=readOrders(),ids=[]; const list=readOrders(),ids=[];
for(const order of list){ for(const order of list){
if(!order||String(order.status||'').trim()==='Отменён'||isAutoCompleted(order))continue; if(!order||order.autoCompletionDisabled===true||String(order.status||'').trim()==='Отменён'||isAutoCompleted(order))continue;
const due=dueAt(order);if(!Number.isFinite(due)||nowMs<due)continue; const due=dueAt(order);if(!Number.isFinite(due)||nowMs<due)continue;
markOrderCompleted(order,nowMs);ids.push(String(order.id)); markOrderCompleted(order,nowMs);ids.push(String(order.id));
} }
@ -130,7 +130,7 @@
function templateList(){try{return window.SunOfferTemplate?.list?.()||[]}catch(_){return[]}} function templateList(){try{return window.SunOfferTemplate?.list?.()||[]}catch(_){return[]}}
function validTemplate(id){return TEMPLATE_IDS.has(String(id||''))} function validTemplate(id){return TEMPLATE_IDS.has(String(id||''))}
function storedTemplateId(order){ function storedTemplateId(order){
const id=order?.clientOfferSnapshot?.offerTemplateId||order?.clientOfferTemplateId||'';return validTemplate(id)?String(id):''; const id=order?.clientOfferSnapshot?.offerTemplateId||order?.clientOfferTemplateId||'';return validTemplate(id)?(window.CateriumProposalPDF?.resolveTemplate(String(id))||String(id)):'';
} }
function globalTemplateId(){ function globalTemplateId(){
try{const state=originalOfferGet?originalOfferGet():window.SunOfferTemplate?.get?.();return validTemplate(state?.id)?state.id:'light'}catch(_){return'light'} try{const state=originalOfferGet?originalOfferGet():window.SunOfferTemplate?.get?.();return validTemplate(state?.id)?state.id:'light'}catch(_){return'light'}
@ -161,13 +161,14 @@
const id=globalTemplateId();persistOfferTemplate(activeOfferOrderId,id);return id; const id=globalTemplateId();persistOfferTemplate(activeOfferOrderId,id);return id;
} }
function offerTemplateMini(id){ function offerTemplateMini(id){
if(window.CateriumProposalPDF?.IDS.includes(id))return `<img src="offer-templates/quality-${id}.jpg?v=20260918-six" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#eeece5;z-index:2">`;
if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>'; if(id==='editorial-grid')return '<i class="mini-head"></i><i class="mini-editorial-hero"></i><i class="mini-editorial-stack a"></i><i class="mini-editorial-stack b"></i><i class="mini-editorial-grid"></i>';
if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>'; if(id==='midnight-glass')return '<i class="mini-head"></i><i class="mini-midnight-hero"></i><i class="mini-midnight-total"></i><i class="mini-midnight-left"></i><i class="mini-midnight-right"></i>';
if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>'; if(id==='emerald-gold')return '<i class="mini-head"></i><i class="mini-emerald-hero"></i><i class="mini-emerald-gold"></i><i class="mini-emerald-info"></i><i class="mini-emerald-menu"></i>';
return '<i class="mini-head"></i><i class="mini-light-hero"></i><i class="mini-light-row a"></i><i class="mini-light-row b"></i><i class="mini-light-row c"></i><i class="mini-light-total"></i>'; return '<i class="mini-head"></i><i class="mini-light-hero"></i><i class="mini-light-row a"></i><i class="mini-light-row b"></i><i class="mini-light-row c"></i><i class="mini-light-total"></i>';
} }
function pickerHtml(){ function pickerHtml(){
const current=activeTemplateId();return `<div class="sun-v1764-offer-template-head"><div><b>Оформление PDF</b><small>Классические и архивные шаблоны. Предварительный просмотр совпадает со скачиваемым PDF.</small></div></div><div class="sun-v1764-offer-template-grid">${templateList().map(t=>`<button type="button" data-v1764-offer-template="${esc(t.id)}" class="${t.id===current?'on':''}"><div class="sun-offer-template-mini sun-offer-template-mini-${esc(t.id)}" data-template-mini="${esc(t.id)}" aria-hidden="true">${offerTemplateMini(t.id)}</div><span>${esc(t.name||t.id)}</span></button>`).join('')}</div>`; const current=activeTemplateId();return `<div class="sun-v1764-offer-template-head"><div><b>Оформление PDF</b><small>Шесть макетов для разных событий. Ваш логотип сохраняет пропорции. Просмотр совпадает со скачиваемым PDF.</small></div></div><div class="sun-v1764-offer-template-grid">${templateList().map(t=>`<button type="button" data-v1764-offer-template="${esc(t.id)}" class="${t.id===current?'on':''}"><div class="sun-offer-template-mini sun-offer-template-mini-${esc(t.id)}" data-template-mini="${esc(t.id)}" aria-hidden="true">${offerTemplateMini(t.id)}</div><span>${esc(t.name||t.id)}</span></button>`).join('')}</div>`;
} }
function ensureOfferPicker(){ function ensureOfferPicker(){
patchOfferTemplateApi();const modal=$('sunClientOfferModal'),dialog=modal?.querySelector('.dialog');if(!dialog||!activeOfferOrderId)return false; patchOfferTemplateApi();const modal=$('sunClientOfferModal'),dialog=modal?.querySelector('.dialog');if(!dialog||!activeOfferOrderId)return false;
@ -175,7 +176,18 @@
if(!box){box=document.createElement('section');box.id='sunOfferTemplateV1764';box.className='sun-v1764-offer-template';const head=dialog.querySelector('.sun-offer-modal-head');if(head)head.insertAdjacentElement('afterend',box);else dialog.prepend(box);box.addEventListener('click',e=>{const b=e.target.closest('[data-v1764-offer-template]');if(!b)return;const id=b.dataset.v1764OfferTemplate;if(!persistOfferTemplate(activeOfferOrderId,id))return;renderOfferPicker();toast(`Оформление «${templateList().find(x=>x.id===id)?.name||id}» сохранено для этого предложения.`,'success',3200)});} if(!box){box=document.createElement('section');box.id='sunOfferTemplateV1764';box.className='sun-v1764-offer-template';const head=dialog.querySelector('.sun-offer-modal-head');if(head)head.insertAdjacentElement('afterend',box);else dialog.prepend(box);box.addEventListener('click',e=>{const b=e.target.closest('[data-v1764-offer-template]');if(!b)return;const id=b.dataset.v1764OfferTemplate;if(!persistOfferTemplate(activeOfferOrderId,id))return;renderOfferPicker();toast(`Оформление «${templateList().find(x=>x.id===id)?.name||id}» сохранено для этого предложения.`,'success',3200)});}
renderOfferPicker();return true; renderOfferPicker();return true;
} }
function renderOfferPicker(){const box=$('sunOfferTemplateV1764');if(box)box.innerHTML=pickerHtml()} function offerPickerSignature(){
const list=templateList(),current=activeTemplateId();
return JSON.stringify({current,templates:list.map(t=>[String(t?.id||''),String(t?.name||'')])});
}
function renderOfferPicker({force=false}={}){
const box=$('sunOfferTemplateV1764');if(!box)return false;
const signature=offerPickerSignature();
if(!force&&box.dataset.sunV1764Signature===signature)return false;
box.innerHTML=pickerHtml();
box.dataset.sunV1764Signature=signature;
return true;
}
function setActiveOffer(id){if(id==null||id==='')return;activeOfferOrderId=String(id);setTimeout(ensureOfferPicker,0)} function setActiveOffer(id){if(id==null||id==='')return;activeOfferOrderId=String(id);setTimeout(ensureOfferPicker,0)}
function clearActiveOffer(){activeOfferOrderId=null} function clearActiveOffer(){activeOfferOrderId=null}
function patchOfferOpenExports(){ function patchOfferOpenExports(){

1873
public/demo/banquet-v1.json Normal file

File diff suppressed because it is too large Load Diff

1800
public/demo/catalog-v1.json Normal file

File diff suppressed because it is too large Load Diff

983
public/demo/extras-v1.json Normal file
View File

@ -0,0 +1,983 @@
{
"version": 1,
"items": [
{
"id": "demo-extras-v1-salmon-caprese",
"name": "Премиум-сет «Лосось и капрезе»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 3500,
"pieces": 18,
"weight": "710 г",
"photo": "demo/images/extras-salmon-caprese.webp",
"demo": true,
"composition": [
"Половина стандартного бокса: Канапе с лососем и сливочным сыром",
"Половина стандартного бокса: Канапе «Капрезе»"
],
"ingredients": [
[
"Хлеб ржаной",
0.12,
"кг"
],
[
"Лосось слабосолёный",
0.12,
"кг"
],
[
"Сыр сливочный",
0.08,
"кг"
],
[
"Огурцы",
0.042,
"кг"
],
[
"Укроп",
0.005,
"кг"
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Моцарелла мини",
0.18,
"кг"
],
[
"Томаты черри",
0.154,
"кг"
],
[
"Базилик",
0.011,
"кг"
],
[
"Масло оливковое",
0.02,
"кг"
],
[
"Шпажка бамбуковая",
10,
"шт."
]
],
"ttk": {
"number": "ДЕМО-П1",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 710,
"pieces": 18,
"rows": [
{
"productId": "demo-v1-stock-rye",
"name": "Хлеб ржаной",
"unit": "кг",
"gross": 0.12,
"net": 0.12,
"unitCost": 220
},
{
"productId": "demo-v1-stock-salmon",
"name": "Лосось слабосолёный",
"unit": "кг",
"gross": 0.12,
"net": 0.12,
"unitCost": 2400
},
{
"productId": "demo-v1-stock-cream",
"name": "Сыр сливочный",
"unit": "кг",
"gross": 0.08,
"net": 0.08,
"unitCost": 850
},
{
"productId": "demo-v1-stock-cucumber",
"name": "Огурцы",
"unit": "кг",
"gross": 0.042,
"net": 0.036,
"unitCost": 220
},
{
"productId": "demo-v1-stock-dill",
"name": "Укроп",
"unit": "кг",
"gross": 0.005,
"net": 0.004,
"unitCost": 700
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-mozzarella",
"name": "Моцарелла мини",
"unit": "кг",
"gross": 0.18,
"net": 0.18,
"unitCost": 950
},
{
"productId": "demo-v1-stock-cherry",
"name": "Томаты черри",
"unit": "кг",
"gross": 0.154,
"net": 0.14,
"unitCost": 450
},
{
"productId": "demo-v1-stock-basil",
"name": "Базилик",
"unit": "кг",
"gross": 0.011,
"net": 0.01,
"unitCost": 1500
},
{
"productId": "demo-v1-stock-oil",
"name": "Масло оливковое",
"unit": "кг",
"gross": 0.02,
"net": 0.02,
"unitCost": 1100
},
{
"productId": "demo-v1-stock-skewer",
"name": "Шпажка бамбуковая",
"unit": "шт.",
"gross": 10,
"net": 10,
"unitCost": 2
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Канапе с лососем и сливочным сыром: Вырезать из ржаного хлеба 16 основ.",
"Канапе с лососем и сливочным сыром: Нанести сливочный сыр, добавить ломтики огурца и лосося.",
"Канапе с лососем и сливочным сыром: Украсить укропом и уложить в коробку.",
"Канапе «Капрезе»: Подготовить черри и базилик, обсушить моцареллу.",
"Канапе «Капрезе»: Собрать 20 шпажек, распределив ингредиенты поровну.",
"Канапе «Капрезе»: Добавить оливковое масло, уложить в коробку.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Рыба",
"Молоко",
"Глютен"
],
"ingredientCost": 758.94,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-meat-cheese",
"name": "Премиум-сет «Мясо и сыры»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 3900,
"pieces": 0,
"weight": "750 г",
"photo": "demo/images/extras-meat-cheese.webp",
"demo": true,
"composition": [
"Половина стандартного бокса: Мясное ассорти с корнишонами",
"Половина стандартного бокса: Сырный бокс с виноградом и орехами"
],
"ingredients": [
[
"Салями",
0.09,
"кг"
],
[
"Ростбиф готовый",
0.11,
"кг"
],
[
"Ветчина из индейки",
0.075,
"кг"
],
[
"Корнишоны",
0.045,
"кг"
],
[
"Маслины без косточек",
0.023,
"кг"
],
[
"Горчица",
0.015,
"кг"
],
[
"Соусник",
1,
"шт."
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Сыр бри",
0.1,
"кг"
],
[
"Сыр гауда",
0.09,
"кг"
],
[
"Сыр с голубой плесенью",
0.06,
"кг"
],
[
"Виноград",
0.088,
"кг"
],
[
"Орех грецкий очищенный",
0.025,
"кг"
],
[
"Мёд",
0.02,
"кг"
],
[
"Крекер",
0.025,
"кг"
]
],
"ttk": {
"number": "ДЕМО-П2",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 750,
"pieces": 0,
"rows": [
{
"productId": "demo-v1-stock-salami",
"name": "Салями",
"unit": "кг",
"gross": 0.09,
"net": 0.09,
"unitCost": 1400
},
{
"productId": "demo-v1-stock-beef",
"name": "Ростбиф готовый",
"unit": "кг",
"gross": 0.11,
"net": 0.11,
"unitCost": 2000
},
{
"productId": "demo-v1-stock-turkey",
"name": "Ветчина из индейки",
"unit": "кг",
"gross": 0.075,
"net": 0.075,
"unitCost": 1000
},
{
"productId": "demo-v1-stock-pickle",
"name": "Корнишоны",
"unit": "кг",
"gross": 0.045,
"net": 0.04,
"unitCost": 400
},
{
"productId": "demo-v1-stock-olive",
"name": "Маслины без косточек",
"unit": "кг",
"gross": 0.023,
"net": 0.02,
"unitCost": 650
},
{
"productId": "demo-v1-stock-mustard",
"name": "Горчица",
"unit": "кг",
"gross": 0.015,
"net": 0.015,
"unitCost": 350
},
{
"productId": "demo-v1-stock-sauce-cup",
"name": "Соусник",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 7
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-brie",
"name": "Сыр бри",
"unit": "кг",
"gross": 0.1,
"net": 0.1,
"unitCost": 1600
},
{
"productId": "demo-v1-stock-gouda",
"name": "Сыр гауда",
"unit": "кг",
"gross": 0.09,
"net": 0.09,
"unitCost": 900
},
{
"productId": "demo-v1-stock-blue",
"name": "Сыр с голубой плесенью",
"unit": "кг",
"gross": 0.06,
"net": 0.06,
"unitCost": 1700
},
{
"productId": "demo-v1-stock-grape",
"name": "Виноград",
"unit": "кг",
"gross": 0.088,
"net": 0.08,
"unitCost": 420
},
{
"productId": "demo-v1-stock-walnut",
"name": "Орех грецкий очищенный",
"unit": "кг",
"gross": 0.025,
"net": 0.025,
"unitCost": 1000
},
{
"productId": "demo-v1-stock-honey",
"name": "Мёд",
"unit": "кг",
"gross": 0.02,
"net": 0.02,
"unitCost": 800
},
{
"productId": "demo-v1-stock-cracker",
"name": "Крекер",
"unit": "кг",
"gross": 0.025,
"net": 0.025,
"unitCost": 500
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Мясное ассорти с корнишонами: Нарезать готовые мясные продукты тонкими ломтиками.",
"Мясное ассорти с корнишонами: Обсушить корнишоны и маслины, переложить горчицу в соусник.",
"Мясное ассорти с корнишонами: Разложить ассорти секциями, добавить гарниры.",
"Сырный бокс с виноградом и орехами: Нарезать сыры порционными кусочками.",
"Сырный бокс с виноградом и орехами: Подготовить виноград, переложить мёд в соусник.",
"Сырный бокс с виноградом и орехами: Разложить сыры, виноград, орехи и крекер отдельными секциями.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Горчица",
"Молоко",
"Орехи",
"Глютен"
],
"ingredientCost": 964.66,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-mini-buffet",
"name": "Премиум-сет «Мини-фуршет»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 2900,
"pieces": 22,
"weight": "820 г",
"photo": "demo/images/extras-mini-buffet.webp",
"demo": true,
"composition": [
"Половина стандартного бокса: Брускетты с томатами и базиликом",
"Половина стандартного бокса: Тарталетки с грибами и сыром"
],
"ingredients": [
[
"Багет",
0.24,
"кг"
],
[
"Томаты",
0.165,
"кг"
],
[
"Базилик",
0.013,
"кг"
],
[
"Масло оливковое",
0.028,
"кг"
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Тарталетки готовые",
0.1,
"кг"
],
[
"Шампиньоны",
0.22,
"кг"
],
[
"Сливки",
0.05,
"кг"
],
[
"Сыр гауда",
0.06,
"кг"
],
[
"Лук репчатый",
0.044,
"кг"
]
],
"ttk": {
"number": "ДЕМО-П3",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 820,
"pieces": 22,
"rows": [
{
"productId": "demo-v1-stock-baguette",
"name": "Багет",
"unit": "кг",
"gross": 0.24,
"net": 0.24,
"unitCost": 240
},
{
"productId": "demo-v1-stock-tomato",
"name": "Томаты",
"unit": "кг",
"gross": 0.165,
"net": 0.15,
"unitCost": 260
},
{
"productId": "demo-v1-stock-basil",
"name": "Базилик",
"unit": "кг",
"gross": 0.013,
"net": 0.012,
"unitCost": 1500
},
{
"productId": "demo-v1-stock-oil",
"name": "Масло оливковое",
"unit": "кг",
"gross": 0.028,
"net": 0.028,
"unitCost": 1100
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-tartlet",
"name": "Тарталетки готовые",
"unit": "кг",
"gross": 0.1,
"net": 0.1,
"unitCost": 750
},
{
"productId": "demo-v1-stock-mushroom",
"name": "Шампиньоны",
"unit": "кг",
"gross": 0.22,
"net": 0.15,
"unitCost": 340
},
{
"productId": "demo-v1-stock-cooking-cream",
"name": "Сливки",
"unit": "кг",
"gross": 0.05,
"net": 0.05,
"unitCost": 500
},
{
"productId": "demo-v1-stock-gouda",
"name": "Сыр гауда",
"unit": "кг",
"gross": 0.06,
"net": 0.06,
"unitCost": 900
},
{
"productId": "demo-v1-stock-onion",
"name": "Лук репчатый",
"unit": "кг",
"gross": 0.044,
"net": 0.03,
"unitCost": 80
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Брускетты с томатами и базиликом: Нарезать багет на 24 одинаковых ломтика и подсушить.",
"Брускетты с томатами и базиликом: Подготовить томаты и базилик, смешать с маслом.",
"Брускетты с томатами и базиликом: Распределить начинку по ломтикам, уложить в коробку.",
"Тарталетки с грибами и сыром: Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.",
"Тарталетки с грибами и сыром: Добавить сливки и распределить начинку по 20 тарталеткам.",
"Тарталетки с грибами и сыром: Добавить сыр, запечь до расплавления и упаковать после охлаждения.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Глютен",
"Молоко"
],
"ingredientCost": 448.12,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-water",
"name": "Вода негазированная, 0,5 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 90,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-water.webp",
"composition": [
"Одна бутылка 0,5 л"
],
"ingredients": [
[
"Демо: вода негазированная 0,5 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-sparkling",
"name": "Вода газированная, 0,5 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 100,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-sparkling.webp",
"composition": [
"Одна бутылка 0,5 л"
],
"ingredients": [
[
"Демо: вода газированная 0,5 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-juice",
"name": "Яблочный сок, 1 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 220,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-juice.webp",
"composition": [
"Одна упаковка 1 л"
],
"ingredients": [
[
"Демо: сок яблочный 1 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-mors",
"name": "Клюквенный морс, 1 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 280,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-mors.webp",
"composition": [
"Одна бутылка готового морса 1 л"
],
"ingredients": [
[
"Демо: морс клюквенный 1 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-plate",
"name": "Тарелка одноразовая, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 25,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-plate.webp",
"composition": [
"Одна сервировочная тарелка"
],
"ingredients": [
[
"Демо: тарелка одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-fork",
"name": "Вилка одноразовая, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 10,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-fork.webp",
"composition": [
"Одна вилка"
],
"ingredients": [
[
"Демо: вилка одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-glass",
"name": "Стакан одноразовый, 250 мл",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 15,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-glass.webp",
"composition": [
"Один стакан"
],
"ingredients": [
[
"Демо: стакан одноразовый 250 мл",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-napkin",
"name": "Салфетка сервировочная, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 10,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-napkin.webp",
"composition": [
"Одна бумажная салфетка"
],
"ingredients": [
[
"Демо: салфетка сервировочная",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-ice",
"name": "Лёд пищевой, пакет 1 кг",
"category": 2,
"catalogSection": "Для сервировки",
"price": 180,
"pieces": 1,
"weight": "1 кг",
"photo": "demo/images/extras-ice.webp",
"composition": [
"Один пакет пищевого льда, 1 кг"
],
"ingredients": [
[
"Демо: лёд пищевой",
1,
"кг"
]
],
"demo": true
},
{
"id": "demo-extras-v1-tablecloth",
"name": "Скатерть одноразовая, 1 шт.",
"category": 2,
"catalogSection": "Для сервировки",
"price": 250,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-tablecloth.webp",
"composition": [
"Одна одноразовая скатерть 120 × 180 см"
],
"ingredients": [
[
"Демо: скатерть одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-serving-kit",
"name": "Набор для подачи: щипцы и лопатка",
"category": 2,
"catalogSection": "Для сервировки",
"price": 190,
"pieces": 1,
"weight": "",
"photo": "demo/images/extras-serving-kit.webp",
"composition": [
"Один комплект одноразовых приборов для подачи"
],
"ingredients": [
[
"Демо: набор щипцы и лопатка",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-delivery-city",
"name": "Доставка по городу",
"category": 4,
"catalogSection": "Учебные тарифы доставки",
"price": 600,
"pieces": 0,
"weight": "",
"photo": "demo/images/extras-delivery-city.webp",
"composition": [
"Учебный тариф за один адрес в пределах города."
],
"ingredients": [],
"demo": true
},
{
"id": "demo-extras-v1-delivery-outer",
"name": "Доставка за город",
"category": 4,
"catalogSection": "Учебные тарифы доставки",
"price": 1200,
"pieces": 0,
"weight": "",
"photo": "demo/images/extras-delivery-outer.webp",
"composition": [
"Учебный тариф за один адрес в ближайшем пригороде."
],
"ingredients": [],
"demo": true
}
],
"stock": [
{
"id": "demo-extras-v1-stock-water",
"name": "Демо: вода негазированная 0,5 л",
"cost": 35,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-sparkling",
"name": "Демо: вода газированная 0,5 л",
"cost": 40,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-juice",
"name": "Демо: сок яблочный 1 л",
"cost": 110,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-mors",
"name": "Демо: морс клюквенный 1 л",
"cost": 140,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-plate",
"name": "Демо: тарелка одноразовая",
"cost": 12,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-fork",
"name": "Демо: вилка одноразовая",
"cost": 4,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-glass",
"name": "Демо: стакан одноразовый 250 мл",
"cost": 6,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-napkin",
"name": "Демо: салфетка сервировочная",
"cost": 3,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-ice",
"name": "Демо: лёд пищевой",
"cost": 70,
"unit": "кг",
"qty": 0,
"min": 1,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-tablecloth",
"name": "Демо: скатерть одноразовая",
"cost": 110,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-serving-kit",
"name": "Демо: набор щипцы и лопатка",
"cost": 85,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Some files were not shown because too many files have changed in this diff Show More